From ecc5726e223e1cba16cf64f3639afe43e3f0f78f Mon Sep 17 00:00:00 2001 From: Mario Melcher Date: Tue, 16 Jul 2024 13:54:21 +0200 Subject: [PATCH 1/9] move user service --- .../{users.service.spec.ts => dbusers.service.spec.ts} | 8 ++++---- src/users/{users.service.ts => dbusers.service.ts} | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) rename src/users/{users.service.spec.ts => dbusers.service.spec.ts} (80%) rename src/users/{users.service.ts => dbusers.service.ts} (95%) diff --git a/src/users/users.service.spec.ts b/src/users/dbusers.service.spec.ts similarity index 80% rename from src/users/users.service.spec.ts rename to src/users/dbusers.service.spec.ts index 8c1f578e..441242ad 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/dbusers.service.spec.ts @@ -1,15 +1,15 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { UsersService } from './users.service'; +import { DbUsersService } from './dbusers.service'; import { AuthService } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; describe('UsersService', () => { - let service: UsersService; + let service: DbUsersService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - UsersService, + DbUsersService, { provide: PrismaService, useValue: { @@ -22,7 +22,7 @@ describe('UsersService', () => { ], }).compile(); - service = module.get(UsersService); + service = module.get(DbUsersService); }); it('should be defined', () => { diff --git a/src/users/users.service.ts b/src/users/dbusers.service.ts similarity index 95% rename from src/users/users.service.ts rename to src/users/dbusers.service.ts index a63d7fc4..da308748 100644 --- a/src/users/users.service.ts +++ b/src/users/dbusers.service.ts @@ -9,10 +9,11 @@ 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 { UsersService } from './users.service'; @Injectable() -export class UsersService { - private readonly logger: Logger = new Logger(UsersService.name); +export class DbUsersService implements UsersService { + private readonly logger: Logger = new Logger(DbUsersService.name); constructor( private prismaService: PrismaService, From 51db2d5ed0bbfe14efb49a9d31cc0126f51fba20 Mon Sep 17 00:00:00 2001 From: Mario Melcher Date: Tue, 16 Jul 2024 13:56:58 +0200 Subject: [PATCH 2/9] add ldap user service --- .env | 11 ++ package-lock.json | 101 +++++++++++- package.json | 1 + src/app.module.ts | 2 +- src/builds/builds.module.ts | 2 +- src/users/dbusers.service.spec.ts | 54 ++++++- src/users/ldapusers.service.spec.ts | 39 +++++ src/users/ldapusers.service.ts | 230 ++++++++++++++++++++++++++++ src/users/users.module.ts | 41 +++-- src/users/users.service.ts | 19 +++ 10 files changed, 481 insertions(+), 19 deletions(-) create mode 100644 src/users/ldapusers.service.spec.ts create mode 100644 src/users/ldapusers.service.ts create mode 100644 src/users/users.service.ts 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/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..eaab90de 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,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/app.module.ts b/src/app.module.ts index f5784f09..7bcf3e8f 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,7 +22,7 @@ import { HealthController } from './health/health.controller'; CacheModule.register(), ScheduleModule.forRoot(), AuthModule, - UsersModule, + UsersModule.register(), BuildsModule, ProjectsModule, TestRunsModule, diff --git a/src/builds/builds.module.ts b/src/builds/builds.module.ts index cce932ba..c25c5420 100644 --- a/src/builds/builds.module.ts +++ b/src/builds/builds.module.ts @@ -9,7 +9,7 @@ import { AuthModule } from '../auth/auth.module'; import { ProjectsModule } from '../projects/projects.module'; @Module({ - imports: [SharedModule, UsersModule, forwardRef(() => TestRunsModule), AuthModule, forwardRef(() => ProjectsModule)], + imports: [SharedModule, UsersModule.register(), forwardRef(() => TestRunsModule), AuthModule, forwardRef(() => ProjectsModule)], providers: [BuildsService, PrismaService], controllers: [BuildsController], exports: [BuildsService], diff --git a/src/users/dbusers.service.spec.ts b/src/users/dbusers.service.spec.ts index 441242ad..1e0d5f07 100644 --- a/src/users/dbusers.service.spec.ts +++ b/src/users/dbusers.service.spec.ts @@ -2,9 +2,12 @@ import { Test, TestingModule } from '@nestjs/testing'; import { DbUsersService } from './dbusers.service'; import { AuthService } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; +import { UserLoginResponseDto } from './dto/user-login-response.dto'; +import { Role } from '@prisma/client'; -describe('UsersService', () => { +describe('DbUsersService', () => { let service: DbUsersService; + let prismaService: PrismaService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -15,17 +18,64 @@ describe('UsersService', () => { useValue: { user: { findMany: jest.fn().mockResolvedValueOnce([]), + create: jest.fn(), }, }, }, - { provide: AuthService, useValue: {} }, + { + provide: AuthService, + useValue: { + generateApiKey: jest.fn((..._: any[]) => 'generatedApiKey'), + encryptPassword: jest.fn((..._: any[]) => 'encryptedPassword'), + }, + }, ], }).compile(); service = module.get(DbUsersService); + prismaService = module.get(PrismaService); }); it('should be defined', () => { expect(service).toBeDefined(); }); + + 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({ + 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(), + }); + + // 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)); + }); }); diff --git a/src/users/ldapusers.service.spec.ts b/src/users/ldapusers.service.spec.ts new file mode 100644 index 00000000..b2e30659 --- /dev/null +++ b/src/users/ldapusers.service.spec.ts @@ -0,0 +1,39 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { LdapUsersService } from './ldapusers.service'; +import { AuthService } from '../auth/auth.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { UserLoginResponseDto } from './dto/user-login-response.dto'; + +describe('LdapUsersService', () => { + let service: LdapUsersService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + LdapUsersService, + { + provide: PrismaService, + useValue: { + user: { + findMany: jest.fn().mockResolvedValueOnce([]), + }, + }, + }, + { provide: AuthService, useValue: {} }, + ], + }).compile(); + + service = module.get(LdapUsersService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + xit('login using ldap', async () => { + // Act + const result = await service.login({ email: 'test@example.com', password: 'example' }); + // Assert + expect(result).toEqual(expect.any(UserLoginResponseDto)); + }); +}); diff --git a/src/users/ldapusers.service.ts b/src/users/ldapusers.service.ts new file mode 100644 index 00000000..14bf005c --- /dev/null +++ b/src/users/ldapusers.service.ts @@ -0,0 +1,230 @@ +import { Injectable, HttpException, HttpStatus } 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 { UserDto } from './dto/user.dto'; +import { UpdateUserDto } from './dto/user-update.dto'; +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 { Entry as LdapEntry, Client as LdapClient } from 'ldapts'; +import { UsersService } from './users.service'; + +@Injectable() +export class LdapUsersService implements UsersService { + private readonly ldapClient: LdapClient; + private readonly logger: Logger = new Logger(LdapUsersService.name); + private readonly assertRequiredEnvVarsAreSet = () => { + const requiredEnvVars = [ + 'LDAP_URL', + 'LDAP_BIND_USER', + 'LDAP_BIND_PASSWORD', + 'LDAP_SEARCH_DN', + 'LDAP_USERS_SEARCH_FILTER', + 'LDAP_ATTRIBUTE_MAIL', + 'LDAP_ATTRIBUTE_FIRST_NAME', + 'LDAP_ATTRIBUTE_LAST_NAME', + ]; + + for (const envVar of requiredEnvVars) { + if (!process.env[envVar]) { + throw new Error(`${envVar} is required.`); + } + } + }; + + constructor( + private prismaService: PrismaService, + private authService: AuthService + ) { + this.assertRequiredEnvVarsAreSet(); + this.ldapClient = new LdapClient({ + url: process.env.LDAP_URL, + tlsOptions: { + rejectUnauthorized: process.env.LDAP_TLS_NO_VERIFY !== 'true', + }, + }); + } + + 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(process.env.LDAP_BIND_USER, process.env.LDAP_BIND_PASSWORD); + const attributes = [ + 'dn', + process.env.LDAP_ATTRIBUTE_MAIL, + process.env.LDAP_ATTRIBUTE_FIRST_NAME, + process.env.LDAP_ATTRIBUTE_LAST_NAME, + ]; + + const { searchEntries } = await this.ldapClient.search(process.env.LDAP_SEARCH_DN, { + filter: process.env.LDAP_USERS_SEARCH_FILTER.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[process.env.LDAP_ATTRIBUTE_MAIL].toString().trim().toLowerCase(), + firstName: ldapEntry[process.env.LDAP_ATTRIBUTE_FIRST_NAME].toString(), + lastName: ldapEntry[process.env.LDAP_ATTRIBUTE_LAST_NAME].toString(), + apiKey: this.authService.generateApiKey(), + password: await this.authService.encryptPassword(Math.random().toString(36).slice(-8)), + role: Role.editor, + }; + + return this.prismaService.user.create({ + data: userForVRTDb, + }); + } + + async create(createUserDto: CreateUserDto): Promise { + // lookup user in LDAP + const ldapEntry = await this.findUserInLdap(createUserDto.email); + const userData = await this.createUserFromLdapEntry(ldapEntry); + + return new UserLoginResponseDto(userData, null); + } + + async findOne(id: string): Promise { + return this.prismaService.user.findUnique({ where: { id } }); + } + + async delete(id: string): Promise { + this.logger.debug(`Removing User: ${id}`); + return this.prismaService.user.delete({ where: { id } }); + } + + async get(id: string): Promise { + const user = await this.findOne(id); + return new UserDto(user); + } + + async assignRole(data: AssignRoleDto): Promise { + const { id, role } = data; + this.logger.debug(`Assigning role ${role} to User: ${id}`); + + const user = await this.prismaService.user.update({ + where: { id }, + data: { role }, + }); + return new UserDto(user); + } + + 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); + } + + async generateNewApiKey(user: User): Promise { + const newApiKey = this.authService.generateApiKey(); + await this.prismaService.user.update({ + where: { id: user.id }, + data: { + apiKey: newApiKey, + }, + }); + return newApiKey; + } + + 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[process.env.LDAP_ATTRIBUTE_MAIL].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.module.ts b/src/users/users.module.ts index 7d54196b..648310d3 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,13 +1,36 @@ -import { Module } from '@nestjs/common'; -import { UsersService } from './users.service'; +import { Module, DynamicModule } from '@nestjs/common'; +import { LdapUsersService } from './ldapusers.service'; +import { DbUsersService } from './dbusers.service'; import { PrismaService } from '../prisma/prisma.service'; import { UsersController } from './users.controller'; import { AuthModule } from '../auth/auth.module'; +import { AuthService } from '../auth/auth.service'; +import { UsersService } from './users.service'; + +@Module({}) +export class UsersModule { + static register(): DynamicModule { + const providers = [ + PrismaService, + { + provide: UsersService, + useFactory: (prismaService: PrismaService, authService: AuthService) => { + if (process.env.LDAP_ENABLED === 'true') { + return new LdapUsersService(prismaService, authService); + } else { + return new DbUsersService(prismaService, authService); + } + }, + inject: [PrismaService, AuthService], + }, + ]; -@Module({ - imports: [AuthModule], - providers: [UsersService, PrismaService], - exports: [UsersService], - controllers: [UsersController], -}) -export class UsersModule {} + return { + module: UsersModule, + providers: providers, + exports: [UsersService], + imports: [AuthModule], + controllers: [UsersController], + }; + } +} diff --git a/src/users/users.service.ts b/src/users/users.service.ts new file mode 100644 index 00000000..39af7a9a --- /dev/null +++ b/src/users/users.service.ts @@ -0,0 +1,19 @@ +import { CreateUserDto } from './dto/user-create.dto'; +import { UserLoginResponseDto } from './dto/user-login-response.dto'; +import { User } from '@prisma/client'; +import { UserDto } from './dto/user.dto'; +import { UpdateUserDto } from './dto/user-update.dto'; +import { UserLoginRequestDto } from './dto/user-login-request.dto'; +import { AssignRoleDto } from './dto/assign-role.dto'; + +export abstract class UsersService { + abstract create(createUserDto: CreateUserDto): Promise; + abstract findOne(id: string): Promise; + abstract delete(id: string): Promise; + abstract get(id: string): Promise; + abstract assignRole(data: AssignRoleDto): Promise; + abstract update(id: string, userDto: UpdateUserDto): Promise; + abstract generateNewApiKey(user: User): Promise; + abstract changePassword(user: User, newPassword: string): Promise; + abstract login(userLoginRequestDto: UserLoginRequestDto): Promise; +} \ No newline at end of file From e7e7ecf3926bd2e650999c1dc2f54da920f3c9f8 Mon Sep 17 00:00:00 2001 From: Mario Melcher Date: Wed, 17 Jul 2024 14:14:32 +0200 Subject: [PATCH 3/9] disable user creation --- src/users/ldapusers.service.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/users/ldapusers.service.ts b/src/users/ldapusers.service.ts index 14bf005c..8326002f 100644 --- a/src/users/ldapusers.service.ts +++ b/src/users/ldapusers.service.ts @@ -98,11 +98,7 @@ export class LdapUsersService implements UsersService { } async create(createUserDto: CreateUserDto): Promise { - // lookup user in LDAP - const ldapEntry = await this.findUserInLdap(createUserDto.email); - const userData = await this.createUserFromLdapEntry(ldapEntry); - - return new UserLoginResponseDto(userData, null); + throw new HttpException('User creation is disabled. Use your LDAP-Credentials to login.', HttpStatus.BAD_REQUEST); } async findOne(id: string): Promise { From b7fc7ec3e975feb494a84bacddc37884f5477294 Mon Sep 17 00:00:00 2001 From: Mario Melcher Date: Wed, 17 Jul 2024 17:19:26 +0200 Subject: [PATCH 4/9] add a local ldap test setup --- README.md | 14 +++++++++++++- docker-compose.ldap.yml | 28 ++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) create mode 100644 docker-compose.ldap.yml 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..5ce3122e --- /dev/null +++ b/docker-compose.ldap.yml @@ -0,0 +1,28 @@ +version: "3.7" +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 From 681b9a8f152b374c1cb201a1182a4a36a464821a Mon Sep 17 00:00:00 2001 From: Mario Melcher Date: Wed, 15 Jan 2025 10:08:38 +0100 Subject: [PATCH 5/9] refactor to factory pattern --- src/app.module.ts | 2 +- src/builds/builds.module.ts | 2 +- src/users/{ => db}/dbusers.service.ts | 57 ++------ src/users/{ => ldap}/ldapusers.service.ts | 129 +++++++----------- src/users/ldapusers.service.spec.ts | 39 ------ src/users/users.factory.spec.ts | 59 ++++++++ src/users/users.factory.ts | 31 +++++ src/users/users.interface.ts | 12 ++ src/users/users.module.ts | 43 ++---- ....service.spec.ts => users.service.spec.ts} | 25 ++-- src/users/users.service.ts | 82 +++++++++-- 11 files changed, 260 insertions(+), 221 deletions(-) rename src/users/{ => db}/dbusers.service.ts (56%) rename src/users/{ => ldap}/ldapusers.service.ts (61%) delete mode 100644 src/users/ldapusers.service.spec.ts create mode 100644 src/users/users.factory.spec.ts create mode 100644 src/users/users.factory.ts create mode 100644 src/users/users.interface.ts rename src/users/{dbusers.service.spec.ts => users.service.spec.ts} (77%) diff --git a/src/app.module.ts b/src/app.module.ts index 7bcf3e8f..f5784f09 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,7 +22,7 @@ import { HealthController } from './health/health.controller'; CacheModule.register(), ScheduleModule.forRoot(), AuthModule, - UsersModule.register(), + UsersModule, BuildsModule, ProjectsModule, TestRunsModule, diff --git a/src/builds/builds.module.ts b/src/builds/builds.module.ts index c25c5420..cce932ba 100644 --- a/src/builds/builds.module.ts +++ b/src/builds/builds.module.ts @@ -9,7 +9,7 @@ import { AuthModule } from '../auth/auth.module'; import { ProjectsModule } from '../projects/projects.module'; @Module({ - imports: [SharedModule, UsersModule.register(), forwardRef(() => TestRunsModule), AuthModule, forwardRef(() => ProjectsModule)], + imports: [SharedModule, UsersModule, forwardRef(() => TestRunsModule), AuthModule, forwardRef(() => ProjectsModule)], providers: [BuildsService, PrismaService], controllers: [BuildsController], exports: [BuildsService], diff --git a/src/users/dbusers.service.ts b/src/users/db/dbusers.service.ts similarity index 56% rename from src/users/dbusers.service.ts rename to src/users/db/dbusers.service.ts index da308748..6d717b22 100644 --- a/src/users/dbusers.service.ts +++ b/src/users/db/dbusers.service.ts @@ -1,18 +1,15 @@ -import { Injectable, HttpException, HttpStatus } 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 { HttpException, HttpStatus } 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 { UserDto } from './dto/user.dto'; -import { UpdateUserDto } from './dto/user-update.dto'; -import { AuthService } from '../auth/auth.service'; -import { UserLoginRequestDto } from './dto/user-login-request.dto'; -import { AssignRoleDto } from './dto/assign-role.dto'; +import { UpdateUserDto } from '../dto/user-update.dto'; +import { AuthService } from '../../auth/auth.service'; +import { UserLoginRequestDto } from '../dto/user-login-request.dto'; import { Logger } from '@nestjs/common'; -import { UsersService } from './users.service'; +import { Users } from '../users.interface'; -@Injectable() -export class DbUsersService implements UsersService { +export class DbUsersService implements Users { private readonly logger: Logger = new Logger(DbUsersService.name); constructor( @@ -36,31 +33,6 @@ export class DbUsersService implements UsersService { return new UserLoginResponseDto(userData, null); } - async findOne(id: string): Promise { - return this.prismaService.user.findUnique({ where: { id } }); - } - - async delete(id: string): Promise { - this.logger.debug(`Removing User: ${id}`); - return this.prismaService.user.delete({ where: { id } }); - } - - async get(id: string): Promise { - const user = await this.findOne(id); - return new UserDto(user); - } - - async assignRole(data: AssignRoleDto): Promise { - const { id, role } = data; - this.logger.debug(`Assigning role ${role} to User: ${id}`); - - const user = await this.prismaService.user.update({ - where: { id }, - data: { role }, - }); - return new UserDto(user); - } - async update(id: string, userDto: UpdateUserDto): Promise { const user = await this.prismaService.user.update({ where: { id }, @@ -74,17 +46,6 @@ export class DbUsersService implements UsersService { return new UserLoginResponseDto(user, token); } - async generateNewApiKey(user: User): Promise { - const newApiKey = this.authService.generateApiKey(); - await this.prismaService.user.update({ - where: { id: user.id }, - data: { - apiKey: newApiKey, - }, - }); - return newApiKey; - } - async changePassword(user: User, newPassword: string): Promise { await this.prismaService.user.update({ where: { id: user.id }, diff --git a/src/users/ldapusers.service.ts b/src/users/ldap/ldapusers.service.ts similarity index 61% rename from src/users/ldapusers.service.ts rename to src/users/ldap/ldapusers.service.ts index 8326002f..9ac9812f 100644 --- a/src/users/ldapusers.service.ts +++ b/src/users/ldap/ldapusers.service.ts @@ -1,49 +1,52 @@ -import { Injectable, HttpException, HttpStatus } 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 { HttpException, HttpStatus } 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 { UserDto } from './dto/user.dto'; -import { UpdateUserDto } from './dto/user-update.dto'; -import { AuthService } from '../auth/auth.service'; -import { UserLoginRequestDto } from './dto/user-login-request.dto'; -import { AssignRoleDto } from './dto/assign-role.dto'; +import { UpdateUserDto } from '../dto/user-update.dto'; +import { AuthService } from '../../auth/auth.service'; +import { UserLoginRequestDto } from '../dto/user-login-request.dto'; import { Logger } from '@nestjs/common'; import { Entry as LdapEntry, Client as LdapClient } from 'ldapts'; -import { UsersService } from './users.service'; - -@Injectable() -export class LdapUsersService implements UsersService { +import { Users } from '../users.interface'; +import { ConfigService } from '@nestjs/config'; + +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 assertRequiredEnvVarsAreSet = () => { - const requiredEnvVars = [ - 'LDAP_URL', - 'LDAP_BIND_USER', - 'LDAP_BIND_PASSWORD', - 'LDAP_SEARCH_DN', - 'LDAP_USERS_SEARCH_FILTER', - 'LDAP_ATTRIBUTE_MAIL', - 'LDAP_ATTRIBUTE_FIRST_NAME', - 'LDAP_ATTRIBUTE_LAST_NAME', - ]; - - for (const envVar of requiredEnvVars) { - if (!process.env[envVar]) { - throw new Error(`${envVar} is required.`); - } - } - }; + private readonly ldapConfig: LDAPConfig; constructor( + private configService: ConfigService, private prismaService: PrismaService, private authService: AuthService ) { - this.assertRequiredEnvVarsAreSet(); + 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), + }; this.ldapClient = new LdapClient({ - url: process.env.LDAP_URL, + url: this.ldapConfig.url, tlsOptions: { - rejectUnauthorized: process.env.LDAP_TLS_NO_VERIFY !== 'true', + rejectUnauthorized: !this.ldapConfig.tlsNoVerify, }, }); } @@ -53,16 +56,16 @@ export class LdapUsersService implements UsersService { email = this.escapeLdap(email.trim()); this.logger.verbose(`search '${email}' in LDAP`); try { - await this.ldapClient.bind(process.env.LDAP_BIND_USER, process.env.LDAP_BIND_PASSWORD); + await this.ldapClient.bind(this.ldapConfig.bindUser, this.ldapConfig.bindPassword); const attributes = [ 'dn', - process.env.LDAP_ATTRIBUTE_MAIL, - process.env.LDAP_ATTRIBUTE_FIRST_NAME, - process.env.LDAP_ATTRIBUTE_LAST_NAME, + this.ldapConfig.attributeMail, + this.ldapConfig.attributeFirstName, + this.ldapConfig.attributeLastName, ]; - const { searchEntries } = await this.ldapClient.search(process.env.LDAP_SEARCH_DN, { - filter: process.env.LDAP_USERS_SEARCH_FILTER.replaceAll('{{email}}', email), + const { searchEntries } = await this.ldapClient.search(this.ldapConfig.searchDN, { + filter: this.ldapConfig.usersSearchFilter.replaceAll('{{email}}', email), sizeLimit: 1, attributes: attributes, }); @@ -84,9 +87,9 @@ export class LdapUsersService implements UsersService { private async createUserFromLdapEntry(ldapEntry: LdapEntry): Promise { const userForVRTDb = { - email: ldapEntry[process.env.LDAP_ATTRIBUTE_MAIL].toString().trim().toLowerCase(), - firstName: ldapEntry[process.env.LDAP_ATTRIBUTE_FIRST_NAME].toString(), - lastName: ldapEntry[process.env.LDAP_ATTRIBUTE_LAST_NAME].toString(), + 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(Math.random().toString(36).slice(-8)), role: Role.editor, @@ -97,35 +100,11 @@ export class LdapUsersService implements UsersService { }); } + // 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 findOne(id: string): Promise { - return this.prismaService.user.findUnique({ where: { id } }); - } - - async delete(id: string): Promise { - this.logger.debug(`Removing User: ${id}`); - return this.prismaService.user.delete({ where: { id } }); - } - - async get(id: string): Promise { - const user = await this.findOne(id); - return new UserDto(user); - } - - async assignRole(data: AssignRoleDto): Promise { - const { id, role } = data; - this.logger.debug(`Assigning role ${role} to User: ${id}`); - - const user = await this.prismaService.user.update({ - where: { id }, - data: { role }, - }); - return new UserDto(user); - } - async update(id: string, userDto: UpdateUserDto): Promise { const userFromLdap = await this.findUserInLdap(userDto.email); const user = await this.prismaService.user.update({ @@ -140,17 +119,7 @@ export class LdapUsersService implements UsersService { return new UserLoginResponseDto(user, token); } - async generateNewApiKey(user: User): Promise { - const newApiKey = this.authService.generateApiKey(); - await this.prismaService.user.update({ - where: { id: user.id }, - data: { - apiKey: newApiKey, - }, - }); - return newApiKey; - } - + // 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; @@ -171,7 +140,7 @@ export class LdapUsersService implements UsersService { await this.ldapClient.unbind(); } - const userEmailFromLdap = userFromLdap[process.env.LDAP_ATTRIBUTE_MAIL].toString().trim().toLowerCase(); + const userEmailFromLdap = userFromLdap[this.ldapConfig.attributeMail].toString().trim().toLowerCase(); let user = await this.prismaService.user.findUnique({ where: { email: userEmailFromLdap }, diff --git a/src/users/ldapusers.service.spec.ts b/src/users/ldapusers.service.spec.ts deleted file mode 100644 index b2e30659..00000000 --- a/src/users/ldapusers.service.spec.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { Test, TestingModule } from '@nestjs/testing'; -import { LdapUsersService } from './ldapusers.service'; -import { AuthService } from '../auth/auth.service'; -import { PrismaService } from '../prisma/prisma.service'; -import { UserLoginResponseDto } from './dto/user-login-response.dto'; - -describe('LdapUsersService', () => { - let service: LdapUsersService; - - beforeEach(async () => { - const module: TestingModule = await Test.createTestingModule({ - providers: [ - LdapUsersService, - { - provide: PrismaService, - useValue: { - user: { - findMany: jest.fn().mockResolvedValueOnce([]), - }, - }, - }, - { provide: AuthService, useValue: {} }, - ], - }).compile(); - - service = module.get(LdapUsersService); - }); - - it('should be defined', () => { - expect(service).toBeDefined(); - }); - - xit('login using ldap', async () => { - // Act - const result = await service.login({ email: 'test@example.com', password: 'example' }); - // Assert - expect(result).toEqual(expect.any(UserLoginResponseDto)); - }); -}); 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..81afcfb6 --- /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 configService: ConfigService, + private prismaService: PrismaService, + private authService: AuthService + ) {} + + getUsersService(): Users { + const serviceType = this.configService.get('LDAP_ENABLED', false); + 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 648310d3..61335c99 100644 --- a/src/users/users.module.ts +++ b/src/users/users.module.ts @@ -1,36 +1,13 @@ -import { Module, DynamicModule } from '@nestjs/common'; -import { LdapUsersService } from './ldapusers.service'; -import { DbUsersService } from './dbusers.service'; -import { PrismaService } from '../prisma/prisma.service'; +import { Module } from '@nestjs/common'; +import { UsersService } from './users.service'; import { UsersController } from './users.controller'; import { AuthModule } from '../auth/auth.module'; -import { AuthService } from '../auth/auth.service'; -import { UsersService } from './users.service'; - -@Module({}) -export class UsersModule { - static register(): DynamicModule { - const providers = [ - PrismaService, - { - provide: UsersService, - useFactory: (prismaService: PrismaService, authService: AuthService) => { - if (process.env.LDAP_ENABLED === 'true') { - return new LdapUsersService(prismaService, authService); - } else { - return new DbUsersService(prismaService, authService); - } - }, - inject: [PrismaService, AuthService], - }, - ]; +import { UsersFactoryService } from './users.factory'; - return { - module: UsersModule, - providers: providers, - exports: [UsersService], - imports: [AuthModule], - controllers: [UsersController], - }; - } -} +@Module({ + imports: [AuthModule], + providers: [UsersService, UsersFactoryService], + exports: [UsersService], + controllers: [UsersController], +}) +export class UsersModule {} diff --git a/src/users/dbusers.service.spec.ts b/src/users/users.service.spec.ts similarity index 77% rename from src/users/dbusers.service.spec.ts rename to src/users/users.service.spec.ts index 1e0d5f07..764acbae 100644 --- a/src/users/dbusers.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -1,18 +1,27 @@ import { Test, TestingModule } from '@nestjs/testing'; -import { DbUsersService } from './dbusers.service'; +import { UsersService } from './users.service'; import { AuthService } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; -import { UserLoginResponseDto } from './dto/user-login-response.dto'; +import { ConfigService } from '@nestjs/config'; +import { UsersFactoryService } from './users.factory'; import { Role } from '@prisma/client'; +import { UserLoginResponseDto } from './dto/user-login-response.dto'; -describe('DbUsersService', () => { - let service: DbUsersService; +describe('UsersService', () => { + let service: UsersService; let prismaService: PrismaService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ providers: [ - DbUsersService, + UsersService, + UsersFactoryService, + { + provide: ConfigService, + useValue: { + get: jest.fn(), + }, + }, { provide: PrismaService, useValue: { @@ -25,14 +34,14 @@ describe('DbUsersService', () => { { provide: AuthService, useValue: { - generateApiKey: jest.fn((..._: any[]) => 'generatedApiKey'), - encryptPassword: jest.fn((..._: any[]) => 'encryptedPassword'), + generateApiKey: jest.fn(() => 'generatedApiKey'), + encryptPassword: jest.fn(() => 'encryptedPassword'), }, }, ], }).compile(); - service = module.get(DbUsersService); + service = module.get(UsersService); prismaService = module.get(PrismaService); }); diff --git a/src/users/users.service.ts b/src/users/users.service.ts index 39af7a9a..246ae1f9 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -1,19 +1,79 @@ +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'; import { User } from '@prisma/client'; import { UserDto } from './dto/user.dto'; import { UpdateUserDto } from './dto/user-update.dto'; +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'; -export abstract class UsersService { - abstract create(createUserDto: CreateUserDto): Promise; - abstract findOne(id: string): Promise; - abstract delete(id: string): Promise; - abstract get(id: string): Promise; - abstract assignRole(data: AssignRoleDto): Promise; - abstract update(id: string, userDto: UpdateUserDto): Promise; - abstract generateNewApiKey(user: User): Promise; - abstract changePassword(user: User, newPassword: string): Promise; - abstract login(userLoginRequestDto: UserLoginRequestDto): Promise; -} \ No newline at end of file +@Injectable() +export class UsersService { + private readonly logger: Logger = new Logger(UsersService.name); + private readonly usersService: Users; + + constructor( + private usersFactoryService: UsersFactoryService, + private prismaService: PrismaService, + private authService: AuthService + ) { + this.usersService = this.usersFactoryService.getUsersService(); + } + + async create(createUserDto: CreateUserDto): Promise { + return this.usersService.create(createUserDto); + } + + 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); + } + + async login(userLoginRequestDto: UserLoginRequestDto) { + return this.usersService.login(userLoginRequestDto); + } + + async findOne(id: string): Promise { + return this.prismaService.user.findUnique({ where: { id } }); + } + + async delete(id: string): Promise { + this.logger.debug(`Removing User: ${id}`); + return this.prismaService.user.delete({ where: { id } }); + } + + async get(id: string): Promise { + const user = await this.findOne(id); + return new UserDto(user); + } + + async assignRole(data: AssignRoleDto): Promise { + const { id, role } = data; + this.logger.debug(`Assigning role ${role} to User: ${id}`); + + const user = await this.prismaService.user.update({ + where: { id }, + data: { role }, + }); + return new UserDto(user); + } + + async generateNewApiKey(user: User): Promise { + const newApiKey = this.authService.generateApiKey(); + await this.prismaService.user.update({ + where: { id: user.id }, + data: { + apiKey: newApiKey, + }, + }); + return newApiKey; + } +} From ad1cbb2a5bedcda8f98b6b6a47c0b766d9b0ebf4 Mon Sep 17 00:00:00 2001 From: Mario Melcher Date: Wed, 15 Jan 2025 17:25:27 +0100 Subject: [PATCH 6/9] add e2e ldap tests --- .github/workflows/workflow.yml | 10 ++++++++++ docker-compose.ldap.yml | 1 - package.json | 3 ++- src/users/ldap/ldapusers.service.ts | 2 +- src/users/users.factory.ts | 2 +- test_ldap/jest.config.ts | 14 ++++++++++++++ test_ldap/ldap.spec.ts | 30 +++++++++++++++++++++++++++++ 7 files changed, 58 insertions(+), 4 deletions(-) create mode 100644 test_ldap/jest.config.ts create mode 100644 test_ldap/ldap.spec.ts diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index fa6e9f12..c60dfba7 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -40,6 +40,16 @@ jobs: 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: 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/docker-compose.ldap.yml b/docker-compose.ldap.yml index 5ce3122e..83acfd7b 100644 --- a/docker-compose.ldap.yml +++ b/docker-compose.ldap.yml @@ -1,4 +1,3 @@ -version: "3.7" services: api: environment: diff --git a/package.json b/package.json index eaab90de..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" diff --git a/src/users/ldap/ldapusers.service.ts b/src/users/ldap/ldapusers.service.ts index 9ac9812f..85cfea23 100644 --- a/src/users/ldap/ldapusers.service.ts +++ b/src/users/ldap/ldapusers.service.ts @@ -41,7 +41,7 @@ export class LdapUsersService implements Users { 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), + tlsNoVerify: this.configService.get('LDAP_TLS_NO_VERIFY', 'false').toLowerCase() === 'true', }; this.ldapClient = new LdapClient({ url: this.ldapConfig.url, diff --git a/src/users/users.factory.ts b/src/users/users.factory.ts index 81afcfb6..257c49a5 100644 --- a/src/users/users.factory.ts +++ b/src/users/users.factory.ts @@ -17,7 +17,7 @@ export class UsersFactoryService { ) {} getUsersService(): Users { - const serviceType = this.configService.get('LDAP_ENABLED', false); + const serviceType = this.configService.get('LDAP_ENABLED', 'false')?.toLowerCase() === 'true'; switch (serviceType) { case true: this.logger.debug('users service type: LDAP'); 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.'); + }); + }); +}); From 8b0a52611110b67c3bc4918879f83bc9ccd7ae93 Mon Sep 17 00:00:00 2001 From: Mario Melcher Date: Wed, 15 Jan 2025 17:58:33 +0100 Subject: [PATCH 7/9] create more secure random PWs for DB --- src/users/ldap/ldapusers.service.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/users/ldap/ldapusers.service.ts b/src/users/ldap/ldapusers.service.ts index 85cfea23..5ecb0ec1 100644 --- a/src/users/ldap/ldapusers.service.ts +++ b/src/users/ldap/ldapusers.service.ts @@ -10,6 +10,7 @@ import { Logger } from '@nestjs/common'; 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; @@ -91,7 +92,7 @@ export class LdapUsersService implements Users { firstName: ldapEntry[this.ldapConfig.attributeFirstName].toString(), lastName: ldapEntry[this.ldapConfig.attributeLastName].toString(), apiKey: this.authService.generateApiKey(), - password: await this.authService.encryptPassword(Math.random().toString(36).slice(-8)), + password: await this.authService.encryptPassword(genSaltSync(1).slice(-12)), role: Role.editor, }; From 8acf8db0e43aa6be508217b1e1d660ccd3088f53 Mon Sep 17 00:00:00 2001 From: Mario Melcher Date: Thu, 16 Jan 2025 07:50:26 +0100 Subject: [PATCH 8/9] dump docker logs on failure --- .github/workflows/workflow.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/workflow.yml b/.github/workflows/workflow.yml index c60dfba7..38bb3651 100644 --- a/.github/workflows/workflow.yml +++ b/.github/workflows/workflow.yml @@ -36,6 +36,10 @@ 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 @@ -46,6 +50,10 @@ jobs: - 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 From 5e4206d3aa7237bf32d907b19163af6e3d70404b Mon Sep 17 00:00:00 2001 From: Mario Melcher Date: Thu, 16 Jan 2025 16:09:12 +0100 Subject: [PATCH 9/9] fix sonar qube findings and add tests --- src/users/db/dbusers.service.ts | 7 +- src/users/ldap/ldapusers.service.spec.ts | 176 +++++++++++++++++++++++ src/users/ldap/ldapusers.service.ts | 9 +- src/users/users.factory.ts | 6 +- src/users/users.service.spec.ts | 129 +++++++++++++++-- src/users/users.service.ts | 2 +- 6 files changed, 302 insertions(+), 27 deletions(-) create mode 100644 src/users/ldap/ldapusers.service.spec.ts diff --git a/src/users/db/dbusers.service.ts b/src/users/db/dbusers.service.ts index 6d717b22..da8d4954 100644 --- a/src/users/db/dbusers.service.ts +++ b/src/users/db/dbusers.service.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus } from '@nestjs/common'; +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'; @@ -6,15 +6,14 @@ 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 { Logger } from '@nestjs/common'; import { Users } from '../users.interface'; export class DbUsersService implements Users { private readonly logger: Logger = new Logger(DbUsersService.name); constructor( - private prismaService: PrismaService, - private authService: AuthService + private readonly prismaService: PrismaService, + private readonly authService: AuthService ) {} async create(createUserDto: CreateUserDto): Promise { 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 index 5ecb0ec1..39aaa490 100644 --- a/src/users/ldap/ldapusers.service.ts +++ b/src/users/ldap/ldapusers.service.ts @@ -1,4 +1,4 @@ -import { HttpException, HttpStatus } from '@nestjs/common'; +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'; @@ -6,7 +6,6 @@ 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 { Logger } from '@nestjs/common'; import { Entry as LdapEntry, Client as LdapClient } from 'ldapts'; import { Users } from '../users.interface'; import { ConfigService } from '@nestjs/config'; @@ -29,9 +28,9 @@ export class LdapUsersService implements Users { private readonly ldapConfig: LDAPConfig; constructor( - private configService: ConfigService, - private prismaService: PrismaService, - private authService: AuthService + private readonly configService: ConfigService, + private readonly prismaService: PrismaService, + private readonly authService: AuthService ) { this.ldapConfig = { url: this.configService.getOrThrow('LDAP_URL'), diff --git a/src/users/users.factory.ts b/src/users/users.factory.ts index 257c49a5..bd9073a7 100644 --- a/src/users/users.factory.ts +++ b/src/users/users.factory.ts @@ -11,9 +11,9 @@ export class UsersFactoryService { private readonly logger: Logger = new Logger(UsersFactoryService.name); constructor( - private configService: ConfigService, - private prismaService: PrismaService, - private authService: AuthService + private readonly configService: ConfigService, + private readonly prismaService: PrismaService, + private readonly authService: AuthService ) {} getUsersService(): Users { diff --git a/src/users/users.service.spec.ts b/src/users/users.service.spec.ts index 764acbae..8a9ad57e 100644 --- a/src/users/users.service.spec.ts +++ b/src/users/users.service.spec.ts @@ -4,10 +4,27 @@ import { AuthService } from '../auth/auth.service'; import { PrismaService } from '../prisma/prisma.service'; import { ConfigService } from '@nestjs/config'; import { UsersFactoryService } from './users.factory'; -import { Role } from '@prisma/client'; +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; @@ -28,6 +45,9 @@ describe('UsersService', () => { user: { findMany: jest.fn().mockResolvedValueOnce([]), create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + findUnique: jest.fn().mockResolvedValueOnce(user), }, }, }, @@ -36,6 +56,8 @@ describe('UsersService', () => { useValue: { generateApiKey: jest.fn(() => 'generatedApiKey'), encryptPassword: jest.fn(() => 'encryptedPassword'), + compare: jest.fn(() => true), + signToken: jest.fn(() => 'token'), }, }, ], @@ -49,6 +71,19 @@ describe('UsersService', () => { 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 = { @@ -59,18 +94,7 @@ describe('UsersService', () => { }; const prismaUserCreateMock = jest.spyOn(prismaService.user, 'create'); - prismaUserCreateMock.mockResolvedValueOnce({ - 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(), - }); + prismaUserCreateMock.mockResolvedValueOnce(user); // Act const result = await service.create(createUserDto); @@ -87,4 +111,81 @@ describe('UsersService', () => { }); 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 246ae1f9..64927d1c 100644 --- a/src/users/users.service.ts +++ b/src/users/users.service.ts @@ -18,7 +18,7 @@ export class UsersService { private readonly usersService: Users; constructor( - private usersFactoryService: UsersFactoryService, + private readonly usersFactoryService: UsersFactoryService, private prismaService: PrismaService, private authService: AuthService ) {