validate authentication use case

This commit is contained in:
sbriat 2023-07-11 11:43:56 +02:00
parent 92ce0cd93a
commit 487ae9c38e
13 changed files with 357 additions and 3 deletions

View File

@ -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,

View File

@ -4,5 +4,6 @@ import { Type } from '../../domain/username.types';
export type UsernameRepositoryPort = RepositoryPort<UsernameEntity> & {
findByType(userId: string, type: Type): Promise<UsernameEntity>;
findByName(name: string): Promise<UsernameEntity>;
updateUsername(oldName: string, entity: UsernameEntity): Promise<void>;
};

View File

@ -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<AggregateID> => {
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();
}
};
}

View File

@ -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;
}
}

View File

@ -47,6 +47,9 @@ export class AuthenticationEntity extends AggregateRoot<AuthenticationProps> {
);
}
authenticate = async (password: string): Promise<boolean> =>
await bcrypt.compare(password, this.props.password);
validate(): void {
// entity business rules validation to protect it's invariant before saving entity to a database
}

View File

@ -54,6 +54,11 @@ export class UsernameRepository
type,
});
findByName = async (name: string): Promise<UsernameEntity> =>
this.findOne({
username: name,
});
updateUsername = async (
oldName: string,
entity: UsernameEntity,

View File

@ -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;
}

View File

@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class ValidateAuthenticationRequestDto {
@IsString()
@IsNotEmpty()
name: string;
@IsString()
@IsNotEmpty()
password: string;
}

View File

@ -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<IdResponse> {
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',
});
}
}
}

View File

@ -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();
});
});

View File

@ -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>(
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);
});
});
});

View File

@ -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({

View File

@ -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>(
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);
});
});