validate authentication use case
This commit is contained in:
parent
92ce0cd93a
commit
487ae9c38e
|
@ -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,
|
||||
|
|
|
@ -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>;
|
||||
};
|
||||
|
|
|
@ -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();
|
||||
}
|
||||
};
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -54,6 +54,11 @@ export class UsernameRepository
|
|||
type,
|
||||
});
|
||||
|
||||
findByName = async (name: string): Promise<UsernameEntity> =>
|
||||
this.findOne({
|
||||
username: name,
|
||||
});
|
||||
|
||||
updateUsername = async (
|
||||
oldName: string,
|
||||
entity: UsernameEntity,
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
@ -0,0 +1,11 @@
|
|||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class ValidateAuthenticationRequestDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
password: string;
|
||||
}
|
|
@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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({
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
Loading…
Reference in New Issue