diff --git a/src/modules/authentication/authentication.module.ts b/src/modules/authentication/authentication.module.ts index c319fa3..539f55b 100644 --- a/src/modules/authentication/authentication.module.ts +++ b/src/modules/authentication/authentication.module.ts @@ -23,6 +23,8 @@ import { UpdateUsernameGrpcController } from './interface/grpc-controllers/updat import { UpdateUsernameService } from './core/application/commands/update-username/update-username.service'; import { UpdatePasswordGrpcController } from './interface/grpc-controllers/update-password.grpc.controller'; import { UpdatePasswordService } from './core/application/commands/update-password/update-password.service'; +import { ValidateAuthenticationGrpcController } from './interface/grpc-controllers/validate-authentication.grpc.controller'; +import { ValidateAuthenticationQueryHandler } from './core/application/queries/validate-authentication/validate-authentication.query-handler'; const grpcControllers = [ CreateAuthenticationGrpcController, @@ -31,6 +33,7 @@ const grpcControllers = [ UpdateUsernameGrpcController, DeleteUsernameGrpcController, UpdatePasswordGrpcController, + ValidateAuthenticationGrpcController, ]; const commandHandlers: Provider[] = [ @@ -42,6 +45,8 @@ const commandHandlers: Provider[] = [ UpdatePasswordService, ]; +const queryHandlers: Provider[] = [ValidateAuthenticationQueryHandler]; + const mappers: Provider[] = [AuthenticationMapper, UsernameMapper]; const repositories: Provider[] = [ @@ -69,6 +74,7 @@ const orms: Provider[] = [PrismaService]; controllers: [...grpcControllers], providers: [ ...commandHandlers, + ...queryHandlers, ...mappers, ...repositories, ...messageBrokers, diff --git a/src/modules/authentication/core/application/ports/username.repository.port.ts b/src/modules/authentication/core/application/ports/username.repository.port.ts index 14ce69b..9188472 100644 --- a/src/modules/authentication/core/application/ports/username.repository.port.ts +++ b/src/modules/authentication/core/application/ports/username.repository.port.ts @@ -4,5 +4,6 @@ import { Type } from '../../domain/username.types'; export type UsernameRepositoryPort = RepositoryPort & { findByType(userId: string, type: Type): Promise; + findByName(name: string): Promise; updateUsername(oldName: string, entity: UsernameEntity): Promise; }; diff --git a/src/modules/authentication/core/application/queries/validate-authentication/validate-authentication.query-handler.ts b/src/modules/authentication/core/application/queries/validate-authentication/validate-authentication.query-handler.ts new file mode 100644 index 0000000..88aa703 --- /dev/null +++ b/src/modules/authentication/core/application/queries/validate-authentication/validate-authentication.query-handler.ts @@ -0,0 +1,50 @@ +import { IQueryHandler, QueryHandler } from '@nestjs/cqrs'; +import { Inject, UnauthorizedException } from '@nestjs/common'; +import { ValidateAuthenticationQuery } from './validate-authentication.query'; +import { + AUTHENTICATION_REPOSITORY, + USERNAME_REPOSITORY, +} from '@modules/authentication/authentication.di-tokens'; +import { AuthenticationRepositoryPort } from '../../ports/authentication.repository.port'; +import { UsernameRepositoryPort } from '../../ports/username.repository.port'; +import { AuthenticationEntity } from '@modules/authentication/core/domain/authentication.entity'; +import { UsernameEntity } from '@modules/authentication/core/domain/username.entity'; +import { AggregateID, NotFoundException } from '@mobicoop/ddd-library'; + +@QueryHandler(ValidateAuthenticationQuery) +export class ValidateAuthenticationQueryHandler implements IQueryHandler { + constructor( + @Inject(AUTHENTICATION_REPOSITORY) + private readonly authenticationRepository: AuthenticationRepositoryPort, + @Inject(USERNAME_REPOSITORY) + private readonly usernameRepository: UsernameRepositoryPort, + ) {} + + execute = async ( + query: ValidateAuthenticationQuery, + ): Promise => { + let usernameEntity: UsernameEntity; + try { + usernameEntity = await this.usernameRepository.findByName(query.name); + } catch (e) { + throw new NotFoundException(); + } + let authenticationEntity: AuthenticationEntity; + try { + authenticationEntity = await this.authenticationRepository.findOneById( + usernameEntity.getProps().userId, + ); + } catch (e) { + throw new NotFoundException(); + } + try { + const isAuthenticated = await authenticationEntity.authenticate( + query.password, + ); + if (isAuthenticated) return authenticationEntity.id; + throw new UnauthorizedException(); + } catch (e) { + throw new UnauthorizedException(); + } + }; +} diff --git a/src/modules/authentication/core/application/queries/validate-authentication/validate-authentication.query.ts b/src/modules/authentication/core/application/queries/validate-authentication/validate-authentication.query.ts new file mode 100644 index 0000000..f979a9e --- /dev/null +++ b/src/modules/authentication/core/application/queries/validate-authentication/validate-authentication.query.ts @@ -0,0 +1,12 @@ +import { QueryBase } from '@mobicoop/ddd-library'; + +export class ValidateAuthenticationQuery extends QueryBase { + readonly name: string; + readonly password: string; + + constructor(name: string, password: string) { + super(); + this.name = name; + this.password = password; + } +} diff --git a/src/modules/authentication/core/domain/authentication.entity.ts b/src/modules/authentication/core/domain/authentication.entity.ts index 6b900af..184a0af 100644 --- a/src/modules/authentication/core/domain/authentication.entity.ts +++ b/src/modules/authentication/core/domain/authentication.entity.ts @@ -47,6 +47,9 @@ export class AuthenticationEntity extends AggregateRoot { ); } + authenticate = async (password: string): Promise => + await bcrypt.compare(password, this.props.password); + validate(): void { // entity business rules validation to protect it's invariant before saving entity to a database } diff --git a/src/modules/authentication/infrastructure/username.repository.ts b/src/modules/authentication/infrastructure/username.repository.ts index 22e5148..9342bdd 100644 --- a/src/modules/authentication/infrastructure/username.repository.ts +++ b/src/modules/authentication/infrastructure/username.repository.ts @@ -54,6 +54,11 @@ export class UsernameRepository type, }); + findByName = async (name: string): Promise => + this.findOne({ + username: name, + }); + updateUsername = async ( oldName: string, entity: UsernameEntity, diff --git a/src/modules/authentication/interface/grpc-controllers/authentication.proto b/src/modules/authentication/interface/grpc-controllers/authentication.proto index 87d04f7..604b09c 100644 --- a/src/modules/authentication/interface/grpc-controllers/authentication.proto +++ b/src/modules/authentication/interface/grpc-controllers/authentication.proto @@ -3,7 +3,7 @@ syntax = "proto3"; package authentication; service AuthenticationService { - rpc Validate(AuthenticationByUsernamePassword) returns (Id); + rpc Validate(AuthenticationByNamePassword) returns (Id); rpc Create(Authentication) returns (Id); rpc AddUsername(Username) returns (Id); rpc UpdatePassword(Password) returns (Id); @@ -12,8 +12,8 @@ service AuthenticationService { rpc Delete(UserId) returns (Empty); } -message AuthenticationByUsernamePassword { - string username = 1; +message AuthenticationByNamePassword { + string name = 1; string password = 2; } diff --git a/src/modules/authentication/interface/grpc-controllers/dtos/validate-authentication.request.dto.ts b/src/modules/authentication/interface/grpc-controllers/dtos/validate-authentication.request.dto.ts new file mode 100644 index 0000000..6637cb8 --- /dev/null +++ b/src/modules/authentication/interface/grpc-controllers/dtos/validate-authentication.request.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class ValidateAuthenticationRequestDto { + @IsString() + @IsNotEmpty() + name: string; + + @IsString() + @IsNotEmpty() + password: string; +} diff --git a/src/modules/authentication/interface/grpc-controllers/validate-authentication.grpc.controller.ts b/src/modules/authentication/interface/grpc-controllers/validate-authentication.grpc.controller.ts new file mode 100644 index 0000000..71f0673 --- /dev/null +++ b/src/modules/authentication/interface/grpc-controllers/validate-authentication.grpc.controller.ts @@ -0,0 +1,37 @@ +import { + AggregateID, + IdResponse, + RpcExceptionCode, + RpcValidationPipe, +} from '@mobicoop/ddd-library'; +import { Controller, UsePipes } from '@nestjs/common'; +import { QueryBus } from '@nestjs/cqrs'; +import { GrpcMethod, RpcException } from '@nestjs/microservices'; +import { ValidateAuthenticationRequestDto } from './dtos/validate-authentication.request.dto'; +import { ValidateAuthenticationQuery } from '@modules/authentication/core/application/queries/validate-authentication/validate-authentication.query'; + +@UsePipes( + new RpcValidationPipe({ + whitelist: true, + forbidUnknownValues: false, + }), +) +@Controller() +export class ValidateAuthenticationGrpcController { + constructor(private readonly queryBus: QueryBus) {} + + @GrpcMethod('AuthenticationService', 'Validate') + async validate(data: ValidateAuthenticationRequestDto): Promise { + try { + const aggregateID: AggregateID = await this.queryBus.execute( + new ValidateAuthenticationQuery(data.name, data.password), + ); + return new IdResponse(aggregateID); + } catch (error: any) { + throw new RpcException({ + code: RpcExceptionCode.PERMISSION_DENIED, + message: 'Permission denied', + }); + } + } +} diff --git a/src/modules/authentication/tests/unit/core/authentication.entity.spec.ts b/src/modules/authentication/tests/unit/core/authentication.entity.spec.ts index 1d5f7dd..761beb7 100644 --- a/src/modules/authentication/tests/unit/core/authentication.entity.spec.ts +++ b/src/modules/authentication/tests/unit/core/authentication.entity.spec.ts @@ -76,3 +76,21 @@ describe('Authentication password update', () => { expect(authenticationEntity.getProps().password).not.toBe(oldPassword); }); }); +describe('Authentication password validation', () => { + it('should validate a valid password', async () => { + const authenticationEntity: AuthenticationEntity = + await AuthenticationEntity.create(createAuthenticationProps); + const result: boolean = await authenticationEntity.authenticate( + 'somePassword', + ); + expect(result).toBeTruthy(); + }); + it('should not validate an invalid password', async () => { + const authenticationEntity: AuthenticationEntity = + await AuthenticationEntity.create(createAuthenticationProps); + const result: boolean = await authenticationEntity.authenticate( + 'someWrongPassword', + ); + expect(result).toBeFalsy(); + }); +}); diff --git a/src/modules/authentication/tests/unit/core/validate-authentication.query-handler.spec.ts b/src/modules/authentication/tests/unit/core/validate-authentication.query-handler.spec.ts new file mode 100644 index 0000000..dd71fe3 --- /dev/null +++ b/src/modules/authentication/tests/unit/core/validate-authentication.query-handler.spec.ts @@ -0,0 +1,120 @@ +import { AggregateID, NotFoundException } from '@mobicoop/ddd-library'; +import { + AUTHENTICATION_REPOSITORY, + USERNAME_REPOSITORY, +} from '@modules/authentication/authentication.di-tokens'; +import { ValidateAuthenticationQuery } from '@modules/authentication/core/application/queries/validate-authentication/validate-authentication.query'; +import { ValidateAuthenticationQueryHandler } from '@modules/authentication/core/application/queries/validate-authentication/validate-authentication.query-handler'; +import { UnauthorizedException } from '@nestjs/common'; + +import { Test, TestingModule } from '@nestjs/testing'; + +const validName = 'john.doe@email.com'; +const furtherInvalidName = 'validNameButInvalidAuthenticationForSomeReason'; +const validUserId = '165192d4-398a-4469-a16b-98c02cc6f531'; +const invalidUserId = 'b3b75798-7104-4151-adab-11fe16e32d85'; +const validPassword = 'V@l1dP@$$w0rd'; +const invalidPassword = '1nV@l1dP@$$w0rd'; + +const mockUsernameRepository = { + findByName: jest.fn().mockImplementation((name: string) => { + if (name == validName) { + return { + getProps: jest.fn().mockImplementation(() => ({ + userId: validUserId, + })), + }; + } + if (name == furtherInvalidName) { + return { + getProps: jest.fn().mockImplementation(() => ({ + userId: invalidUserId, + })), + }; + } + throw new Error(); + }), +}; + +const mockAuthenticationEntity = { + id: validUserId, + authenticate: jest + .fn() + .mockImplementation((password: string) => password === validPassword), +}; + +const mockAuthenticationRepository = { + findOneById: jest.fn().mockImplementation((id: string) => { + if (id === validUserId) return mockAuthenticationEntity; + throw new Error(); + }), +}; + +describe('Validate Authentication Query Handler', () => { + let validateAuthenticationQueryHandler: ValidateAuthenticationQueryHandler; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: AUTHENTICATION_REPOSITORY, + useValue: mockAuthenticationRepository, + }, + { + provide: USERNAME_REPOSITORY, + useValue: mockUsernameRepository, + }, + ValidateAuthenticationQueryHandler, + ], + }).compile(); + + validateAuthenticationQueryHandler = + module.get( + ValidateAuthenticationQueryHandler, + ); + }); + + it('should be defined', () => { + expect(validateAuthenticationQueryHandler).toBeDefined(); + }); + + describe('execution', () => { + it('should validate an authentication query', async () => { + const validateAuthenticationQuery = new ValidateAuthenticationQuery( + validName, + validPassword, + ); + const id: AggregateID = await validateAuthenticationQueryHandler.execute( + validateAuthenticationQuery, + ); + expect(id).toBe(validUserId); + }); + it('should not validate an authentication query if username is invalid', async () => { + const validateAuthenticationQuery = new ValidateAuthenticationQuery( + 'invalidName', + validPassword, + ); + await expect( + validateAuthenticationQueryHandler.execute(validateAuthenticationQuery), + ).rejects.toBeInstanceOf(NotFoundException); + }); + it('should not validate an authentication query if authentication is not found', async () => { + const validateAuthenticationQuery = new ValidateAuthenticationQuery( + furtherInvalidName, + validPassword, + ); + await expect( + validateAuthenticationQueryHandler.execute(validateAuthenticationQuery), + ).rejects.toBeInstanceOf(NotFoundException); + }); + it('should not validate a failing authentication query', async () => { + const validateAuthenticationQuery = new ValidateAuthenticationQuery( + validName, + invalidPassword, + ); + await expect( + validateAuthenticationQueryHandler.execute(validateAuthenticationQuery), + ).rejects.toBeInstanceOf(UnauthorizedException); + }); + }); +}); diff --git a/src/modules/authentication/tests/unit/infrastructure/username.repository.spec.ts b/src/modules/authentication/tests/unit/infrastructure/username.repository.spec.ts index 23842f9..a9b08b9 100644 --- a/src/modules/authentication/tests/unit/infrastructure/username.repository.spec.ts +++ b/src/modules/authentication/tests/unit/infrastructure/username.repository.spec.ts @@ -59,6 +59,11 @@ describe('Username repository', () => { mockMessagePublisher, ); }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + it('should be defined', () => { expect(usernameRepository).toBeDefined(); }); @@ -75,6 +80,17 @@ describe('Username repository', () => { }); expect(username.getProps().name).toBe('john.doe@email.com'); }); + it('should find a username by its name', async () => { + jest.spyOn(usernameRepository, 'findOne'); + const username: UsernameEntity = await usernameRepository.findByName( + 'john.doe@email.com', + ); + expect(usernameRepository.findOne).toHaveBeenCalledTimes(1); + expect(usernameRepository.findOne).toHaveBeenCalledWith({ + username: 'john.doe@email.com', + }); + expect(username.getProps().name).toBe('john.doe@email.com'); + }); it('should update a username', async () => { jest.spyOn(usernameRepository, 'updateWhere'); const usernameToUpdate: UsernameEntity = await UsernameEntity.create({ diff --git a/src/modules/authentication/tests/unit/interface/validate-authentication.grpc.controller.spec.ts b/src/modules/authentication/tests/unit/interface/validate-authentication.grpc.controller.spec.ts new file mode 100644 index 0000000..e6babb3 --- /dev/null +++ b/src/modules/authentication/tests/unit/interface/validate-authentication.grpc.controller.spec.ts @@ -0,0 +1,75 @@ +import { IdResponse } from '@mobicoop/ddd-library'; +import { RpcExceptionCode } from '@mobicoop/ddd-library'; +import { ValidateAuthenticationRequestDto } from '@modules/authentication/interface/grpc-controllers/dtos/validate-authentication.request.dto'; +import { ValidateAuthenticationGrpcController } from '@modules/authentication/interface/grpc-controllers/validate-authentication.grpc.controller'; +import { QueryBus } from '@nestjs/cqrs'; +import { RpcException } from '@nestjs/microservices'; +import { Test, TestingModule } from '@nestjs/testing'; + +const validateAuthenticationRequest: ValidateAuthenticationRequestDto = { + password: 'John123', + name: 'john.doe@email.com', +}; + +const mockQueryBus = { + execute: jest + .fn() + .mockImplementationOnce(() => '78153e03-4861-4f58-a705-88526efee53b') + .mockImplementationOnce(() => { + throw new Error(); + }), +}; + +describe('Validate Authentication Grpc Controller', () => { + let validateAuthenticationGrpcController: ValidateAuthenticationGrpcController; + + beforeAll(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + { + provide: QueryBus, + useValue: mockQueryBus, + }, + ValidateAuthenticationGrpcController, + ], + }).compile(); + + validateAuthenticationGrpcController = + module.get( + ValidateAuthenticationGrpcController, + ); + }); + + afterEach(async () => { + jest.clearAllMocks(); + }); + + it('should be defined', () => { + expect(validateAuthenticationGrpcController).toBeDefined(); + }); + + it('should validate an authentication', async () => { + jest.spyOn(mockQueryBus, 'execute'); + const result: IdResponse = + await validateAuthenticationGrpcController.validate( + validateAuthenticationRequest, + ); + expect(result).toBeInstanceOf(IdResponse); + expect(result.id).toBe('78153e03-4861-4f58-a705-88526efee53b'); + expect(mockQueryBus.execute).toHaveBeenCalledTimes(1); + }); + + it('should throw a dedicated RpcException if authentication fails', async () => { + jest.spyOn(mockQueryBus, 'execute'); + expect.assertions(3); + try { + await validateAuthenticationGrpcController.validate( + validateAuthenticationRequest, + ); + } catch (e: any) { + expect(e).toBeInstanceOf(RpcException); + expect(e.error.code).toBe(RpcExceptionCode.PERMISSION_DENIED); + } + expect(mockQueryBus.execute).toHaveBeenCalledTimes(1); + }); +});