diff --git a/.env b/.env index 24104128..7674f0f4 100644 --- a/.env +++ b/.env @@ -21,6 +21,17 @@ AWS_REGION= AWS_S3_BUCKET_NAME= # optional +#LDAP_ENABLED=true +#LDAP_TLS_NO_VERIFY=false +#LDAP_URL='ldaps://ldap.example.com:636/' +#LDAP_BIND_USER='CN=searchuser,OU=systemaccounts,DC=example,DC=com' +#LDAP_BIND_PASSWORD='searchuserpassword' +#LDAP_SEARCH_DN='OU=users,DC=example,DC=com' +#LDAP_USERS_SEARCH_FILTER='(&(objectClass=Person)(objectCategory=Person)(|(mail={{email}})(sAMAccountName={{email}})))' +#LDAP_ATTRIBUTE_LAST_NAME='sn' +#LDAP_ATTRIBUTE_FIRST_NAME='givenName' +#LDAP_ATTRIBUTE_MAIL='mail' + #HTTPS_KEY_PATH='./secrets/ssl.key' #HTTPS_CERT_PATH='./secrets/ssl.cert' #SERVER_TIMEOUT=120000 diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index fa6e9f12..38bb3651 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -36,10 +36,28 @@ jobs: - name: Run e2e tests run: npm run test:e2e + - name: Dump docker logs on failure + if: failure() + uses: jwalton/gh-docker-logs@v2 + - name: Stop containers if: always() run: docker compose down + - name: Run ldap containers + run: docker compose -f docker-compose.yml -f docker-compose.ldap.yml up -d + + - name: Run ldap test + run: npm run test:ldap + + - name: Dump docker logs on failure + if: failure() + uses: jwalton/gh-docker-logs@v2 + + - name: Stop ldap containers + if: always() + run: docker compose -f docker-compose.yml -f docker-compose.ldap.yml down + - name: SonarCloud Scan uses: SonarSource/sonarcloud-github-action@master env: diff --git a/README.md b/README.md index 5fa06768..89671e00 100644 --- a/README.md +++ b/README.md @@ -20,4 +20,16 @@ ## Local HTTPS config - Generate keys [here](https://www.selfsignedcertificate.com/) -- place in folder `/secrets` named `ssl.cert` and `ssl.key` \ No newline at end of file +- place in folder `/secrets` named `ssl.cert` and `ssl.key` + +## Local LDAP test server + +- Run `docker compose -f docker-compose.yml -f docker-compose.ldap.yml ` (see [docker docs for multiple-compose-files - merge](https://docs.docker.com/compose/multiple-compose-files/merge/)) +- test the login with ldap and have a look at the logs + ```sh + curl 'http://localhost:4200/users/login' \ + -H 'accept: */*' \ + -H 'accept-language: de-DE,de;q=0.9,en-US;q=0.8,en;q=0.7,fr;q=0.6' \ + -H 'content-type: application/json' \ + --data-raw '{"email":"developer.one@ldapmock.local","password":"password"}' + ``` \ No newline at end of file diff --git a/docker-compose.ldap.yml b/docker-compose.ldap.yml new file mode 100644 index 00000000..83acfd7b --- /dev/null +++ b/docker-compose.ldap.yml @@ -0,0 +1,27 @@ +services: + api: + environment: + LDAP_ENABLED: 'true' + LDAP_TLS_NO_VERIFY: 'true' + LDAP_URL: 'ldaps://ldapmock:636/' + LDAP_BIND_USER: 'cn=admin,dc=ldapmock,dc=local' + LDAP_BIND_PASSWORD: 'adminpass' + LDAP_SEARCH_DN: 'ou=people,dc=ldapmock,dc=local' + LDAP_USERS_SEARCH_FILTER: '(&(objectClass=person)(mail={{email}}))' + LDAP_ATTRIBUTE_LAST_NAME: 'sn' + LDAP_ATTRIBUTE_FIRST_NAME: 'givenName' + LDAP_ATTRIBUTE_MAIL: 'mail' + depends_on: + postgres: + condition: service_healthy + ldapmock: + condition: service_started + ldapmock: + container_name: ldapmock + # See https://github.com/docker-ThoTeam/slapd-server-mock/tree/main + # Default users in LDAP: https://github.com/docker-ThoTeam/slapd-server-mock/blob/main/bootstrap/data.ldif.TEMPLATE + # e.g.: developer.one@ldapmock.local:password + image: thoteam/slapd-server-mock:latest + restart: always + ports: + - "636:636" \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index ac804aa2..678fd256 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,6 +30,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "fs-extra": "^11.1.1", + "ldapts": "^7.1.0", "looks-same": "^9.0.0", "odiff-bin": "^2.6.1", "passport": "^0.6.0", @@ -3836,6 +3837,14 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "node_modules/@types/asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@types/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/babel__core": { "version": "7.20.2", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.2.tgz", @@ -5153,6 +5162,14 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true }, + "node_modules/asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "dependencies": { + "safer-buffer": "~2.1.0" + } + }, "node_modules/async": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", @@ -6093,9 +6110,9 @@ } }, "node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "dependencies": { "ms": "2.1.2" }, @@ -9076,6 +9093,33 @@ "node": ">=6" } }, + "node_modules/ldapts": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-7.1.0.tgz", + "integrity": "sha512-EGHJC1L9xFd9Qxevkq4hTi4I8KQ9Eh3F8Uzv7m1dviu5D8Ryq2Q4a52ddb49bDOv40UZuc37tuV94YPf+Ub/1g==", + "dependencies": { + "@types/asn1": ">=0.2.4", + "asn1": "~0.2.6", + "debug": "~4.3.5", + "strict-event-emitter-types": "~2.0.0", + "uuid": "~10.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/ldapts/node_modules/uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "bin": { + "uuid": "dist/bin/uuid" + } + }, "node_modules/leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -11327,6 +11371,11 @@ "queue-tick": "^1.0.1" } }, + "node_modules/strict-event-emitter-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", + "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==" + }, "node_modules/string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", @@ -15489,6 +15538,14 @@ "integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==", "dev": true }, + "@types/asn1": { + "version": "0.2.4", + "resolved": "https://registry.npmjs.org/@types/asn1/-/asn1-0.2.4.tgz", + "integrity": "sha512-V91DSJ2l0h0gRhVP4oBfBzRBN9lAbPUkGDMCnwedqPKX2d84aAMc9CulOvxdw1f7DfEYx99afab+Rsm3e52jhA==", + "requires": { + "@types/node": "*" + } + }, "@types/babel__core": { "version": "7.20.2", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.2.tgz", @@ -16525,6 +16582,14 @@ "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", "dev": true }, + "asn1": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/asn1/-/asn1-0.2.6.tgz", + "integrity": "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ==", + "requires": { + "safer-buffer": "~2.1.0" + } + }, "async": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/async/-/async-3.2.3.tgz", @@ -17217,9 +17282,9 @@ } }, "debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "version": "4.3.5", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.5.tgz", + "integrity": "sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==", "requires": { "ms": "2.1.2" } @@ -19438,6 +19503,25 @@ "integrity": "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==", "dev": true }, + "ldapts": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/ldapts/-/ldapts-7.1.0.tgz", + "integrity": "sha512-EGHJC1L9xFd9Qxevkq4hTi4I8KQ9Eh3F8Uzv7m1dviu5D8Ryq2Q4a52ddb49bDOv40UZuc37tuV94YPf+Ub/1g==", + "requires": { + "@types/asn1": ">=0.2.4", + "asn1": "~0.2.6", + "debug": "~4.3.5", + "strict-event-emitter-types": "~2.0.0", + "uuid": "~10.0.0" + }, + "dependencies": { + "uuid": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz", + "integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==" + } + } + }, "leven": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz", @@ -21088,6 +21172,11 @@ "queue-tick": "^1.0.1" } }, + "strict-event-emitter-types": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/strict-event-emitter-types/-/strict-event-emitter-types-2.0.0.tgz", + "integrity": "sha512-Nk/brWYpD85WlOgzw5h173aci0Teyv8YdIAEtV+N88nDB0dLlazZyJMIsN6eo1/AR61l+p6CJTG1JIyFaoNEEA==" + }, "string_decoder": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz", diff --git a/package.json b/package.json index a817f731..c1f9f364 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,8 @@ "test:cov": "jest --projects src --coverage", "test:debug": "node --inspect-brk -r tsconfig-paths/register -r ts-node/register node_modules/.bin/jest --projects src --runInBand", "test:e2e": "jest --projects test", - "test:acceptance": "jest --projects test_acceptance" + "test:acceptance": "jest --projects test_acceptance", + "test:ldap": "jest --projects test_ldap" }, "engines": { "node": ">=18.12.0" @@ -47,6 +48,7 @@ "class-transformer": "^0.5.1", "class-validator": "^0.14.0", "fs-extra": "^11.1.1", + "ldapts": "^7.1.0", "looks-same": "^9.0.0", "odiff-bin": "^2.6.1", "passport": "^0.6.0", diff --git a/src/users/db/dbusers.service.ts b/src/users/db/dbusers.service.ts new file mode 100644 index 00000000..da8d4954 --- /dev/null +++ b/src/users/db/dbusers.service.ts @@ -0,0 +1,75 @@ +import { HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { CreateUserDto } from '../dto/user-create.dto'; +import { UserLoginResponseDto } from '../dto/user-login-response.dto'; +import { PrismaService } from '../../prisma/prisma.service'; +import { User } from '@prisma/client'; +import { UpdateUserDto } from '../dto/user-update.dto'; +import { AuthService } from '../../auth/auth.service'; +import { UserLoginRequestDto } from '../dto/user-login-request.dto'; +import { Users } from '../users.interface'; + +export class DbUsersService implements Users { + private readonly logger: Logger = new Logger(DbUsersService.name); + + constructor( + private readonly prismaService: PrismaService, + private readonly authService: AuthService + ) {} + + async create(createUserDto: CreateUserDto): Promise { + const user = { + email: createUserDto.email.trim().toLowerCase(), + firstName: createUserDto.firstName, + lastName: createUserDto.lastName, + apiKey: this.authService.generateApiKey(), + password: await this.authService.encryptPassword(createUserDto.password), + }; + + const userData = await this.prismaService.user.create({ + data: user, + }); + + return new UserLoginResponseDto(userData, null); + } + + async update(id: string, userDto: UpdateUserDto): Promise { + const user = await this.prismaService.user.update({ + where: { id }, + data: { + email: userDto.email, + firstName: userDto.firstName, + lastName: userDto.lastName, + }, + }); + const token = this.authService.signToken(user); + return new UserLoginResponseDto(user, token); + } + + async changePassword(user: User, newPassword: string): Promise { + await this.prismaService.user.update({ + where: { id: user.id }, + data: { + password: await this.authService.encryptPassword(newPassword), + }, + }); + return true; + } + + async login(userLoginRequestDto: UserLoginRequestDto) { + const user = await this.prismaService.user.findUnique({ + where: { email: userLoginRequestDto.email }, + }); + if (!user) { + throw new HttpException('Invalid email or password.', HttpStatus.BAD_REQUEST); + } + + const isMatch = await this.authService.compare(userLoginRequestDto.password, user.password); + + if (!isMatch) { + throw new HttpException('Invalid email or password.', HttpStatus.BAD_REQUEST); + } + + const token = this.authService.signToken(user); + return new UserLoginResponseDto(user, token); + } +} diff --git a/src/users/ldap/ldapusers.service.spec.ts b/src/users/ldap/ldapusers.service.spec.ts new file mode 100644 index 00000000..53c8dd74 --- /dev/null +++ b/src/users/ldap/ldapusers.service.spec.ts @@ -0,0 +1,176 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LdapUsersService } from './ldapusers.service'; +import { AuthService } from '../../auth/auth.service'; +import { PrismaService } from '../../prisma/prisma.service'; +import { ConfigService } from '@nestjs/config'; +import { Role, User } from '@prisma/client'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Entry as LdapEntry, Client as LdapClient, SearchOptions, SearchResult } from 'ldapts'; + +jest.mock('ldapts', () => { + const mockLdapClient = { + // eslint-disable-next-line @typescript-eslint/no-unused-vars + search: jest.fn((_searchDN, searchOptions: SearchOptions): Promise => { + if (searchOptions.filter.toString().includes('exists@example.com')) { + return Promise.resolve({ + searchEntries: [ + { + dn: 'dn', + LDAP_ATTRIBUTE_MAIL: 'exists@example.com', + LDAP_ATTRIBUTE_FIRST_NAME: 'first', + LDAP_ATTRIBUTE_LAST_NAME: 'last', + }, + ], + searchReferences: [], + }); + } else { + return Promise.resolve({ searchEntries: [], searchReferences: [] }); + } + }), + bind: jest.fn(), + unbind: jest.fn(), + }; + + return { + Client: jest.fn(() => mockLdapClient), + }; +}); + +const user: User = { + id: '1', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + apiKey: 'generatedApiKey', + password: 'encryptedPassword', + isActive: true, + role: Role.editor, + updatedAt: new Date(), + createdAt: new Date(), +}; + +describe('LdapUsersService match from ldap', () => { + let service: LdapUsersService; + let prismaService: PrismaService; + let configService: ConfigService; + let authService: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue('true'), + getOrThrow: jest.fn((string) => (string === 'LDAP_USERS_SEARCH_FILTER' ? '(&(mail={{email}}))' : string)), + }, + }, + { + provide: PrismaService, + useValue: { + user: { + findMany: jest.fn().mockResolvedValueOnce([]), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }, + }, + { + provide: AuthService, + useValue: { + generateApiKey: jest.fn(() => 'generatedApiKey'), + encryptPassword: jest.fn(() => 'encryptedPassword'), + signToken: jest.fn(() => 'token'), + }, + }, + ], + }).compile(); + configService = module.get(ConfigService); + prismaService = module.get(PrismaService); + authService = module.get(AuthService); + service = new LdapUsersService(configService, prismaService, authService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should create new user on login', async () => { + jest.spyOn(prismaService.user, 'findUnique').mockResolvedValue(undefined); + const prismaUserCreateMock = jest.spyOn(prismaService.user, 'create').mockResolvedValue(user); + await service.login({ email: 'exists@example.com', password: 'password' }); + expect(prismaUserCreateMock).toBeCalled(); + }); + + it('should login with user already in db', async () => { + jest.spyOn(prismaService.user, 'findUnique').mockResolvedValue(user); + const prismaUserCreateMock = jest.spyOn(prismaService.user, 'create'); + await service.login({ email: 'exists@example.com', password: 'password' }); + expect(prismaUserCreateMock).not.toBeCalled(); + }); +}); + +describe('LdapUsersService with no results from ldap', () => { + let service: LdapUsersService; + let prismaService: PrismaService; + let configService: ConfigService; + let authService: AuthService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: ConfigService, + useValue: { + get: jest.fn().mockReturnValue('true'), + getOrThrow: jest.fn().mockReturnValue('string'), + }, + }, + { + provide: PrismaService, + useValue: { + user: { + findMany: jest.fn().mockResolvedValueOnce([]), + create: jest.fn(), + update: jest.fn(), + }, + }, + }, + { + provide: AuthService, + useValue: { + generateApiKey: jest.fn(() => 'generatedApiKey'), + encryptPassword: jest.fn(() => 'encryptedPassword'), + }, + }, + ], + }).compile(); + configService = module.get(ConfigService); + prismaService = module.get(PrismaService); + authService = module.get(AuthService); + service = new LdapUsersService(configService, prismaService, authService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should never change a password', async () => { + const prismaUserCreateMock = jest.spyOn(prismaService.user, 'create'); + const prismaUserUpdateMock = jest.spyOn(prismaService.user, 'update'); + const result = await service.changePassword(user, 'newPassword'); + expect(prismaUserCreateMock).not.toBeCalled(); + expect(prismaUserUpdateMock).not.toBeCalled(); + expect(result).toBe(true); + }); + + it('should not login when ldap search results are empty', async () => { + expect.assertions(1); + try { + await service.login({ email: 'testÜ*()\\\0@example.com', password: 'password' }); + } catch (e) { + expect(e.message).toBe('Invalid email or password.'); + } + }); +}); diff --git a/src/users/ldap/ldapusers.service.ts b/src/users/ldap/ldapusers.service.ts new file mode 100644 index 00000000..39aaa490 --- /dev/null +++ b/src/users/ldap/ldapusers.service.ts @@ -0,0 +1,195 @@ +import { HttpException, HttpStatus, Logger } from '@nestjs/common'; +import { CreateUserDto } from '../dto/user-create.dto'; +import { UserLoginResponseDto } from '../dto/user-login-response.dto'; +import { PrismaService } from '../../prisma/prisma.service'; +import { Role, User } from '@prisma/client'; +import { UpdateUserDto } from '../dto/user-update.dto'; +import { AuthService } from '../../auth/auth.service'; +import { UserLoginRequestDto } from '../dto/user-login-request.dto'; +import { Entry as LdapEntry, Client as LdapClient } from 'ldapts'; +import { Users } from '../users.interface'; +import { ConfigService } from '@nestjs/config'; +import { genSaltSync } from 'bcryptjs'; + +type LDAPConfig = { + url: string; + bindUser: string; + bindPassword: string; + searchDN: string; + usersSearchFilter: string; + attributeMail: string; + attributeFirstName: string; + attributeLastName: string; + tlsNoVerify: boolean; +}; +export class LdapUsersService implements Users { + private readonly ldapClient: LdapClient; + private readonly logger: Logger = new Logger(LdapUsersService.name); + private readonly ldapConfig: LDAPConfig; + + constructor( + private readonly configService: ConfigService, + private readonly prismaService: PrismaService, + private readonly authService: AuthService + ) { + this.ldapConfig = { + url: this.configService.getOrThrow('LDAP_URL'), + bindUser: this.configService.getOrThrow('LDAP_BIND_USER'), + bindPassword: this.configService.getOrThrow('LDAP_BIND_PASSWORD'), + searchDN: this.configService.getOrThrow('LDAP_SEARCH_DN'), + usersSearchFilter: this.configService.getOrThrow('LDAP_USERS_SEARCH_FILTER'), + attributeMail: this.configService.getOrThrow('LDAP_ATTRIBUTE_MAIL'), + attributeFirstName: this.configService.getOrThrow('LDAP_ATTRIBUTE_FIRST_NAME'), + attributeLastName: this.configService.getOrThrow('LDAP_ATTRIBUTE_LAST_NAME'), + tlsNoVerify: this.configService.get('LDAP_TLS_NO_VERIFY', 'false').toLowerCase() === 'true', + }; + this.ldapClient = new LdapClient({ + url: this.ldapConfig.url, + tlsOptions: { + rejectUnauthorized: !this.ldapConfig.tlsNoVerify, + }, + }); + } + + private async findUserInLdap(email: string): Promise { + //escape the email address for LDAP search + email = this.escapeLdap(email.trim()); + this.logger.verbose(`search '${email}' in LDAP`); + try { + await this.ldapClient.bind(this.ldapConfig.bindUser, this.ldapConfig.bindPassword); + const attributes = [ + 'dn', + this.ldapConfig.attributeMail, + this.ldapConfig.attributeFirstName, + this.ldapConfig.attributeLastName, + ]; + + const { searchEntries } = await this.ldapClient.search(this.ldapConfig.searchDN, { + filter: this.ldapConfig.usersSearchFilter.replaceAll('{{email}}', email), + sizeLimit: 1, + attributes: attributes, + }); + if (searchEntries.length === 0) { + this.logger.log('User not found in LDAP.'); + throw new HttpException('Invalid email or password.', HttpStatus.BAD_REQUEST); + } + for (const attribute of attributes) { + if (searchEntries[0][attribute] == null) { + this.logger.warn(`Attribute '${attribute}' not found in LDAP entry found for '${email}'`); + throw new HttpException('Invalid email or password.', HttpStatus.BAD_REQUEST); + } + } + return searchEntries[0]; + } finally { + await this.ldapClient.unbind(); + } + } + + private async createUserFromLdapEntry(ldapEntry: LdapEntry): Promise { + const userForVRTDb = { + email: ldapEntry[this.ldapConfig.attributeMail].toString().trim().toLowerCase(), + firstName: ldapEntry[this.ldapConfig.attributeFirstName].toString(), + lastName: ldapEntry[this.ldapConfig.attributeLastName].toString(), + apiKey: this.authService.generateApiKey(), + password: await this.authService.encryptPassword(genSaltSync(1).slice(-12)), + role: Role.editor, + }; + + return this.prismaService.user.create({ + data: userForVRTDb, + }); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async create(createUserDto: CreateUserDto): Promise { + throw new HttpException('User creation is disabled. Use your LDAP-Credentials to login.', HttpStatus.BAD_REQUEST); + } + + async update(id: string, userDto: UpdateUserDto): Promise { + const userFromLdap = await this.findUserInLdap(userDto.email); + const user = await this.prismaService.user.update({ + where: { id }, + data: { + email: userFromLdap.mail.toString().trim().toLowerCase(), + firstName: userFromLdap.givenName.toString(), + lastName: userFromLdap.sn.toString(), + }, + }); + const token = this.authService.signToken(user); + return new UserLoginResponseDto(user, token); + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async changePassword(user: User, newPassword: string): Promise { + this.logger.warn(`${user.email} tied to change password - this is not supported for LDAP users`); + return true; + } + + async login(userLoginRequestDto: UserLoginRequestDto) { + const userFromLdap = await this.findUserInLdap(userLoginRequestDto.email); + if (!userFromLdap) { + throw new HttpException('Invalid email or password.', HttpStatus.BAD_REQUEST); + } + + // check if user password is correct using ldap + try { + await this.ldapClient.bind(userFromLdap.dn, userLoginRequestDto.password); + } catch (e) { + throw new HttpException('Invalid email or password.', HttpStatus.BAD_REQUEST); + } finally { + await this.ldapClient.unbind(); + } + + const userEmailFromLdap = userFromLdap[this.ldapConfig.attributeMail].toString().trim().toLowerCase(); + + let user = await this.prismaService.user.findUnique({ + where: { email: userEmailFromLdap }, + }); + + if (!user) { + // create user if not found in VRT database + this.logger.log( + `'${userLoginRequestDto.email}' (found in ldap as '${userEmailFromLdap}') successfully ` + + 'authenticated via LDAP, but not found in VRT database. Creating user.' + ); + user = await this.createUserFromLdapEntry(userFromLdap); + } + + const token = this.authService.signToken(user); + return new UserLoginResponseDto(user, token); + } + + /** + * RFC 2254 Escaping of filter strings + * Raw Escaped + * (o=Parens (R Us)) (o=Parens \28R Us\29) + * (cn=star*) (cn=star\2A) + * (filename=C:\MyFile) (filename=C:\5cMyFile) + */ + private escapeLdap(input: string): string { + let escapedResult = ''; + for (const inputChar of input) { + switch (inputChar) { + case '*': + escapedResult += '\\2a'; + break; + case '(': + escapedResult += '\\28'; + break; + case ')': + escapedResult += '\\29'; + break; + case '\\': + escapedResult += '\\5c'; + break; + case '\0': + escapedResult += '\\00'; + break; + default: + escapedResult += inputChar; + break; + } + } + return escapedResult; + } +} diff --git a/src/users/users.factory.spec.ts b/src/users/users.factory.spec.ts new file mode 100644 index 00000000..bb98115a --- /dev/null +++ b/src/users/users.factory.spec.ts @@ -0,0 +1,59 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { ConfigService } from '@nestjs/config'; +import { UsersFactoryService } from './users.factory'; +import { DbUsersService } from './db/dbusers.service'; +import { LdapUsersService } from './ldap/ldapusers.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthService } from '../auth/auth.service'; +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { Client } from 'ldapts'; +jest.mock('ldapts'); + +describe('UsersFactoryService', () => { + let service: UsersFactoryService; + let configService: ConfigService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + UsersFactoryService, + { + provide: ConfigService, + useValue: { + get: jest.fn(), + getOrThrow: jest.fn(), + }, + }, + { + provide: PrismaService, + useValue: { + user: { + findMany: jest.fn().mockResolvedValueOnce([]), + }, + }, + }, + { provide: AuthService, useValue: {} }, + ], + }).compile(); + + service = module.get(UsersFactoryService); + configService = module.get(ConfigService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should return DbUsersService when LDAP_ENABLED is false', () => { + jest.spyOn(configService, 'get').mockReturnValue('false'); + const result = service.getUsersService(); + expect(result).toBeInstanceOf(DbUsersService); + }); + + it('should return LdapUsersService when LDAP_ENABLED is true', () => { + jest.spyOn(configService, 'get').mockReturnValue('true'); + jest.spyOn(configService, 'getOrThrow').mockReturnValue('mockedValue'); + const result = service.getUsersService(); + expect(result).toBeInstanceOf(LdapUsersService); + }); +}); diff --git a/src/users/users.factory.ts b/src/users/users.factory.ts new file mode 100644 index 00000000..bd9073a7 --- /dev/null +++ b/src/users/users.factory.ts @@ -0,0 +1,31 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { Users } from './users.interface'; +import { DbUsersService } from './db/dbusers.service'; +import { LdapUsersService } from './ldap/ldapusers.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuthService } from '../auth/auth.service'; + +@Injectable() +export class UsersFactoryService { + private readonly logger: Logger = new Logger(UsersFactoryService.name); + + constructor( + private readonly configService: ConfigService, + private readonly prismaService: PrismaService, + private readonly authService: AuthService + ) {} + + getUsersService(): Users { + const serviceType = this.configService.get('LDAP_ENABLED', 'false')?.toLowerCase() === 'true'; + switch (serviceType) { + case true: + this.logger.debug('users service type: LDAP'); + return new LdapUsersService(this.configService, this.prismaService, this.authService); + case false: + default: + this.logger.debug('users service type: DB'); + return new DbUsersService(this.prismaService, this.authService); + } + } +} diff --git a/src/users/users.interface.ts b/src/users/users.interface.ts new file mode 100644 index 00000000..4ac84267 --- /dev/null +++ b/src/users/users.interface.ts @@ -0,0 +1,12 @@ +import { CreateUserDto } from './dto/user-create.dto'; +import { UserLoginResponseDto } from './dto/user-login-response.dto'; +import { User } from '@prisma/client'; +import { UpdateUserDto } from './dto/user-update.dto'; +import { UserLoginRequestDto } from './dto/user-login-request.dto'; + +export interface Users { + create(createUserDto: CreateUserDto): Promise; + update(id: string, userDto: UpdateUserDto): Promise; + changePassword(user: User, newPassword: string): Promise; + login(userLoginRequestDto: UserLoginRequestDto): Promise; +} diff --git a/src/users/users.module.ts b/src/users/users.module.ts index 7d54196b..61335c99 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,12 +1,12 @@ import { Module } from '@nestjs/common'; import { UsersService } from './users.service'; -import { PrismaService } from '../prisma/prisma.service'; import { UsersController } from './users.controller'; import { AuthModule } from '../auth/auth.module'; +import { UsersFactoryService } from './users.factory'; @Module({ imports: [AuthModule], - providers: [UsersService, PrismaService], + providers: [UsersService, UsersFactoryService], exports: [UsersService], controllers: [UsersController], }) diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index 8c1f578e..8a9ad57e 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -2,30 +2,190 @@ import { Test, TestingModule } from '@nestjs/testing'; import { UsersService } from './users.service'; import { AuthService } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; +import { ConfigService } from '@nestjs/config'; +import { UsersFactoryService } from './users.factory'; +import { Role, User } from '@prisma/client'; +import { UserLoginResponseDto } from './dto/user-login-response.dto'; +import { AssignRoleDto } from './dto/assign-role.dto'; +import { randomUUID } from 'crypto'; +import { UserDto } from './dto/user.dto'; +import { UpdateUserDto } from './dto/user-update.dto'; -describe('UsersService', () => { +const user: User = { + id: '1', + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + apiKey: 'generatedApiKey', + password: 'encryptedPassword', + isActive: true, + role: Role.editor, + updatedAt: new Date(), + createdAt: new Date(), +}; + +describe('UsersService with DbUserService implementation', () => { let service: UsersService; + let prismaService: PrismaService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ UsersService, + UsersFactoryService, + { + provide: ConfigService, + useValue: { + get: jest.fn(), + }, + }, { provide: PrismaService, useValue: { user: { findMany: jest.fn().mockResolvedValueOnce([]), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + findUnique: jest.fn().mockResolvedValueOnce(user), }, }, }, - { provide: AuthService, useValue: {} }, + { + provide: AuthService, + useValue: { + generateApiKey: jest.fn(() => 'generatedApiKey'), + encryptPassword: jest.fn(() => 'encryptedPassword'), + compare: jest.fn(() => true), + signToken: jest.fn(() => 'token'), + }, + }, ], }).compile(); service = module.get(UsersService); + prismaService = module.get(PrismaService); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + it('should generate new api key', async () => { + // Arrange + const prismaUserUpdateMock = jest.spyOn(prismaService.user, 'update'); + prismaUserUpdateMock.mockResolvedValueOnce(user); + + // Act + const result = await service.generateNewApiKey(user); + + // Assert + expect(prismaUserUpdateMock).toHaveBeenCalled(); + expect(typeof result).toBe('string'); + }); + + it('should create a new user', async () => { + // Arrange + const createUserDto = { + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + password: 'password123', + }; + + const prismaUserCreateMock = jest.spyOn(prismaService.user, 'create'); + prismaUserCreateMock.mockResolvedValueOnce(user); + + // Act + const result = await service.create(createUserDto); + + // Assert + expect(prismaUserCreateMock).toHaveBeenCalledWith({ + data: { + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + apiKey: 'generatedApiKey', + password: 'encryptedPassword', + }, + }); + expect(result).toEqual(expect.any(UserLoginResponseDto)); + }); + + it('should update a user', async () => { + // Arrange + const updateUserDto: UpdateUserDto = { + email: 'test@example.com', + firstName: 'John', + lastName: 'Doe', + }; + + const prismaUserUpdateMock = jest.spyOn(prismaService.user, 'update'); + prismaUserUpdateMock.mockResolvedValueOnce(user); + + // Act + const result = await service.update(user.id, updateUserDto); + + // Assert + expect(prismaUserUpdateMock).toHaveBeenCalled(); + expect(result).toEqual(expect.any(UserLoginResponseDto)); + }); + + it('should change password', async () => { + // Arrange + const prismaUserUpdateMock = jest.spyOn(prismaService.user, 'update'); + prismaUserUpdateMock.mockResolvedValueOnce(user); + + // Act + const result = await service.changePassword(user, 'newPassword'); + + // Assert + expect(result).toEqual(true); + }); + + it('should login existing user', async () => { + // Act + const result = await service.login({ email: 'test@example.com', password: 'doesntmatter' }); + + // Assert + expect(result).toEqual(expect.any(UserLoginResponseDto)); + }); + + it('should delete existing user', async () => { + // Arrange + const prismaUserDeleteMock = jest.spyOn(prismaService.user, 'delete'); + prismaUserDeleteMock.mockResolvedValueOnce(user); + + // Act + await service.delete(user.id); + + // Assert + expect(prismaUserDeleteMock).toHaveBeenCalled(); + }); + + it('should get existing user', async () => { + // Act + const result = await service.get(user.id); + + // Assert + expect(result).toBeInstanceOf(UserDto); + }); + + it('should assign role to user', async () => { + // Arrange + const assignRoleDto: AssignRoleDto = { + id: randomUUID(), + role: Role.editor, + }; + + const prismaUserCreateMock = jest.spyOn(prismaService.user, 'update'); + prismaUserCreateMock.mockResolvedValueOnce(user); + + // Act + const result = await service.assignRole(assignRoleDto); + + // Assert + expect(prismaUserCreateMock).toHaveBeenCalled(); + expect(result).toBeInstanceOf(UserDto); + }); }); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index a63d7fc4..64927d1c 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,4 +1,4 @@ -import { Injectable, HttpException, HttpStatus } from '@nestjs/common'; +import { Injectable } from '@nestjs/common'; import { CreateUserDto } from './dto/user-create.dto'; import { UserLoginResponseDto } from './dto/user-login-response.dto'; import { PrismaService } from '../prisma/prisma.service'; @@ -9,30 +9,36 @@ import { AuthService } from '../auth/auth.service'; import { UserLoginRequestDto } from './dto/user-login-request.dto'; import { AssignRoleDto } from './dto/assign-role.dto'; import { Logger } from '@nestjs/common'; +import { UsersFactoryService } from './users.factory'; +import { Users } from './users.interface'; @Injectable() export class UsersService { private readonly logger: Logger = new Logger(UsersService.name); + private readonly usersService: Users; constructor( + private readonly usersFactoryService: UsersFactoryService, private prismaService: PrismaService, private authService: AuthService - ) {} + ) { + this.usersService = this.usersFactoryService.getUsersService(); + } async create(createUserDto: CreateUserDto): Promise { - const user = { - email: createUserDto.email.trim().toLowerCase(), - firstName: createUserDto.firstName, - lastName: createUserDto.lastName, - apiKey: this.authService.generateApiKey(), - password: await this.authService.encryptPassword(createUserDto.password), - }; + return this.usersService.create(createUserDto); + } - const userData = await this.prismaService.user.create({ - data: user, - }); + async update(id: string, userDto: UpdateUserDto): Promise { + return this.usersService.update(id, userDto); + } + + async changePassword(user: User, newPassword: string): Promise { + return this.usersService.changePassword(user, newPassword); + } - return new UserLoginResponseDto(userData, null); + async login(userLoginRequestDto: UserLoginRequestDto) { + return this.usersService.login(userLoginRequestDto); } async findOne(id: string): Promise { @@ -60,19 +66,6 @@ export class UsersService { return new UserDto(user); } - async update(id: string, userDto: UpdateUserDto): Promise { - const user = await this.prismaService.user.update({ - where: { id }, - data: { - email: userDto.email, - firstName: userDto.firstName, - lastName: userDto.lastName, - }, - }); - const token = this.authService.signToken(user); - return new UserLoginResponseDto(user, token); - } - async generateNewApiKey(user: User): Promise { const newApiKey = this.authService.generateApiKey(); await this.prismaService.user.update({ @@ -83,32 +76,4 @@ export class UsersService { }); return newApiKey; } - - async changePassword(user: User, newPassword: string): Promise { - await this.prismaService.user.update({ - where: { id: user.id }, - data: { - password: await this.authService.encryptPassword(newPassword), - }, - }); - return true; - } - - async login(userLoginRequestDto: UserLoginRequestDto) { - const user = await this.prismaService.user.findUnique({ - where: { email: userLoginRequestDto.email }, - }); - if (!user) { - throw new HttpException('Invalid email or password.', HttpStatus.BAD_REQUEST); - } - - const isMatch = await this.authService.compare(userLoginRequestDto.password, user.password); - - if (!isMatch) { - throw new HttpException('Invalid email or password.', HttpStatus.BAD_REQUEST); - } - - const token = this.authService.signToken(user); - return new UserLoginResponseDto(user, token); - } } diff --git a/test_ldap/jest.config.ts b/test_ldap/jest.config.ts new file mode 100644 index 00000000..a7ec3362 --- /dev/null +++ b/test_ldap/jest.config.ts @@ -0,0 +1,14 @@ +/** @returns {Promise} */ +module.exports = async () => { + return { + displayName: 'Ldap', + roots: ['./'], + testTimeout: 30000, + testRegex: '.spec.ts$', + moduleFileExtensions: ['js', 'json', 'ts'], + transform: { + '^.+\\.(t|j)s$': 'ts-jest', + }, + testEnvironment: 'node', + }; +}; diff --git a/test_ldap/ldap.spec.ts b/test_ldap/ldap.spec.ts new file mode 100644 index 00000000..475507eb --- /dev/null +++ b/test_ldap/ldap.spec.ts @@ -0,0 +1,30 @@ +import axios from 'axios'; +import { UserLoginRequestDto } from 'src/users/dto/user-login-request.dto'; +import { UserLoginResponseDto } from 'src/users/dto/user-login-response.dto'; + +axios.defaults.baseURL = 'http://localhost:4200'; + +const loginData: UserLoginRequestDto = { + email: `developer.one@ldapmock.local`, + password: 'password', +}; +const wrongLoginData: UserLoginRequestDto = { + email: loginData.email, + password: 'wrongpassword', +}; + +describe('Ldap', () => { + test('Sucessful login', async () => { + const response = await axios.post('/users/login', loginData); + + expect(response.status).toBe(201); + expect(response.data.token).toBeDefined(); + }); + + test('Unsucessful login', async () => { + await axios.post('/users/login', wrongLoginData).catch((error) => { + expect(error.response.status).toBe(400); + expect(error.response.data.message).toContain('Invalid email or password.'); + }); + }); +});