From 6f98c286b5b2b7764dd7a34e91d8abf4377b85c8 Mon Sep 17 00:00:00 2001 From: Thiago Ramalho Date: Wed, 29 Jan 2025 16:18:01 -0300 Subject: [PATCH 1/8] chore: add password strength by role --- .../authenticated-user.interface.ts | 5 +- packages/nestjs-common/src/domain/index.ts | 3 + .../interfaces/role-assignments.interface.ts | 8 +++ .../role/interfaces/role-ownable.interface.ts | 7 +++ .../interfaces/user-creatable.interface.ts | 4 +- .../user/interfaces/user-roles.interface.ts | 5 ++ .../interfaces/user-updatable.interface.ts | 4 +- ...assword-create-object-options.interface.ts | 4 ++ .../password-strength-service.interface.ts | 4 +- .../src/services/password-creation.service.ts | 2 +- .../src/services/password-strength.service.ts | 5 +- .../src/config/user-default.config.ts | 4 ++ .../nestjs-user/src/dto/user-roles.dto.ts | 13 ++++ .../src/entities/user-postgres.entity.ts | 3 + .../src/entities/user-sqlite.entity.ts | 3 + .../src/interfaces/user-entity.interface.ts | 5 +- .../src/interfaces/user-settings.interface.ts | 7 +++ .../listeners/invitation-accepted-listener.ts | 2 + .../src/services/user-mutate.service.ts | 3 +- .../src/services/user-password.service.ts | 60 ++++++++++++++++++- .../nestjs-user/src/user.module-definition.ts | 3 + packages/nestjs-user/src/user.types.ts | 5 ++ packages/nestjs-user/src/user.utils.ts | 15 +++++ yarn.lock | 1 + 24 files changed, 162 insertions(+), 13 deletions(-) create mode 100644 packages/nestjs-common/src/domain/role/interfaces/role-assignments.interface.ts create mode 100644 packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts create mode 100644 packages/nestjs-common/src/domain/user/interfaces/user-roles.interface.ts create mode 100644 packages/nestjs-user/src/dto/user-roles.dto.ts create mode 100644 packages/nestjs-user/src/user.utils.ts diff --git a/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user.interface.ts b/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user.interface.ts index 921ddea50..302379bfb 100644 --- a/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user.interface.ts +++ b/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user.interface.ts @@ -1,3 +1,6 @@ import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id.interface'; +import { UserRolesInterface } from '../../user/interfaces/user-roles.interface'; -export interface AuthenticatedUserInterface extends ReferenceIdInterface {} +export interface AuthenticatedUserInterface + extends ReferenceIdInterface, Partial +{ } diff --git a/packages/nestjs-common/src/domain/index.ts b/packages/nestjs-common/src/domain/index.ts index 883eeafdd..9d545141f 100644 --- a/packages/nestjs-common/src/domain/index.ts +++ b/packages/nestjs-common/src/domain/index.ts @@ -24,6 +24,8 @@ export { UserOwnableInterface } from './user/interfaces/user-ownable.interface'; export { UserUpdatableInterface } from './user/interfaces/user-updatable.interface'; export { UserInterface } from './user/interfaces/user.interface'; +export { UserRolesInterface } from './user/interfaces/user-roles.interface'; + export { FederatedCreatableInterface } from './federated/interfaces/federated-creatable.interface'; export { FederatedUpdatableInterface } from './federated/interfaces/federated-updatable.interface'; export { FederatedInterface } from './federated/interfaces/federated.interface'; @@ -33,6 +35,7 @@ export { RoleAssignmentCreatableInterface } from './role/interfaces/role-assignm export { RoleAssignmentInterface } from './role/interfaces/role-assignment.interface'; export { RoleCreatableInterface } from './role/interfaces/role-creatable.interface'; export { RoleUpdatableInterface } from './role/interfaces/role-updatable.interface'; +export { RoleOwnableInterface } from './role/interfaces/role-ownable.interface'; export { RoleInterface } from './role/interfaces/role.interface'; export { OtpClearInterface } from './otp/interfaces/otp-clear.interface'; diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-assignments.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-assignments.interface.ts new file mode 100644 index 000000000..a082459cb --- /dev/null +++ b/packages/nestjs-common/src/domain/role/interfaces/role-assignments.interface.ts @@ -0,0 +1,8 @@ +import { RoleAssignmentInterface } from './role-assignment.interface'; + +export interface RoleAssignmentsInterface { + /** + * roles for the assignment + */ + assignmentRoles: RoleAssignmentInterface[]; +} diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts new file mode 100644 index 000000000..7467ca15d --- /dev/null +++ b/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts @@ -0,0 +1,7 @@ +import { ReferenceId } from '../../../reference/interfaces/reference.types'; +import { RoleInterface } from './role.interface'; + +export interface RoleOwnableInterface { + roleId: ReferenceId; + role?: RoleInterface; +} diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts index 257542366..334667492 100644 --- a/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts +++ b/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts @@ -1,7 +1,9 @@ import { UserInterface } from './user.interface'; import { PasswordPlainInterface } from '../../password/interfaces/password-plain.interface'; +import { UserRolesInterface } from './user-roles.interface'; export interface UserCreatableInterface extends Pick, Partial>, - Partial {} + Partial, + Partial { } diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-roles.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-roles.interface.ts new file mode 100644 index 000000000..d9fb28d24 --- /dev/null +++ b/packages/nestjs-common/src/domain/user/interfaces/user-roles.interface.ts @@ -0,0 +1,5 @@ +import { RoleOwnableInterface } from '../../role/interfaces/role-ownable.interface'; + +export interface UserRolesInterface { + userRoles: RoleOwnableInterface []; +} \ No newline at end of file diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts index a96d76e9e..89144de55 100644 --- a/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts +++ b/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts @@ -1,8 +1,10 @@ import { UserCreatableInterface } from './user-creatable.interface'; import { PasswordPlainCurrentInterface } from '../../password/interfaces/password-plain-current.interface'; +import { UserRolesInterface } from './user-roles.interface'; export interface UserUpdatableInterface extends Partial< Pick >, - Partial {} + Partial, + Partial { } diff --git a/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts b/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts index 5290f2a28..ac2cb1ddb 100644 --- a/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts @@ -1,3 +1,5 @@ +import { PasswordStrengthEnum } from "../enum/password-strength.enum"; + export interface PasswordCreateObjectOptionsInterface { /** * Optional salt. If not provided, one will be generated. @@ -8,4 +10,6 @@ export interface PasswordCreateObjectOptionsInterface { * Set to true if password is required. */ required?: boolean; + + passwordStrength?: PasswordStrengthEnum } diff --git a/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts b/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts index 360106d9e..53796ac89 100644 --- a/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts @@ -1,3 +1,5 @@ +import { PasswordStrengthEnum } from "../enum/password-strength.enum"; + /** * Password Strength Service Interface */ @@ -7,5 +9,5 @@ export interface PasswordStrengthServiceInterface { * * @param password - The plain text password */ - isStrong(password: string): boolean; + isStrong(password: string, passwordStrength?: PasswordStrengthEnum): boolean; } diff --git a/packages/nestjs-password/src/services/password-creation.service.ts b/packages/nestjs-password/src/services/password-creation.service.ts index 72f2af196..118c6d567 100644 --- a/packages/nestjs-password/src/services/password-creation.service.ts +++ b/packages/nestjs-password/src/services/password-creation.service.ts @@ -80,7 +80,7 @@ export class PasswordCreationService // is the password in the object? if (typeof password === 'string') { // check strength - if (!this.passwordStrengthService.isStrong(password)) { + if (!this.passwordStrengthService.isStrong(password, options?.passwordStrength)) { throw new PasswordNotStrongException(); } } diff --git a/packages/nestjs-password/src/services/password-strength.service.ts b/packages/nestjs-password/src/services/password-strength.service.ts index 51dd41b20..de7869c09 100644 --- a/packages/nestjs-password/src/services/password-strength.service.ts +++ b/packages/nestjs-password/src/services/password-strength.service.ts @@ -28,9 +28,10 @@ export class PasswordStrengthService * @param password - the plain text password * @returns password strength */ - isStrong(password: string): boolean { + isStrong(password: string, passwordStrength?: PasswordStrengthEnum): boolean { // Get min password Strength - const minStrength = + // TODO: should it overwrite event is is lower then min password + const minStrength = passwordStrength || this.settings?.minPasswordStrength || PasswordStrengthEnum.None; // check strength of the password diff --git a/packages/nestjs-user/src/config/user-default.config.ts b/packages/nestjs-user/src/config/user-default.config.ts index e4af884ef..e20b67493 100644 --- a/packages/nestjs-user/src/config/user-default.config.ts +++ b/packages/nestjs-user/src/config/user-default.config.ts @@ -4,6 +4,7 @@ import { USER_MODULE_DEFAULT_SETTINGS_TOKEN, USER_MODULE_USER_PASSWORD_HISTORY_LIMIT_DAYS_DEFAULT, } from '../user.constants'; +import { defaultPasswordStrengthByRole } from '../user.utils'; /** * Default configuration for User module. @@ -24,6 +25,9 @@ export const userDefaultConfig = registerAs( enabled, limitDays: isNaN(limitDays) || limitDays < 1 ? undefined : limitDays, }, + passwordStrength: { + passwordStrengthCallback: defaultPasswordStrengthByRole + } }; }, ); diff --git a/packages/nestjs-user/src/dto/user-roles.dto.ts b/packages/nestjs-user/src/dto/user-roles.dto.ts new file mode 100644 index 000000000..2fa07d1e8 --- /dev/null +++ b/packages/nestjs-user/src/dto/user-roles.dto.ts @@ -0,0 +1,13 @@ +import { RoleOwnableInterface, UserRolesInterface } from '@concepta/nestjs-common'; +import { ApiProperty } from '@nestjs/swagger'; +import { Exclude, Expose } from 'class-transformer'; + +@Exclude() +export class UserRolesDto implements UserRolesInterface { + @Expose() + @ApiProperty({ + type: 'array', + description: 'User roles', + }) + userRoles!: RoleOwnableInterface[]; +} diff --git a/packages/nestjs-user/src/entities/user-postgres.entity.ts b/packages/nestjs-user/src/entities/user-postgres.entity.ts index 0a33aa0b2..d44aaf333 100644 --- a/packages/nestjs-user/src/entities/user-postgres.entity.ts +++ b/packages/nestjs-user/src/entities/user-postgres.entity.ts @@ -2,6 +2,7 @@ import { Column } from 'typeorm'; import { CommonPostgresEntity } from '@concepta/typeorm-common'; import { UserEntityInterface } from '../interfaces/user-entity.interface'; import { UserPasswordHistoryEntityInterface } from '../interfaces/user-password-history-entity.interface'; +import { RoleOwnableInterface } from '@concepta/nestjs-common'; /** * User Entity @@ -40,5 +41,7 @@ export abstract class UserPostgresEntity @Column({ type: 'text', nullable: true, default: null }) passwordSalt: string | null = null; + userRoles?: RoleOwnableInterface[]; + userPasswordHistory?: UserPasswordHistoryEntityInterface; } diff --git a/packages/nestjs-user/src/entities/user-sqlite.entity.ts b/packages/nestjs-user/src/entities/user-sqlite.entity.ts index 8909ea186..0ad015dbd 100644 --- a/packages/nestjs-user/src/entities/user-sqlite.entity.ts +++ b/packages/nestjs-user/src/entities/user-sqlite.entity.ts @@ -2,6 +2,7 @@ import { Column } from 'typeorm'; import { CommonSqliteEntity } from '@concepta/typeorm-common'; import { UserEntityInterface } from '../interfaces/user-entity.interface'; import { UserPasswordHistoryEntityInterface } from '../interfaces/user-password-history-entity.interface'; +import { RoleOwnableInterface } from '@concepta/nestjs-common'; export abstract class UserSqliteEntity extends CommonSqliteEntity @@ -37,5 +38,7 @@ export abstract class UserSqliteEntity @Column({ type: 'text', nullable: true, default: null }) passwordSalt: string | null = null; + userRoles?: RoleOwnableInterface[]; + userPasswordHistory?: UserPasswordHistoryEntityInterface; } diff --git a/packages/nestjs-user/src/interfaces/user-entity.interface.ts b/packages/nestjs-user/src/interfaces/user-entity.interface.ts index dcd35d273..103ff9131 100644 --- a/packages/nestjs-user/src/interfaces/user-entity.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-entity.interface.ts @@ -1,6 +1,7 @@ -import { UserInterface } from '@concepta/nestjs-common'; +import { UserInterface, UserRolesInterface } from '@concepta/nestjs-common'; import { PasswordStorageInterface } from '@concepta/nestjs-password'; export interface UserEntityInterface extends UserInterface, - PasswordStorageInterface {} + PasswordStorageInterface, + Partial { } diff --git a/packages/nestjs-user/src/interfaces/user-settings.interface.ts b/packages/nestjs-user/src/interfaces/user-settings.interface.ts index f13691ba9..eee7feda0 100644 --- a/packages/nestjs-user/src/interfaces/user-settings.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-settings.interface.ts @@ -7,6 +7,8 @@ import { InvitationGetUserEventPayloadInterface, InvitationGetUserEventResponseInterface, } from '@concepta/nestjs-common'; +import { PasswordStrengthEnum } from '@concepta/nestjs-password'; +import { PasswordStrengthByRoleCallback } from '../user.types'; export interface UserSettingsInterface { invitationRequestEvent?: EventClassInterface< @@ -28,4 +30,9 @@ export interface UserSettingsInterface { */ limitDays?: number | undefined; }; + + passwordStrength?: { + passwordStrengthCallback?: PasswordStrengthByRoleCallback + + } } diff --git a/packages/nestjs-user/src/listeners/invitation-accepted-listener.ts b/packages/nestjs-user/src/listeners/invitation-accepted-listener.ts index fe32ab24c..44bd17089 100644 --- a/packages/nestjs-user/src/listeners/invitation-accepted-listener.ts +++ b/packages/nestjs-user/src/listeners/invitation-accepted-listener.ts @@ -70,6 +70,8 @@ export class InvitationAcceptedListener throw new UserNotFoundException(); } + // TODO: UPDATE PASSWORD how to get roles here? + // should return on user lookup? await this.userMutateService.update( { ...user, password: newPassword }, event.payload?.queryOptions, diff --git a/packages/nestjs-user/src/services/user-mutate.service.ts b/packages/nestjs-user/src/services/user-mutate.service.ts index de898d110..c859911a4 100644 --- a/packages/nestjs-user/src/services/user-mutate.service.ts +++ b/packages/nestjs-user/src/services/user-mutate.service.ts @@ -4,6 +4,7 @@ import { MutateService } from '@concepta/typeorm-common'; import { PasswordPlainInterface, UserCreatableInterface, + UserRolesInterface, UserUpdatableInterface, } from '@concepta/nestjs-common'; import { InjectDynamicRepository } from '@concepta/nestjs-typeorm-ext'; @@ -45,7 +46,7 @@ export class UserMutateService } protected async transform>( - user: T | (T & PasswordPlainInterface), + user: T | (T & PasswordPlainInterface & Partial), ): Promise> { // do we need to hash the password? if ('password' in user && typeof user.password === 'string') { diff --git a/packages/nestjs-user/src/services/user-password.service.ts b/packages/nestjs-user/src/services/user-password.service.ts index 8a51b3c1f..16000b02e 100644 --- a/packages/nestjs-user/src/services/user-password.service.ts +++ b/packages/nestjs-user/src/services/user-password.service.ts @@ -1,5 +1,5 @@ import { HttpStatus, Inject, Injectable, Optional } from '@nestjs/common'; -import { ReferenceId, ReferenceIdInterface } from '@concepta/nestjs-common'; +import { ReferenceId, ReferenceIdInterface, RoleOwnableInterface, UserInterface, UserRolesInterface } from '@concepta/nestjs-common'; import { AuthenticatedUserInterface, PasswordPlainCurrentInterface, @@ -10,6 +10,7 @@ import { PasswordCreationService, PasswordCreationServiceInterface, PasswordStorageInterface, + PasswordStrengthEnum, } from '@concepta/nestjs-password'; import { UserPasswordServiceInterface } from '../interfaces/user-password-service.interface'; @@ -19,6 +20,8 @@ import { UserLookupService } from './user-lookup.service'; import { UserPasswordHistoryService } from './user-password-history.service'; import { UserException } from '../exceptions/user-exception'; import { UserNotFoundException } from '../exceptions/user-not-found-exception'; +import { USER_MODULE_SETTINGS_TOKEN } from '../user.constants'; +import { UserSettingsInterface } from '../interfaces/user-settings.interface'; /** * User password service @@ -36,6 +39,8 @@ export class UserPasswordService implements UserPasswordServiceInterface { protected readonly userLookupService: UserLookupServiceInterface, @Inject(PasswordCreationService) protected readonly passwordCreationService: PasswordCreationServiceInterface, + @Inject(USER_MODULE_SETTINGS_TOKEN) + protected readonly userSettings: UserSettingsInterface, @Optional() @Inject(UserPasswordHistoryService) private userPasswordHistoryService?: UserPasswordHistoryServiceInterface, @@ -43,7 +48,9 @@ export class UserPasswordService implements UserPasswordServiceInterface { async setPassword( passwordDto: Partial< - PasswordPlainInterface & PasswordPlainCurrentInterface + PasswordPlainInterface & + PasswordPlainCurrentInterface & + UserRolesInterface >, userToUpdateId?: ReferenceId, authorizedUser?: AuthenticatedUserInterface, @@ -77,10 +84,13 @@ export class UserPasswordService implements UserPasswordServiceInterface { // create safe object const targetSafe = { ...passwordDto, password }; - // call the password creation service + const roles = await this.getUserRoles(passwordDto, userToUpdateId); + const passwordStrength = await this.getPasswordStrength(roles); + const userWithPasswordHashed = await this.passwordCreationService.createObject(targetSafe, { required: false, + passwordStrength }); // push password history if necessary @@ -102,7 +112,51 @@ export class UserPasswordService implements UserPasswordServiceInterface { // return the object untouched return passwordDto; } + // TODO: add function to call the callback and this can ber overwrite + /** + * Get user roles from either the password DTO or by looking up the user. + * + * @param passwordDto - Password DTO that may contain user roles + * @param userToUpdateId - Optional ID of user to lookup roles for + * @returns Array of role names, or empty array if no roles found + */ + protected async getUserRoles( + passwordDto: Partial, + userToUpdateId?: ReferenceId, + ): Promise { + // get roles from payload + if (passwordDto.userRoles && passwordDto.userRoles?.some(userRole => userRole.role?.name)) { + return this.mapRoles(passwordDto.userRoles) + } + // get roles based on user id + if (userToUpdateId) { + const user: (ReferenceIdInterface & Partial) | null = + await this.userLookupService.byId(userToUpdateId); + if (user && user.userRoles) + return this.mapRoles(user.userRoles) + } + + return []; + } + + mapRoles(userRoles: RoleOwnableInterface[]) { + return [...new Set(userRoles + .filter(userRole => userRole.role?.name) + .map(userRole => userRole.role?.name ?? ''))] + } + /** + * Get password strength based on user roles. if callback is not enough + * user can always be able to overwrite this method to take advantage of injections + * + * @param roles - Array of role names to check against + * @returns Password strength enum value if callback exists and roles are provided, + * undefined otherwise + */ + protected getPasswordStrength(roles?: string[]): PasswordStrengthEnum | undefined { + return roles && this.userSettings.passwordStrength?.passwordStrengthCallback && + this.userSettings.passwordStrength?.passwordStrengthCallback(roles || [], PasswordStrengthEnum.Medium); + } async getPasswordStore( userId: ReferenceId, ): Promise { diff --git a/packages/nestjs-user/src/user.module-definition.ts b/packages/nestjs-user/src/user.module-definition.ts index 015660e36..d1bf81dfd 100644 --- a/packages/nestjs-user/src/user.module-definition.ts +++ b/packages/nestjs-user/src/user.module-definition.ts @@ -192,6 +192,7 @@ export function createUserPasswordServiceProvider( RAW_OPTIONS_TOKEN, UserLookupService, PasswordCreationService, + USER_MODULE_SETTINGS_TOKEN, { token: UserPasswordHistoryService, optional: true, @@ -201,6 +202,7 @@ export function createUserPasswordServiceProvider( options: UserOptionsInterface, userLookUpService: UserLookupServiceInterface, passwordCreationService: PasswordCreationService, + userSettings: UserSettingsInterface, userPasswordHistoryService?: UserPasswordHistoryService, ) => optionsOverrides?.userPasswordService ?? @@ -208,6 +210,7 @@ export function createUserPasswordServiceProvider( new UserPasswordService( userLookUpService, passwordCreationService, + userSettings, userPasswordHistoryService, ), }; diff --git a/packages/nestjs-user/src/user.types.ts b/packages/nestjs-user/src/user.types.ts index b5559d5bf..23b4fea4b 100644 --- a/packages/nestjs-user/src/user.types.ts +++ b/packages/nestjs-user/src/user.types.ts @@ -1,4 +1,9 @@ +import { PasswordStrengthEnum } from "@concepta/nestjs-password"; + export enum UserResource { 'One' = 'user', 'Many' = 'user-list', } + +export type PasswordStrengthByRoleCallback = + (userRoles: string[], defaultPasswordStrength: PasswordStrengthEnum) => PasswordStrengthEnum; \ No newline at end of file diff --git a/packages/nestjs-user/src/user.utils.ts b/packages/nestjs-user/src/user.utils.ts new file mode 100644 index 000000000..7dcbb2a49 --- /dev/null +++ b/packages/nestjs-user/src/user.utils.ts @@ -0,0 +1,15 @@ +import { PasswordStrengthEnum } from "@concepta/nestjs-password"; +import { PasswordStrengthByRoleCallback } from "./user.types"; + +export const defaultPasswordStrengthByRole: PasswordStrengthByRoleCallback = + (roles: string[], defaultPasswordStrength: PasswordStrengthEnum) => { + // Default implementation - require medium strength for all roles + if (roles.includes('admin')) { + return PasswordStrengthEnum.VeryStrong; + } + if (roles.includes('user')) { + return PasswordStrengthEnum.Strong; + } + + return defaultPasswordStrength; +}; \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index 241cd3c33..e39f4108e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1507,6 +1507,7 @@ __metadata: accesscontrol: "npm:^2.2.1" supertest: "npm:^6.3.4" peerDependencies: + "@concepta/nestjs-role": ^6.0.0-alpha.1 class-transformer: "*" class-validator: "*" typeorm: ^0.3.0 From 2971a81c25960a67fc2fa1b0ab03c5d836502bf5 Mon Sep 17 00:00:00 2001 From: Thiago Ramalho Date: Thu, 30 Jan 2025 16:15:04 -0300 Subject: [PATCH 2/8] chore: add tests for password strength --- .../authenticated-user.interface.ts | 4 +- .../role/interfaces/role-ownable.interface.ts | 2 +- .../interfaces/user-creatable.interface.ts | 2 +- .../user/interfaces/user-roles.interface.ts | 4 +- .../interfaces/user-updatable.interface.ts | 4 +- ...assword-create-object-options.interface.ts | 4 +- .../password-strength-service.interface.ts | 2 +- .../src/services/password-creation.service.ts | 7 +- .../src/services/password-strength.service.ts | 11 +- .../src/config/user-default.config.ts | 4 +- .../nestjs-user/src/dto/user-roles.dto.ts | 8 +- .../src/entities/user-postgres.entity.ts | 2 +- .../src/entities/user-sqlite.entity.ts | 2 +- .../src/interfaces/user-entity.interface.ts | 4 +- .../src/interfaces/user-settings.interface.ts | 6 +- .../src/services/user-password.service.ts | 68 ++++-- .../src/user.controller.pw.e2e-spec.ts | 224 +++++++++++++++++- packages/nestjs-user/src/user.types.ts | 7 +- packages/nestjs-user/src/user.utils.ts | 13 +- 19 files changed, 311 insertions(+), 67 deletions(-) diff --git a/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user.interface.ts b/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user.interface.ts index 302379bfb..49bd0e662 100644 --- a/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user.interface.ts +++ b/packages/nestjs-common/src/domain/authentication/interfaces/authenticated-user.interface.ts @@ -2,5 +2,5 @@ import { ReferenceIdInterface } from '../../../reference/interfaces/reference-id import { UserRolesInterface } from '../../user/interfaces/user-roles.interface'; export interface AuthenticatedUserInterface - extends ReferenceIdInterface, Partial -{ } + extends ReferenceIdInterface, + Partial {} diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts index 7467ca15d..58a3ab38a 100644 --- a/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts +++ b/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts @@ -3,5 +3,5 @@ import { RoleInterface } from './role.interface'; export interface RoleOwnableInterface { roleId: ReferenceId; - role?: RoleInterface; + role: Partial; } diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts index 334667492..19883eddf 100644 --- a/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts +++ b/packages/nestjs-common/src/domain/user/interfaces/user-creatable.interface.ts @@ -6,4 +6,4 @@ export interface UserCreatableInterface extends Pick, Partial>, Partial, - Partial { } + Partial {} diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-roles.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-roles.interface.ts index d9fb28d24..c001e9499 100644 --- a/packages/nestjs-common/src/domain/user/interfaces/user-roles.interface.ts +++ b/packages/nestjs-common/src/domain/user/interfaces/user-roles.interface.ts @@ -1,5 +1,5 @@ import { RoleOwnableInterface } from '../../role/interfaces/role-ownable.interface'; export interface UserRolesInterface { - userRoles: RoleOwnableInterface []; -} \ No newline at end of file + userRoles: Partial>[]; +} diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts index 89144de55..a60fda402 100644 --- a/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts +++ b/packages/nestjs-common/src/domain/user/interfaces/user-updatable.interface.ts @@ -6,5 +6,5 @@ export interface UserUpdatableInterface extends Partial< Pick >, - Partial, - Partial { } + Partial, + Partial {} diff --git a/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts b/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts index ac2cb1ddb..562bf2324 100644 --- a/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts @@ -1,4 +1,4 @@ -import { PasswordStrengthEnum } from "../enum/password-strength.enum"; +import { PasswordStrengthEnum } from '../enum/password-strength.enum'; export interface PasswordCreateObjectOptionsInterface { /** @@ -11,5 +11,5 @@ export interface PasswordCreateObjectOptionsInterface { */ required?: boolean; - passwordStrength?: PasswordStrengthEnum + passwordStrength?: PasswordStrengthEnum | null; } diff --git a/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts b/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts index 53796ac89..7655b928b 100644 --- a/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts @@ -1,4 +1,4 @@ -import { PasswordStrengthEnum } from "../enum/password-strength.enum"; +import { PasswordStrengthEnum } from '../enum/password-strength.enum'; /** * Password Strength Service Interface diff --git a/packages/nestjs-password/src/services/password-creation.service.ts b/packages/nestjs-password/src/services/password-creation.service.ts index 118c6d567..6de32b9db 100644 --- a/packages/nestjs-password/src/services/password-creation.service.ts +++ b/packages/nestjs-password/src/services/password-creation.service.ts @@ -80,7 +80,12 @@ export class PasswordCreationService // is the password in the object? if (typeof password === 'string') { // check strength - if (!this.passwordStrengthService.isStrong(password, options?.passwordStrength)) { + if ( + !this.passwordStrengthService.isStrong( + password, + options?.passwordStrength, + ) + ) { throw new PasswordNotStrongException(); } } diff --git a/packages/nestjs-password/src/services/password-strength.service.ts b/packages/nestjs-password/src/services/password-strength.service.ts index de7869c09..84ce9bdb7 100644 --- a/packages/nestjs-password/src/services/password-strength.service.ts +++ b/packages/nestjs-password/src/services/password-strength.service.ts @@ -28,11 +28,16 @@ export class PasswordStrengthService * @param password - the plain text password * @returns password strength */ - isStrong(password: string, passwordStrength?: PasswordStrengthEnum): boolean { + isStrong( + password: string, + passwordStrength?: PasswordStrengthEnum | null, + ): boolean { // Get min password Strength // TODO: should it overwrite event is is lower then min password - const minStrength = passwordStrength || - this.settings?.minPasswordStrength || PasswordStrengthEnum.None; + const minStrength = + passwordStrength || + this.settings?.minPasswordStrength || + PasswordStrengthEnum.None; // check strength of the password const result = zxcvbn(password); diff --git a/packages/nestjs-user/src/config/user-default.config.ts b/packages/nestjs-user/src/config/user-default.config.ts index e20b67493..1d2ac4bfe 100644 --- a/packages/nestjs-user/src/config/user-default.config.ts +++ b/packages/nestjs-user/src/config/user-default.config.ts @@ -26,8 +26,8 @@ export const userDefaultConfig = registerAs( limitDays: isNaN(limitDays) || limitDays < 1 ? undefined : limitDays, }, passwordStrength: { - passwordStrengthCallback: defaultPasswordStrengthByRole - } + passwordStrengthCallback: defaultPasswordStrengthByRole, + }, }; }, ); diff --git a/packages/nestjs-user/src/dto/user-roles.dto.ts b/packages/nestjs-user/src/dto/user-roles.dto.ts index 2fa07d1e8..df910aa0f 100644 --- a/packages/nestjs-user/src/dto/user-roles.dto.ts +++ b/packages/nestjs-user/src/dto/user-roles.dto.ts @@ -1,4 +1,7 @@ -import { RoleOwnableInterface, UserRolesInterface } from '@concepta/nestjs-common'; +import { + RoleOwnableInterface, + UserRolesInterface, +} from '@concepta/nestjs-common'; import { ApiProperty } from '@nestjs/swagger'; import { Exclude, Expose } from 'class-transformer'; @@ -7,7 +10,8 @@ export class UserRolesDto implements UserRolesInterface { @Expose() @ApiProperty({ type: 'array', + isArray: true, description: 'User roles', }) - userRoles!: RoleOwnableInterface[]; + userRoles!: Partial[]; } diff --git a/packages/nestjs-user/src/entities/user-postgres.entity.ts b/packages/nestjs-user/src/entities/user-postgres.entity.ts index d44aaf333..e78b0acd9 100644 --- a/packages/nestjs-user/src/entities/user-postgres.entity.ts +++ b/packages/nestjs-user/src/entities/user-postgres.entity.ts @@ -41,7 +41,7 @@ export abstract class UserPostgresEntity @Column({ type: 'text', nullable: true, default: null }) passwordSalt: string | null = null; - userRoles?: RoleOwnableInterface[]; + userRoles?: Partial[]; userPasswordHistory?: UserPasswordHistoryEntityInterface; } diff --git a/packages/nestjs-user/src/entities/user-sqlite.entity.ts b/packages/nestjs-user/src/entities/user-sqlite.entity.ts index 0ad015dbd..8659de58d 100644 --- a/packages/nestjs-user/src/entities/user-sqlite.entity.ts +++ b/packages/nestjs-user/src/entities/user-sqlite.entity.ts @@ -38,7 +38,7 @@ export abstract class UserSqliteEntity @Column({ type: 'text', nullable: true, default: null }) passwordSalt: string | null = null; - userRoles?: RoleOwnableInterface[]; + userRoles?: Partial[]; userPasswordHistory?: UserPasswordHistoryEntityInterface; } diff --git a/packages/nestjs-user/src/interfaces/user-entity.interface.ts b/packages/nestjs-user/src/interfaces/user-entity.interface.ts index 103ff9131..248f085cb 100644 --- a/packages/nestjs-user/src/interfaces/user-entity.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-entity.interface.ts @@ -3,5 +3,5 @@ import { PasswordStorageInterface } from '@concepta/nestjs-password'; export interface UserEntityInterface extends UserInterface, - PasswordStorageInterface, - Partial { } + PasswordStorageInterface, + Partial {} diff --git a/packages/nestjs-user/src/interfaces/user-settings.interface.ts b/packages/nestjs-user/src/interfaces/user-settings.interface.ts index eee7feda0..e9f7a7485 100644 --- a/packages/nestjs-user/src/interfaces/user-settings.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-settings.interface.ts @@ -7,7 +7,6 @@ import { InvitationGetUserEventPayloadInterface, InvitationGetUserEventResponseInterface, } from '@concepta/nestjs-common'; -import { PasswordStrengthEnum } from '@concepta/nestjs-password'; import { PasswordStrengthByRoleCallback } from '../user.types'; export interface UserSettingsInterface { @@ -32,7 +31,6 @@ export interface UserSettingsInterface { }; passwordStrength?: { - passwordStrengthCallback?: PasswordStrengthByRoleCallback - - } + passwordStrengthCallback?: PasswordStrengthByRoleCallback; + }; } diff --git a/packages/nestjs-user/src/services/user-password.service.ts b/packages/nestjs-user/src/services/user-password.service.ts index 16000b02e..a4b5839da 100644 --- a/packages/nestjs-user/src/services/user-password.service.ts +++ b/packages/nestjs-user/src/services/user-password.service.ts @@ -1,5 +1,10 @@ import { HttpStatus, Inject, Injectable, Optional } from '@nestjs/common'; -import { ReferenceId, ReferenceIdInterface, RoleOwnableInterface, UserInterface, UserRolesInterface } from '@concepta/nestjs-common'; +import { + ReferenceId, + ReferenceIdInterface, + RoleOwnableInterface, + UserRolesInterface, +} from '@concepta/nestjs-common'; import { AuthenticatedUserInterface, PasswordPlainCurrentInterface, @@ -49,8 +54,8 @@ export class UserPasswordService implements UserPasswordServiceInterface { async setPassword( passwordDto: Partial< PasswordPlainInterface & - PasswordPlainCurrentInterface & - UserRolesInterface + PasswordPlainCurrentInterface & + UserRolesInterface >, userToUpdateId?: ReferenceId, authorizedUser?: AuthenticatedUserInterface, @@ -86,11 +91,11 @@ export class UserPasswordService implements UserPasswordServiceInterface { const roles = await this.getUserRoles(passwordDto, userToUpdateId); const passwordStrength = await this.getPasswordStrength(roles); - + const userWithPasswordHashed = await this.passwordCreationService.createObject(targetSafe, { required: false, - passwordStrength + passwordStrength, }); // push password history if necessary @@ -116,7 +121,7 @@ export class UserPasswordService implements UserPasswordServiceInterface { /** * Get user roles from either the password DTO or by looking up the user. - * + * * @param passwordDto - Password DTO that may contain user roles * @param userToUpdateId - Optional ID of user to lookup roles for * @returns Array of role names, or empty array if no roles found @@ -125,37 +130,50 @@ export class UserPasswordService implements UserPasswordServiceInterface { passwordDto: Partial, userToUpdateId?: ReferenceId, ): Promise { - // get roles from payload - if (passwordDto.userRoles && passwordDto.userRoles?.some(userRole => userRole.role?.name)) { - return this.mapRoles(passwordDto.userRoles) - } // get roles based on user id if (userToUpdateId) { - const user: (ReferenceIdInterface & Partial) | null = - await this.userLookupService.byId(userToUpdateId); - if (user && user.userRoles) - return this.mapRoles(user.userRoles) - } + const user: + | (ReferenceIdInterface & Partial) + | null = await this.userLookupService.byId(userToUpdateId); + if (user && user.userRoles) return this.mapRoles(user.userRoles); + } + + // get roles from payload + if ( + passwordDto.userRoles && + passwordDto.userRoles?.some((userRole) => userRole.role?.name) + ) { + return this.mapRoles(passwordDto.userRoles); + } return []; } - mapRoles(userRoles: RoleOwnableInterface[]) { - return [...new Set(userRoles - .filter(userRole => userRole.role?.name) - .map(userRole => userRole.role?.name ?? ''))] + mapRoles(userRoles: Partial>[]) { + return [ + ...new Set( + userRoles + .filter((userRole) => userRole.role?.name) + .map((userRole) => userRole.role?.name ?? ''), + ), + ]; } /** - * Get password strength based on user roles. if callback is not enough + * Get password strength based on user roles. if callback is not enough * user can always be able to overwrite this method to take advantage of injections - * + * * @param roles - Array of role names to check against - * @returns Password strength enum value if callback exists and roles are provided, + * @returns Password strength enum value if callback exists and roles are provided, * undefined otherwise */ - protected getPasswordStrength(roles?: string[]): PasswordStrengthEnum | undefined { - return roles && this.userSettings.passwordStrength?.passwordStrengthCallback && - this.userSettings.passwordStrength?.passwordStrengthCallback(roles || [], PasswordStrengthEnum.Medium); + protected getPasswordStrength( + roles?: string[], + ): PasswordStrengthEnum | null | undefined { + return ( + roles && + this.userSettings.passwordStrength?.passwordStrengthCallback && + this.userSettings.passwordStrength?.passwordStrengthCallback(roles || []) + ); } async getPasswordStore( userId: ReferenceId, diff --git a/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts b/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts index 7f8eb04f8..d7618faa0 100644 --- a/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts +++ b/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts @@ -11,6 +11,7 @@ import { PasswordCreationService, PasswordStorageInterface, PasswordStorageService, + PasswordStrengthEnum, PasswordValidationService, } from '@concepta/nestjs-password'; import { SeedingSource } from '@concepta/typeorm-seeding'; @@ -98,12 +99,11 @@ describe('User Controller (password e2e)', () => { entity: UserPasswordHistoryEntityFixture, }); - fakeUser = await userFactory.create( - await passwordStorageService.hashObject({ - id: userId, - password: userPassword, - }), - ); + const userWithPassword = await passwordStorageService.hashObject({ + id: userId, + password: userPassword, + }); + fakeUser = await userFactory.create(userWithPassword); await userPasswordHistoryFactory.create( await passwordStorageService.hashObject({ @@ -238,5 +238,217 @@ describe('User Controller (password e2e)', () => { } }); }); + + describe(`UserPasswordController user WITH roles`, () => { + it('Should update password', async () => { + passwordCreationService['settings'].requireCurrentToUpdate = false; + + jest.spyOn(userLookupService, 'byId').mockResolvedValueOnce({ + ...fakeUser, + userRoles: [ + { + role: { + name: 'manager', + }, + }, + ], + } as UserEntityFixture); + + await supertest(app.getHttpServer()) + .patch(`/user/${userId}`) + .set('Authorization', `bearer ${authToken}`) + .send({ + password: userNewPassword, + }) + .expect(200); + + const updatedUser = await userLookupService.byId(userId); + + if (updatedUser) { + const isPasswordValid = + await passwordValidationService.validateObject( + userNewPassword, + updatedUser, + ); + expect(isPasswordValid).toEqual(true); + } else { + fail('User not updated'); + } + }); + + it('Should not update weak password for admin', async () => { + passwordCreationService['settings'].requireCurrentToUpdate = false; + userPasswordService['userSettings'].passwordStrength = { + passwordStrengthCallback: ( + roles: string[], + ): PasswordStrengthEnum | null => { + if (roles.includes('admin')) { + return PasswordStrengthEnum.VeryStrong; + } + return null; + }, + }; + + jest.spyOn(userLookupService, 'byId').mockResolvedValue({ + ...fakeUser, + userRoles: [ + { + role: { + name: 'admin', + }, + }, + ], + }); + + await supertest(app.getHttpServer()) + .patch(`/user/${userId}`) + .set('Authorization', `bearer ${authToken}`) + .send({ + password: userNewPassword, + }) + .expect(400) + .expect((res) => { + expect(res.body.message).toBe('Password is not strong enough'); + expect(res.body.errorCode).toBe('PASSWORD_NOT_STRONG_ERROR'); + }); + }); + + it('Should not update weak password for admin', async () => { + passwordCreationService['settings'].requireCurrentToUpdate = false; + userPasswordService['userSettings'].passwordStrength = { + passwordStrengthCallback: ( + roles: string[], + ): PasswordStrengthEnum | null => { + if (roles.includes('admin')) { + return PasswordStrengthEnum.VeryStrong; + } + if (roles.includes('user')) { + return PasswordStrengthEnum.None; + } + return null; + }, + }; + + jest.spyOn(userLookupService, 'byId').mockResolvedValue({ + ...fakeUser, + userRoles: [ + { + role: { + name: 'admin', + }, + }, + { + role: { + name: 'user', + }, + }, + ], + }); + + await supertest(app.getHttpServer()) + .patch(`/user/${userId}`) + .set('Authorization', `bearer ${authToken}`) + .send({ + password: userNewPassword, + }) + .expect(400) + .expect((res) => { + expect(res.body.message).toBe('Password is not strong enough'); + expect(res.body.errorCode).toBe('PASSWORD_NOT_STRONG_ERROR'); + }); + }); + + it('Should update with weak password for admin', async () => { + passwordCreationService['settings'].requireCurrentToUpdate = false; + userPasswordService['userSettings'].passwordStrength = { + passwordStrengthCallback: ( + roles: string[], + ): PasswordStrengthEnum | null => { + if (roles.includes('user')) { + return PasswordStrengthEnum.None; + } + return null; + }, + }; + + jest.spyOn(userLookupService, 'byId').mockResolvedValue({ + ...fakeUser, + userRoles: [ + { + role: { + name: 'user', + }, + }, + ], + }); + + await supertest(app.getHttpServer()) + .patch(`/user/${userId}`) + .set('Authorization', `bearer ${authToken}`) + .send({ + password: userNewPassword, + }) + .expect(200); + }); + + it('Should update password for admin and user ', async () => { + passwordCreationService['settings'].requireCurrentToUpdate = false; + userPasswordService['userSettings'].passwordStrength = { + passwordStrengthCallback: ( + roles: string[], + ): PasswordStrengthEnum | null => { + if (roles.includes('admin')) { + return PasswordStrengthEnum.VeryStrong; + } + if (roles.includes('user')) { + return PasswordStrengthEnum.None; + } + return null; + }, + }; + + jest.spyOn(userLookupService, 'byId').mockResolvedValue({ + ...fakeUser, + userRoles: [ + { + role: { + name: 'user', + }, + }, + ], + }); + + await supertest(app.getHttpServer()) + .patch(`/user/${userId}`) + .set('Authorization', `bearer ${authToken}`) + .send({ + password: userNewPassword, + }) + .expect(200); + + jest.spyOn(userLookupService, 'byId').mockResolvedValue({ + ...fakeUser, + userRoles: [ + { + role: { + name: 'admin', + }, + }, + ], + }); + + await supertest(app.getHttpServer()) + .patch(`/user/${userId}`) + .set('Authorization', `bearer ${authToken}`) + .send({ + password: userPassword, + }) + .expect(400) + .expect((res) => { + expect(res.body.message).toBe('Password is not strong enough'); + expect(res.body.errorCode).toBe('PASSWORD_NOT_STRONG_ERROR'); + }); + }); + }); }); }); diff --git a/packages/nestjs-user/src/user.types.ts b/packages/nestjs-user/src/user.types.ts index 23b4fea4b..a98cfae60 100644 --- a/packages/nestjs-user/src/user.types.ts +++ b/packages/nestjs-user/src/user.types.ts @@ -1,9 +1,10 @@ -import { PasswordStrengthEnum } from "@concepta/nestjs-password"; +import { PasswordStrengthEnum } from '@concepta/nestjs-password'; export enum UserResource { 'One' = 'user', 'Many' = 'user-list', } -export type PasswordStrengthByRoleCallback = - (userRoles: string[], defaultPasswordStrength: PasswordStrengthEnum) => PasswordStrengthEnum; \ No newline at end of file +export type PasswordStrengthByRoleCallback = ( + userRoles: string[], +) => PasswordStrengthEnum | null; diff --git a/packages/nestjs-user/src/user.utils.ts b/packages/nestjs-user/src/user.utils.ts index 7dcbb2a49..1f5de5ddf 100644 --- a/packages/nestjs-user/src/user.utils.ts +++ b/packages/nestjs-user/src/user.utils.ts @@ -1,8 +1,9 @@ -import { PasswordStrengthEnum } from "@concepta/nestjs-password"; -import { PasswordStrengthByRoleCallback } from "./user.types"; +import { PasswordStrengthEnum } from '@concepta/nestjs-password'; +import { PasswordStrengthByRoleCallback } from './user.types'; -export const defaultPasswordStrengthByRole: PasswordStrengthByRoleCallback = - (roles: string[], defaultPasswordStrength: PasswordStrengthEnum) => { +export const defaultPasswordStrengthByRole: PasswordStrengthByRoleCallback = ( + roles: string[], +): PasswordStrengthEnum | null => { // Default implementation - require medium strength for all roles if (roles.includes('admin')) { return PasswordStrengthEnum.VeryStrong; @@ -11,5 +12,5 @@ export const defaultPasswordStrengthByRole: PasswordStrengthByRoleCallback = return PasswordStrengthEnum.Strong; } - return defaultPasswordStrength; -}; \ No newline at end of file + return null; +}; From e64152cfee9c91f66eec9900b2a262669e969d69 Mon Sep 17 00:00:00 2001 From: Thiago Ramalho Date: Thu, 30 Jan 2025 16:18:44 -0300 Subject: [PATCH 3/8] chore: update dependencies --- yarn.lock | 1 - 1 file changed, 1 deletion(-) diff --git a/yarn.lock b/yarn.lock index e39f4108e..241cd3c33 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1507,7 +1507,6 @@ __metadata: accesscontrol: "npm:^2.2.1" supertest: "npm:^6.3.4" peerDependencies: - "@concepta/nestjs-role": ^6.0.0-alpha.1 class-transformer: "*" class-validator: "*" typeorm: ^0.3.0 From aa1ed9ddb1ea80a8da10b62536f1442ebae54f67 Mon Sep 17 00:00:00 2001 From: Thiago Ramalho Date: Fri, 31 Jan 2025 15:33:00 -0300 Subject: [PATCH 4/8] chore: add user roles to dto --- .../domain/role/interfaces/role-assignments.interface.ts | 8 -------- packages/nestjs-user/src/dto/user-create.dto.ts | 2 ++ packages/nestjs-user/src/dto/user-update.dto.ts | 2 ++ 3 files changed, 4 insertions(+), 8 deletions(-) delete mode 100644 packages/nestjs-common/src/domain/role/interfaces/role-assignments.interface.ts diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-assignments.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-assignments.interface.ts deleted file mode 100644 index a082459cb..000000000 --- a/packages/nestjs-common/src/domain/role/interfaces/role-assignments.interface.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { RoleAssignmentInterface } from './role-assignment.interface'; - -export interface RoleAssignmentsInterface { - /** - * roles for the assignment - */ - assignmentRoles: RoleAssignmentInterface[]; -} diff --git a/packages/nestjs-user/src/dto/user-create.dto.ts b/packages/nestjs-user/src/dto/user-create.dto.ts index f73630878..ff49c0525 100644 --- a/packages/nestjs-user/src/dto/user-create.dto.ts +++ b/packages/nestjs-user/src/dto/user-create.dto.ts @@ -3,6 +3,7 @@ import { IntersectionType, PartialType, PickType } from '@nestjs/swagger'; import { UserCreatableInterface } from '@concepta/nestjs-common'; import { UserDto } from './user.dto'; import { UserPasswordDto } from './user-password.dto'; +import { UserRolesDto } from './user-roles.dto'; /** * User Create DTO @@ -13,5 +14,6 @@ export class UserCreateDto PickType(UserDto, ['username', 'email'] as const), PartialType(PickType(UserDto, ['active'] as const)), PartialType(UserPasswordDto), + PartialType(UserRolesDto), ) implements UserCreatableInterface {} diff --git a/packages/nestjs-user/src/dto/user-update.dto.ts b/packages/nestjs-user/src/dto/user-update.dto.ts index 1c11205fb..fe1a555e1 100644 --- a/packages/nestjs-user/src/dto/user-update.dto.ts +++ b/packages/nestjs-user/src/dto/user-update.dto.ts @@ -4,6 +4,7 @@ import { UserUpdatableInterface } from '@concepta/nestjs-common'; import { UserDto } from './user.dto'; import { UserPasswordDto } from './user-password.dto'; import { UserPasswordUpdateDto } from './user-password-update.dto'; +import { UserRolesDto } from './user-roles.dto'; /** * User Update DTO @@ -14,5 +15,6 @@ export class UserUpdateDto PartialType(PickType(UserDto, ['email', 'active'] as const)), PartialType(UserPasswordDto), PartialType(UserPasswordUpdateDto), + PartialType(UserRolesDto), ) implements UserUpdatableInterface {} From 1fba77eb0db9c725bd19b7be12ec55f471e905ca Mon Sep 17 00:00:00 2001 From: Thiago Ramalho Date: Wed, 5 Feb 2025 16:49:11 -0300 Subject: [PATCH 5/8] chore: make user roles service and update params --- packages/nestjs-common/src/domain/index.ts | 1 + ...rd-strength-transform-options.interface.ts | 3 + .../role/interfaces/role-ownable.interface.ts | 4 +- .../user/interfaces/user-roles.interface.ts | 2 +- ...assword-create-object-options.interface.ts | 4 + .../password-strength-options.interface.ts | 8 ++ .../password-strength-service.interface.ts | 8 +- .../src/services/password-creation.service.ts | 7 +- .../src/services/password-strength.service.ts | 8 +- .../src/config/user-default.config.ts | 4 +- .../nestjs-user/src/dto/user-roles.dto.ts | 6 +- .../src/entities/user-postgres.entity.ts | 2 +- .../src/entities/user-sqlite.entity.ts | 2 +- .../src/interfaces/user-entity.interface.ts | 2 +- .../interfaces/user-role-service.interface.ts | 42 ++++++++++ .../src/interfaces/user-settings.interface.ts | 4 +- .../src/services/user-mutate.service.ts | 2 +- .../src/services/user-password.service.ts | 84 +++++++------------ .../src/services/user-role.service.ts | 74 ++++++++++++++++ .../src/user.controller.pw.e2e-spec.ts | 35 ++++---- .../nestjs-user/src/user.module-definition.ts | 8 +- packages/nestjs-user/src/user.types.ts | 5 +- packages/nestjs-user/src/user.utils.ts | 8 +- 23 files changed, 220 insertions(+), 103 deletions(-) create mode 100644 packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts create mode 100644 packages/nestjs-password/src/interfaces/password-strength-options.interface.ts create mode 100644 packages/nestjs-user/src/interfaces/user-role-service.interface.ts create mode 100644 packages/nestjs-user/src/services/user-role.service.ts diff --git a/packages/nestjs-common/src/domain/index.ts b/packages/nestjs-common/src/domain/index.ts index 9d545141f..232f88eb1 100644 --- a/packages/nestjs-common/src/domain/index.ts +++ b/packages/nestjs-common/src/domain/index.ts @@ -12,6 +12,7 @@ export { AuthorizationPayloadInterface } from './authorization/interfaces/author export { PasswordPlainCurrentInterface } from './password/interfaces/password-plain-current.interface'; export { PasswordPlainInterface } from './password/interfaces/password-plain.interface'; +export { PasswordStrengthTransformOptionsInterface } from './password/interfaces/password-strength-transform-options.interface'; export { OrgCreatableInterface } from './org/interfaces/org-creatable.interface'; export { OrgMemberInterface } from './org/interfaces/org-member.interface'; diff --git a/packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts b/packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts new file mode 100644 index 000000000..c1d609dad --- /dev/null +++ b/packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts @@ -0,0 +1,3 @@ +export interface PasswordStrengthTransformOptionsInterface { + roles: string[]; +} diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts index 58a3ab38a..20d507e15 100644 --- a/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts +++ b/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts @@ -2,6 +2,6 @@ import { ReferenceId } from '../../../reference/interfaces/reference.types'; import { RoleInterface } from './role.interface'; export interface RoleOwnableInterface { - roleId: ReferenceId; - role: Partial; + roleId?: ReferenceId; + role?: Partial; } diff --git a/packages/nestjs-common/src/domain/user/interfaces/user-roles.interface.ts b/packages/nestjs-common/src/domain/user/interfaces/user-roles.interface.ts index c001e9499..88d549240 100644 --- a/packages/nestjs-common/src/domain/user/interfaces/user-roles.interface.ts +++ b/packages/nestjs-common/src/domain/user/interfaces/user-roles.interface.ts @@ -1,5 +1,5 @@ import { RoleOwnableInterface } from '../../role/interfaces/role-ownable.interface'; export interface UserRolesInterface { - userRoles: Partial>[]; + userRoles?: RoleOwnableInterface[]; } diff --git a/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts b/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts index 562bf2324..560304f22 100644 --- a/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts @@ -11,5 +11,9 @@ export interface PasswordCreateObjectOptionsInterface { */ required?: boolean; + /** + * Optional password strength requirement. If provided, will validate + * that password meets minimum strength requirements. + */ passwordStrength?: PasswordStrengthEnum | null; } diff --git a/packages/nestjs-password/src/interfaces/password-strength-options.interface.ts b/packages/nestjs-password/src/interfaces/password-strength-options.interface.ts new file mode 100644 index 000000000..84fd7288d --- /dev/null +++ b/packages/nestjs-password/src/interfaces/password-strength-options.interface.ts @@ -0,0 +1,8 @@ +import { PasswordStrengthEnum } from '../enum/password-strength.enum'; + +/** + * Password Strength Options Interface + */ +export interface PasswordStrengthOptionsInterface { + passwordStrength?: PasswordStrengthEnum | null; +} diff --git a/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts b/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts index 7655b928b..865df8d1c 100644 --- a/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-strength-service.interface.ts @@ -1,4 +1,4 @@ -import { PasswordStrengthEnum } from '../enum/password-strength.enum'; +import { PasswordStrengthOptionsInterface } from './password-strength-options.interface'; /** * Password Strength Service Interface @@ -8,6 +8,10 @@ export interface PasswordStrengthServiceInterface { * Check if Password is strong * * @param password - The plain text password + * @param options - The options */ - isStrong(password: string, passwordStrength?: PasswordStrengthEnum): boolean; + isStrong( + password: string, + options?: PasswordStrengthOptionsInterface, + ): boolean; } diff --git a/packages/nestjs-password/src/services/password-creation.service.ts b/packages/nestjs-password/src/services/password-creation.service.ts index 6de32b9db..eed69ed9f 100644 --- a/packages/nestjs-password/src/services/password-creation.service.ts +++ b/packages/nestjs-password/src/services/password-creation.service.ts @@ -81,10 +81,9 @@ export class PasswordCreationService if (typeof password === 'string') { // check strength if ( - !this.passwordStrengthService.isStrong( - password, - options?.passwordStrength, - ) + !this.passwordStrengthService.isStrong(password, { + passwordStrength: options?.passwordStrength, + }) ) { throw new PasswordNotStrongException(); } diff --git a/packages/nestjs-password/src/services/password-strength.service.ts b/packages/nestjs-password/src/services/password-strength.service.ts index 84ce9bdb7..a4e82c10b 100644 --- a/packages/nestjs-password/src/services/password-strength.service.ts +++ b/packages/nestjs-password/src/services/password-strength.service.ts @@ -6,6 +6,7 @@ import { PASSWORD_MODULE_SETTINGS_TOKEN } from '../password.constants'; import { PasswordStrengthEnum } from '../enum/password-strength.enum'; import { PasswordStrengthServiceInterface } from '../interfaces/password-strength-service.interface'; import { PasswordSettingsInterface } from '../interfaces/password-settings.interface'; +import { PasswordStrengthOptionsInterface } from '../interfaces/password-strength-options.interface'; /** * Service to validate password strength @@ -30,10 +31,11 @@ export class PasswordStrengthService */ isStrong( password: string, - passwordStrength?: PasswordStrengthEnum | null, + options?: PasswordStrengthOptionsInterface, ): boolean { - // Get min password Strength - // TODO: should it overwrite event is is lower then min password + const { passwordStrength } = options || {}; + + // TODO: Should we allow overriding the minimum password strength even if the provided strength is lower than the configured minimum? const minStrength = passwordStrength || this.settings?.minPasswordStrength || diff --git a/packages/nestjs-user/src/config/user-default.config.ts b/packages/nestjs-user/src/config/user-default.config.ts index 1d2ac4bfe..fd1363733 100644 --- a/packages/nestjs-user/src/config/user-default.config.ts +++ b/packages/nestjs-user/src/config/user-default.config.ts @@ -4,7 +4,7 @@ import { USER_MODULE_DEFAULT_SETTINGS_TOKEN, USER_MODULE_USER_PASSWORD_HISTORY_LIMIT_DAYS_DEFAULT, } from '../user.constants'; -import { defaultPasswordStrengthByRole } from '../user.utils'; +import { defaultPasswordStrengthTransform } from '../user.utils'; /** * Default configuration for User module. @@ -26,7 +26,7 @@ export const userDefaultConfig = registerAs( limitDays: isNaN(limitDays) || limitDays < 1 ? undefined : limitDays, }, passwordStrength: { - passwordStrengthCallback: defaultPasswordStrengthByRole, + passwordStrengthTransform: defaultPasswordStrengthTransform, }, }; }, diff --git a/packages/nestjs-user/src/dto/user-roles.dto.ts b/packages/nestjs-user/src/dto/user-roles.dto.ts index df910aa0f..180c4583f 100644 --- a/packages/nestjs-user/src/dto/user-roles.dto.ts +++ b/packages/nestjs-user/src/dto/user-roles.dto.ts @@ -2,16 +2,16 @@ import { RoleOwnableInterface, UserRolesInterface, } from '@concepta/nestjs-common'; -import { ApiProperty } from '@nestjs/swagger'; +import { ApiPropertyOptional } from '@nestjs/swagger'; import { Exclude, Expose } from 'class-transformer'; @Exclude() export class UserRolesDto implements UserRolesInterface { @Expose() - @ApiProperty({ + @ApiPropertyOptional({ type: 'array', isArray: true, description: 'User roles', }) - userRoles!: Partial[]; + userRoles?: RoleOwnableInterface[]; } diff --git a/packages/nestjs-user/src/entities/user-postgres.entity.ts b/packages/nestjs-user/src/entities/user-postgres.entity.ts index e78b0acd9..d44aaf333 100644 --- a/packages/nestjs-user/src/entities/user-postgres.entity.ts +++ b/packages/nestjs-user/src/entities/user-postgres.entity.ts @@ -41,7 +41,7 @@ export abstract class UserPostgresEntity @Column({ type: 'text', nullable: true, default: null }) passwordSalt: string | null = null; - userRoles?: Partial[]; + userRoles?: RoleOwnableInterface[]; userPasswordHistory?: UserPasswordHistoryEntityInterface; } diff --git a/packages/nestjs-user/src/entities/user-sqlite.entity.ts b/packages/nestjs-user/src/entities/user-sqlite.entity.ts index 8659de58d..0ad015dbd 100644 --- a/packages/nestjs-user/src/entities/user-sqlite.entity.ts +++ b/packages/nestjs-user/src/entities/user-sqlite.entity.ts @@ -38,7 +38,7 @@ export abstract class UserSqliteEntity @Column({ type: 'text', nullable: true, default: null }) passwordSalt: string | null = null; - userRoles?: Partial[]; + userRoles?: RoleOwnableInterface[]; userPasswordHistory?: UserPasswordHistoryEntityInterface; } diff --git a/packages/nestjs-user/src/interfaces/user-entity.interface.ts b/packages/nestjs-user/src/interfaces/user-entity.interface.ts index 248f085cb..86dc8c522 100644 --- a/packages/nestjs-user/src/interfaces/user-entity.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-entity.interface.ts @@ -4,4 +4,4 @@ import { PasswordStorageInterface } from '@concepta/nestjs-password'; export interface UserEntityInterface extends UserInterface, PasswordStorageInterface, - Partial {} + UserRolesInterface {} diff --git a/packages/nestjs-user/src/interfaces/user-role-service.interface.ts b/packages/nestjs-user/src/interfaces/user-role-service.interface.ts new file mode 100644 index 000000000..a6abacabd --- /dev/null +++ b/packages/nestjs-user/src/interfaces/user-role-service.interface.ts @@ -0,0 +1,42 @@ +import { + ReferenceId, + RoleOwnableInterface, + UserRolesInterface, +} from '@concepta/nestjs-common'; +import { PasswordStrengthEnum } from '@concepta/nestjs-password'; + +export interface UserRoleServiceInterface { + /** + * Get user roles from either the user DTO or by looking up the user. + * + * @param userDto - User DTO that may contain user roles + * @param userToUpdateId - Optional ID of user to lookup roles for + * @returns Array of role names, or empty array if no roles found + */ + getUserRoles( + userDto: UserRolesInterface, + userToUpdateId?: ReferenceId, + ): Promise; + + /** + * Normalize role names by filtering out invalid roles and removing duplicates. + * + * @param userRoles - Array of user role objects that may contain role names + * @returns Array of unique, valid role names + */ + normalizeRoleNames( + userRoles: Partial>[], + ): string[]; + + /** + * Get password strength based on user roles. + * Uses the configured passwordStrengthTransform callback if available. + * + * @param roles - Array of role names to check against + * @returns Password strength enum value if callback exists and roles are provided, + * undefined otherwise + */ + resolvePasswordStrength( + roles?: string[], + ): PasswordStrengthEnum | null | undefined; +} diff --git a/packages/nestjs-user/src/interfaces/user-settings.interface.ts b/packages/nestjs-user/src/interfaces/user-settings.interface.ts index e9f7a7485..9a3311c26 100644 --- a/packages/nestjs-user/src/interfaces/user-settings.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-settings.interface.ts @@ -7,7 +7,7 @@ import { InvitationGetUserEventPayloadInterface, InvitationGetUserEventResponseInterface, } from '@concepta/nestjs-common'; -import { PasswordStrengthByRoleCallback } from '../user.types'; +import { PasswordStrengthTransform } from '../user.types'; export interface UserSettingsInterface { invitationRequestEvent?: EventClassInterface< @@ -31,6 +31,6 @@ export interface UserSettingsInterface { }; passwordStrength?: { - passwordStrengthCallback?: PasswordStrengthByRoleCallback; + passwordStrengthTransform?: PasswordStrengthTransform; }; } diff --git a/packages/nestjs-user/src/services/user-mutate.service.ts b/packages/nestjs-user/src/services/user-mutate.service.ts index c859911a4..e0cf19508 100644 --- a/packages/nestjs-user/src/services/user-mutate.service.ts +++ b/packages/nestjs-user/src/services/user-mutate.service.ts @@ -46,7 +46,7 @@ export class UserMutateService } protected async transform>( - user: T | (T & PasswordPlainInterface & Partial), + user: T | (T & PasswordPlainInterface & UserRolesInterface), ): Promise> { // do we need to hash the password? if ('password' in user && typeof user.password === 'string') { diff --git a/packages/nestjs-user/src/services/user-password.service.ts b/packages/nestjs-user/src/services/user-password.service.ts index a4b5839da..434c203b2 100644 --- a/packages/nestjs-user/src/services/user-password.service.ts +++ b/packages/nestjs-user/src/services/user-password.service.ts @@ -2,7 +2,6 @@ import { HttpStatus, Inject, Injectable, Optional } from '@nestjs/common'; import { ReferenceId, ReferenceIdInterface, - RoleOwnableInterface, UserRolesInterface, } from '@concepta/nestjs-common'; import { @@ -25,8 +24,8 @@ import { UserLookupService } from './user-lookup.service'; import { UserPasswordHistoryService } from './user-password-history.service'; import { UserException } from '../exceptions/user-exception'; import { UserNotFoundException } from '../exceptions/user-not-found-exception'; -import { USER_MODULE_SETTINGS_TOKEN } from '../user.constants'; -import { UserSettingsInterface } from '../interfaces/user-settings.interface'; +import { UserRoleService } from './user-role.service'; +import { UserRoleServiceInterface } from '../interfaces/user-role-service.interface'; /** * User password service @@ -44,8 +43,9 @@ export class UserPasswordService implements UserPasswordServiceInterface { protected readonly userLookupService: UserLookupServiceInterface, @Inject(PasswordCreationService) protected readonly passwordCreationService: PasswordCreationServiceInterface, - @Inject(USER_MODULE_SETTINGS_TOKEN) - protected readonly userSettings: UserSettingsInterface, + @Optional() + @Inject(UserRoleService) + private userRoleService?: UserRoleServiceInterface, @Optional() @Inject(UserPasswordHistoryService) private userPasswordHistoryService?: UserPasswordHistoryServiceInterface, @@ -89,8 +89,10 @@ export class UserPasswordService implements UserPasswordServiceInterface { // create safe object const targetSafe = { ...passwordDto, password }; - const roles = await this.getUserRoles(passwordDto, userToUpdateId); - const passwordStrength = await this.getPasswordStrength(roles); + const passwordStrength = await this.getPasswordStrength( + passwordDto, + userToUpdateId, + ); const userWithPasswordHashed = await this.passwordCreationService.createObject(targetSafe, { @@ -117,63 +119,33 @@ export class UserPasswordService implements UserPasswordServiceInterface { // return the object untouched return passwordDto; } - // TODO: add function to call the callback and this can ber overwrite /** - * Get user roles from either the password DTO or by looking up the user. + * Get the password strength based on user roles * - * @param passwordDto - Password DTO that may contain user roles - * @param userToUpdateId - Optional ID of user to lookup roles for - * @returns Array of role names, or empty array if no roles found + * @param userDto - The user object containing roles + * @param userToUpdateId - Optional ID of user being updated + * @returns The resolved password strength enum value, or null/undefined if no roles service */ - protected async getUserRoles( - passwordDto: Partial, + protected async getPasswordStrength( + userDto: UserRolesInterface, userToUpdateId?: ReferenceId, - ): Promise { - // get roles based on user id - if (userToUpdateId) { - const user: - | (ReferenceIdInterface & Partial) - | null = await this.userLookupService.byId(userToUpdateId); - if (user && user.userRoles) return this.mapRoles(user.userRoles); - } - - // get roles from payload + ): Promise { + let passwordStrength; if ( - passwordDto.userRoles && - passwordDto.userRoles?.some((userRole) => userRole.role?.name) + this.userRoleService && + this.userRoleService.getUserRoles && + this.userRoleService.resolvePasswordStrength ) { - return this.mapRoles(passwordDto.userRoles); + const roles = await this.userRoleService.getUserRoles( + userDto, + userToUpdateId, + ); + passwordStrength = await this.userRoleService.resolvePasswordStrength( + roles, + ); } - - return []; - } - - mapRoles(userRoles: Partial>[]) { - return [ - ...new Set( - userRoles - .filter((userRole) => userRole.role?.name) - .map((userRole) => userRole.role?.name ?? ''), - ), - ]; - } - /** - * Get password strength based on user roles. if callback is not enough - * user can always be able to overwrite this method to take advantage of injections - * - * @param roles - Array of role names to check against - * @returns Password strength enum value if callback exists and roles are provided, - * undefined otherwise - */ - protected getPasswordStrength( - roles?: string[], - ): PasswordStrengthEnum | null | undefined { - return ( - roles && - this.userSettings.passwordStrength?.passwordStrengthCallback && - this.userSettings.passwordStrength?.passwordStrengthCallback(roles || []) - ); + return passwordStrength; } async getPasswordStore( userId: ReferenceId, diff --git a/packages/nestjs-user/src/services/user-role.service.ts b/packages/nestjs-user/src/services/user-role.service.ts new file mode 100644 index 000000000..ce01fab3b --- /dev/null +++ b/packages/nestjs-user/src/services/user-role.service.ts @@ -0,0 +1,74 @@ +import { + ReferenceId, + ReferenceIdInterface, + RoleOwnableInterface, + UserRolesInterface, +} from '@concepta/nestjs-common'; +import { PasswordStrengthEnum } from '@concepta/nestjs-password'; +import { Inject, Injectable } from '@nestjs/common'; + +import { UserLookupServiceInterface } from '../interfaces/user-lookup-service.interface'; +import { UserSettingsInterface } from '../interfaces/user-settings.interface'; +import { USER_MODULE_SETTINGS_TOKEN } from '../user.constants'; +import { UserLookupService } from './user-lookup.service'; +import { UserRoleServiceInterface } from '../interfaces/user-role-service.interface'; + +@Injectable() +export class UserRoleService implements UserRoleServiceInterface { + constructor( + @Inject(USER_MODULE_SETTINGS_TOKEN) + protected readonly userSettings: UserSettingsInterface, + @Inject(UserLookupService) + protected readonly userLookupService: UserLookupServiceInterface, + ) {} + + async getUserRoles( + userDto: UserRolesInterface, + userToUpdateId?: ReferenceId, + ): Promise { + // get roles based on user id + if (userToUpdateId) { + const user: (ReferenceIdInterface & UserRolesInterface) | null = + await this.userLookupService.byId(userToUpdateId); + if (user && user.userRoles) + return this.normalizeRoleNames(user.userRoles); + } + + // get roles from payload + if ( + userDto.userRoles && + userDto.userRoles?.some((userRole) => userRole.role?.name) + ) { + return this.normalizeRoleNames(userDto.userRoles); + } + + return []; + } + + normalizeRoleNames(userRoles: Partial>[]) { + return Array.from( + new Set( + userRoles + .filter((userRole) => userRole.role?.name) + .map((userRole) => userRole.role?.name ?? ''), + ), + ); + } + /** + * Get password strength based on user roles. if callback is not enough + * user can always be able to overwrite this method to take advantage of injections + * + * @param roles - Array of role names to check against + * @returns Password strength enum value if callback exists and roles are provided, + * undefined otherwise + */ + resolvePasswordStrength( + roles?: string[], + ): PasswordStrengthEnum | null | undefined { + return ( + roles && + this.userSettings.passwordStrength?.passwordStrengthTransform && + this.userSettings.passwordStrength?.passwordStrengthTransform({ roles }) + ); + } +} diff --git a/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts b/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts index d7618faa0..1aed7fa57 100644 --- a/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts +++ b/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts @@ -25,6 +25,7 @@ import { UserPasswordHistoryLookupService } from './services/user-password-histo import { AppModuleFixture } from './__fixtures__/app.module.fixture'; import { UserEntityFixture } from './__fixtures__/user.entity.fixture'; import { UserPasswordHistoryEntityFixture } from './__fixtures__/user-password-history.entity.fixture'; +import { UserRoleService } from './services/user-role.service'; describe('User Controller (password e2e)', () => { describe('Password Update Flow', () => { @@ -37,6 +38,7 @@ describe('User Controller (password e2e)', () => { let passwordCreationService: PasswordCreationService; let userLookupService: UserLookupService; let userPasswordService: UserPasswordService; + let userRoleService: UserRoleService; let userPasswordHistoryLookupService: UserPasswordHistoryLookupService; let issueTokenService: IssueTokenService; let accessControlService: AccessControlService; @@ -75,6 +77,7 @@ describe('User Controller (password e2e)', () => { passwordCreationService = app.get(PasswordCreationService); userLookupService = app.get(UserLookupService); userPasswordService = app.get(UserPasswordService); + userRoleService = app.get(UserRoleService); userPasswordHistoryLookupService = app.get( UserPasswordHistoryLookupService, ); @@ -278,10 +281,10 @@ describe('User Controller (password e2e)', () => { it('Should not update weak password for admin', async () => { passwordCreationService['settings'].requireCurrentToUpdate = false; - userPasswordService['userSettings'].passwordStrength = { - passwordStrengthCallback: ( - roles: string[], - ): PasswordStrengthEnum | null => { + userRoleService['userSettings'].passwordStrength = { + passwordStrengthTransform: ({ + roles, + }): PasswordStrengthEnum | null => { if (roles.includes('admin')) { return PasswordStrengthEnum.VeryStrong; } @@ -315,10 +318,10 @@ describe('User Controller (password e2e)', () => { it('Should not update weak password for admin', async () => { passwordCreationService['settings'].requireCurrentToUpdate = false; - userPasswordService['userSettings'].passwordStrength = { - passwordStrengthCallback: ( - roles: string[], - ): PasswordStrengthEnum | null => { + userRoleService['userSettings'].passwordStrength = { + passwordStrengthTransform: ({ + roles, + }): PasswordStrengthEnum | null => { if (roles.includes('admin')) { return PasswordStrengthEnum.VeryStrong; } @@ -360,10 +363,10 @@ describe('User Controller (password e2e)', () => { it('Should update with weak password for admin', async () => { passwordCreationService['settings'].requireCurrentToUpdate = false; - userPasswordService['userSettings'].passwordStrength = { - passwordStrengthCallback: ( - roles: string[], - ): PasswordStrengthEnum | null => { + userRoleService['userSettings'].passwordStrength = { + passwordStrengthTransform: ({ + roles, + }): PasswordStrengthEnum | null => { if (roles.includes('user')) { return PasswordStrengthEnum.None; } @@ -393,10 +396,10 @@ describe('User Controller (password e2e)', () => { it('Should update password for admin and user ', async () => { passwordCreationService['settings'].requireCurrentToUpdate = false; - userPasswordService['userSettings'].passwordStrength = { - passwordStrengthCallback: ( - roles: string[], - ): PasswordStrengthEnum | null => { + userRoleService['userSettings'].passwordStrength = { + passwordStrengthTransform: ({ + roles, + }): PasswordStrengthEnum | null => { if (roles.includes('admin')) { return PasswordStrengthEnum.VeryStrong; } diff --git a/packages/nestjs-user/src/user.module-definition.ts b/packages/nestjs-user/src/user.module-definition.ts index d1bf81dfd..94cbea564 100644 --- a/packages/nestjs-user/src/user.module-definition.ts +++ b/packages/nestjs-user/src/user.module-definition.ts @@ -38,6 +38,7 @@ import { UserController } from './user.controller'; import { InvitationAcceptedListener } from './listeners/invitation-accepted-listener'; import { InvitationGetUserListener } from './listeners/invitation-get-user.listener'; import { userDefaultConfig } from './config/user-default.config'; +import { UserRoleService } from './services/user-role.service'; const RAW_OPTIONS_TOKEN = Symbol('__USER_MODULE_RAW_OPTIONS_TOKEN__'); @@ -97,6 +98,7 @@ export function createUserProviders(options: { InvitationAcceptedListener, InvitationGetUserListener, UserPasswordHistoryMutateService, + UserRoleService, createUserSettingsProvider(options.overrides), createUserLookupServiceProvider(options.overrides), createUserMutateServiceProvider(options.overrides), @@ -192,7 +194,7 @@ export function createUserPasswordServiceProvider( RAW_OPTIONS_TOKEN, UserLookupService, PasswordCreationService, - USER_MODULE_SETTINGS_TOKEN, + UserRoleService, { token: UserPasswordHistoryService, optional: true, @@ -202,7 +204,7 @@ export function createUserPasswordServiceProvider( options: UserOptionsInterface, userLookUpService: UserLookupServiceInterface, passwordCreationService: PasswordCreationService, - userSettings: UserSettingsInterface, + userRoleService: UserRoleService, userPasswordHistoryService?: UserPasswordHistoryService, ) => optionsOverrides?.userPasswordService ?? @@ -210,7 +212,7 @@ export function createUserPasswordServiceProvider( new UserPasswordService( userLookUpService, passwordCreationService, - userSettings, + userRoleService, userPasswordHistoryService, ), }; diff --git a/packages/nestjs-user/src/user.types.ts b/packages/nestjs-user/src/user.types.ts index a98cfae60..4c91689b2 100644 --- a/packages/nestjs-user/src/user.types.ts +++ b/packages/nestjs-user/src/user.types.ts @@ -1,3 +1,4 @@ +import { PasswordStrengthTransformOptionsInterface } from '@concepta/nestjs-common'; import { PasswordStrengthEnum } from '@concepta/nestjs-password'; export enum UserResource { @@ -5,6 +6,6 @@ export enum UserResource { 'Many' = 'user-list', } -export type PasswordStrengthByRoleCallback = ( - userRoles: string[], +export type PasswordStrengthTransform = ( + options: PasswordStrengthTransformOptionsInterface, ) => PasswordStrengthEnum | null; diff --git a/packages/nestjs-user/src/user.utils.ts b/packages/nestjs-user/src/user.utils.ts index 1f5de5ddf..6eb1cc2fc 100644 --- a/packages/nestjs-user/src/user.utils.ts +++ b/packages/nestjs-user/src/user.utils.ts @@ -1,9 +1,11 @@ import { PasswordStrengthEnum } from '@concepta/nestjs-password'; -import { PasswordStrengthByRoleCallback } from './user.types'; +import { PasswordStrengthTransform } from './user.types'; +import { PasswordStrengthTransformOptionsInterface } from '@concepta/nestjs-common'; -export const defaultPasswordStrengthByRole: PasswordStrengthByRoleCallback = ( - roles: string[], +export const defaultPasswordStrengthTransform: PasswordStrengthTransform = ( + options: PasswordStrengthTransformOptionsInterface, ): PasswordStrengthEnum | null => { + const { roles } = options; // Default implementation - require medium strength for all roles if (roles.includes('admin')) { return PasswordStrengthEnum.VeryStrong; From 4563d41c9bf6fd648f618d854d4eee949f4c326b Mon Sep 17 00:00:00 2001 From: Thiago Ramalho Date: Wed, 5 Feb 2025 16:50:28 -0300 Subject: [PATCH 6/8] chore: add comments --- packages/nestjs-user/src/services/user-role.service.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/nestjs-user/src/services/user-role.service.ts b/packages/nestjs-user/src/services/user-role.service.ts index ce01fab3b..250aaf103 100644 --- a/packages/nestjs-user/src/services/user-role.service.ts +++ b/packages/nestjs-user/src/services/user-role.service.ts @@ -35,6 +35,7 @@ export class UserRoleService implements UserRoleServiceInterface { } // get roles from payload + // TODO: review this, maybe do a logic to get by role Id as well ? if ( userDto.userRoles && userDto.userRoles?.some((userRole) => userRole.role?.name) From cddb9e963809fbdb712a23c0fd1cba0ea666472e Mon Sep 17 00:00:00 2001 From: Thiago Ramalho Date: Thu, 6 Feb 2025 14:56:07 -0300 Subject: [PATCH 7/8] chore: update user role service --- ...rd-strength-transform-options.interface.ts | 4 ++- .../src/interfaces/user-options.interface.ts | 2 ++ .../interfaces/user-role-service.interface.ts | 16 +++--------- .../src/services/user-role.service.ts | 25 ++++--------------- .../src/user.controller.pw.e2e-spec.ts | 12 ++++----- .../nestjs-user/src/user.module-definition.ts | 20 ++++++++++++++- packages/nestjs-user/src/user.utils.ts | 6 ++--- 7 files changed, 41 insertions(+), 44 deletions(-) diff --git a/packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts b/packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts index c1d609dad..19e54c3b6 100644 --- a/packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts +++ b/packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts @@ -1,3 +1,5 @@ +import { RoleOwnableInterface } from '../../role/interfaces/role-ownable.interface'; + export interface PasswordStrengthTransformOptionsInterface { - roles: string[]; + roles: RoleOwnableInterface[]; } diff --git a/packages/nestjs-user/src/interfaces/user-options.interface.ts b/packages/nestjs-user/src/interfaces/user-options.interface.ts index 83593cc17..4f4087014 100644 --- a/packages/nestjs-user/src/interfaces/user-options.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-options.interface.ts @@ -4,12 +4,14 @@ import { UserLookupServiceInterface } from './user-lookup-service.interface'; import { UserMutateServiceInterface } from './user-mutate-service.interface'; import { UserPasswordServiceInterface } from './user-password-service.interface'; import { UserPasswordHistoryServiceInterface } from './user-password-history-service.interface'; +import { UserRoleServiceInterface } from './user-role-service.interface'; export interface UserOptionsInterface { settings?: UserSettingsInterface; userLookupService?: UserLookupServiceInterface; userMutateService?: UserMutateServiceInterface; userPasswordService?: UserPasswordServiceInterface; + userRoleService?: UserRoleServiceInterface; userPasswordHistoryService?: UserPasswordHistoryServiceInterface; userAccessQueryService?: CanAccess; } diff --git a/packages/nestjs-user/src/interfaces/user-role-service.interface.ts b/packages/nestjs-user/src/interfaces/user-role-service.interface.ts index a6abacabd..50efea933 100644 --- a/packages/nestjs-user/src/interfaces/user-role-service.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-role-service.interface.ts @@ -16,27 +16,17 @@ export interface UserRoleServiceInterface { getUserRoles( userDto: UserRolesInterface, userToUpdateId?: ReferenceId, - ): Promise; - - /** - * Normalize role names by filtering out invalid roles and removing duplicates. - * - * @param userRoles - Array of user role objects that may contain role names - * @returns Array of unique, valid role names - */ - normalizeRoleNames( - userRoles: Partial>[], - ): string[]; + ): Promise; /** * Get password strength based on user roles. * Uses the configured passwordStrengthTransform callback if available. * - * @param roles - Array of role names to check against + * @param roles - Array of roles to check against * @returns Password strength enum value if callback exists and roles are provided, * undefined otherwise */ resolvePasswordStrength( - roles?: string[], + roles?: RoleOwnableInterface[], ): PasswordStrengthEnum | null | undefined; } diff --git a/packages/nestjs-user/src/services/user-role.service.ts b/packages/nestjs-user/src/services/user-role.service.ts index 250aaf103..d0d4c886d 100644 --- a/packages/nestjs-user/src/services/user-role.service.ts +++ b/packages/nestjs-user/src/services/user-role.service.ts @@ -25,36 +25,21 @@ export class UserRoleService implements UserRoleServiceInterface { async getUserRoles( userDto: UserRolesInterface, userToUpdateId?: ReferenceId, - ): Promise { + ): Promise { // get roles based on user id if (userToUpdateId) { const user: (ReferenceIdInterface & UserRolesInterface) | null = await this.userLookupService.byId(userToUpdateId); - if (user && user.userRoles) - return this.normalizeRoleNames(user.userRoles); + if (user && user.userRoles) return user.userRoles; } // get roles from payload - // TODO: review this, maybe do a logic to get by role Id as well ? - if ( - userDto.userRoles && - userDto.userRoles?.some((userRole) => userRole.role?.name) - ) { - return this.normalizeRoleNames(userDto.userRoles); - } + if (userDto.userRoles && userDto.userRoles.length > 0) + return userDto.userRoles; return []; } - normalizeRoleNames(userRoles: Partial>[]) { - return Array.from( - new Set( - userRoles - .filter((userRole) => userRole.role?.name) - .map((userRole) => userRole.role?.name ?? ''), - ), - ); - } /** * Get password strength based on user roles. if callback is not enough * user can always be able to overwrite this method to take advantage of injections @@ -64,7 +49,7 @@ export class UserRoleService implements UserRoleServiceInterface { * undefined otherwise */ resolvePasswordStrength( - roles?: string[], + roles?: RoleOwnableInterface[], ): PasswordStrengthEnum | null | undefined { return ( roles && diff --git a/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts b/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts index 1aed7fa57..5da9028ab 100644 --- a/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts +++ b/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts @@ -285,7 +285,7 @@ describe('User Controller (password e2e)', () => { passwordStrengthTransform: ({ roles, }): PasswordStrengthEnum | null => { - if (roles.includes('admin')) { + if (roles.some((role) => role.role?.name === 'admin')) { return PasswordStrengthEnum.VeryStrong; } return null; @@ -322,10 +322,10 @@ describe('User Controller (password e2e)', () => { passwordStrengthTransform: ({ roles, }): PasswordStrengthEnum | null => { - if (roles.includes('admin')) { + if (roles.some((role) => role.role?.name === 'admin')) { return PasswordStrengthEnum.VeryStrong; } - if (roles.includes('user')) { + if (roles.some((role) => role.role?.name === 'user')) { return PasswordStrengthEnum.None; } return null; @@ -367,7 +367,7 @@ describe('User Controller (password e2e)', () => { passwordStrengthTransform: ({ roles, }): PasswordStrengthEnum | null => { - if (roles.includes('user')) { + if (roles.some((role) => role.role?.name === 'user')) { return PasswordStrengthEnum.None; } return null; @@ -400,10 +400,10 @@ describe('User Controller (password e2e)', () => { passwordStrengthTransform: ({ roles, }): PasswordStrengthEnum | null => { - if (roles.includes('admin')) { + if (roles.some((role) => role.role?.name === 'admin')) { return PasswordStrengthEnum.VeryStrong; } - if (roles.includes('user')) { + if (roles.some((role) => role.role?.name === 'user')) { return PasswordStrengthEnum.None; } return null; diff --git a/packages/nestjs-user/src/user.module-definition.ts b/packages/nestjs-user/src/user.module-definition.ts index 94cbea564..f8d9e8437 100644 --- a/packages/nestjs-user/src/user.module-definition.ts +++ b/packages/nestjs-user/src/user.module-definition.ts @@ -98,11 +98,11 @@ export function createUserProviders(options: { InvitationAcceptedListener, InvitationGetUserListener, UserPasswordHistoryMutateService, - UserRoleService, createUserSettingsProvider(options.overrides), createUserLookupServiceProvider(options.overrides), createUserMutateServiceProvider(options.overrides), createUserPasswordServiceProvider(options.overrides), + createUserRoleServiceProvider(options.overrides), createUserPasswordHistoryServiceProvider(options.overrides), createUserPasswordHistoryLookupServiceProvider(), createUserPasswordHistoryMutateServiceProvider(), @@ -119,6 +119,7 @@ export function createUserExports(): Required< UserMutateService, UserCrudService, UserPasswordService, + UserRoleService, UserPasswordHistoryService, UserPasswordHistoryLookupService, UserPasswordHistoryMutateService, @@ -218,6 +219,23 @@ export function createUserPasswordServiceProvider( }; } +export function createUserRoleServiceProvider( + optionsOverrides?: UserOptions, +): Provider { + return { + provide: UserRoleService, + inject: [RAW_OPTIONS_TOKEN, USER_MODULE_SETTINGS_TOKEN, UserLookupService], + useFactory: async ( + options: UserOptionsInterface, + settings: UserSettingsInterface, + userLookUpService: UserLookupServiceInterface, + ) => + optionsOverrides?.userRoleService ?? + options.userRoleService ?? + new UserRoleService(settings, userLookUpService), + }; +} + export function createUserPasswordHistoryLookupServiceProvider(): Provider { return { provide: UserPasswordHistoryLookupService, diff --git a/packages/nestjs-user/src/user.utils.ts b/packages/nestjs-user/src/user.utils.ts index 6eb1cc2fc..fc5b3de3b 100644 --- a/packages/nestjs-user/src/user.utils.ts +++ b/packages/nestjs-user/src/user.utils.ts @@ -6,11 +6,11 @@ export const defaultPasswordStrengthTransform: PasswordStrengthTransform = ( options: PasswordStrengthTransformOptionsInterface, ): PasswordStrengthEnum | null => { const { roles } = options; - // Default implementation - require medium strength for all roles - if (roles.includes('admin')) { + + if (roles.some((role) => role.role?.name === 'admin')) { return PasswordStrengthEnum.VeryStrong; } - if (roles.includes('user')) { + if (roles.some((role) => role.role?.name === 'user')) { return PasswordStrengthEnum.Strong; } From b671c37573f026126b3c9a8c03c13c24fb064934 Mon Sep 17 00:00:00 2001 From: Thiago Ramalho Date: Tue, 11 Feb 2025 18:41:15 -0300 Subject: [PATCH 8/8] chore: mr changes --- ...rd-strength-transform-options.interface.ts | 2 +- .../role/interfaces/role-ownable.interface.ts | 2 +- ...assword-create-object-options.interface.ts | 2 +- .../password-strength-options.interface.ts | 2 +- .../src/services/password-creation.service.ts | 40 +++++--- .../src/services/password-strength.service.ts | 18 ++-- .../user-role-password-exception.ts | 15 +++ .../src/exceptions/user-roles-exception.ts | 16 ++++ packages/nestjs-user/src/index.ts | 1 + .../interfaces/user-role-service.interface.ts | 28 +++--- .../src/services/user-password.service.ts | 46 ++-------- .../src/services/user-role.service.ts | 68 ++++++++++---- .../src/user.controller.pw.e2e-spec.ts | 92 ++++++++++++------- .../nestjs-user/src/user.module-definition.ts | 2 +- packages/nestjs-user/src/user.types.ts | 4 +- packages/nestjs-user/src/user.utils.ts | 31 +++++-- 16 files changed, 230 insertions(+), 139 deletions(-) create mode 100644 packages/nestjs-user/src/exceptions/user-role-password-exception.ts create mode 100644 packages/nestjs-user/src/exceptions/user-roles-exception.ts diff --git a/packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts b/packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts index 19e54c3b6..05ffb4532 100644 --- a/packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts +++ b/packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts @@ -1,5 +1,5 @@ import { RoleOwnableInterface } from '../../role/interfaces/role-ownable.interface'; export interface PasswordStrengthTransformOptionsInterface { - roles: RoleOwnableInterface[]; + roles?: RoleOwnableInterface[]; } diff --git a/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts b/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts index 20d507e15..c3a9a96ae 100644 --- a/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts +++ b/packages/nestjs-common/src/domain/role/interfaces/role-ownable.interface.ts @@ -2,6 +2,6 @@ import { ReferenceId } from '../../../reference/interfaces/reference.types'; import { RoleInterface } from './role.interface'; export interface RoleOwnableInterface { - roleId?: ReferenceId; + roleId: ReferenceId; role?: Partial; } diff --git a/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts b/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts index 560304f22..fcf26c299 100644 --- a/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-create-object-options.interface.ts @@ -15,5 +15,5 @@ export interface PasswordCreateObjectOptionsInterface { * Optional password strength requirement. If provided, will validate * that password meets minimum strength requirements. */ - passwordStrength?: PasswordStrengthEnum | null; + passwordStrength?: PasswordStrengthEnum | undefined; } diff --git a/packages/nestjs-password/src/interfaces/password-strength-options.interface.ts b/packages/nestjs-password/src/interfaces/password-strength-options.interface.ts index 84fd7288d..592c53ebe 100644 --- a/packages/nestjs-password/src/interfaces/password-strength-options.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-strength-options.interface.ts @@ -4,5 +4,5 @@ import { PasswordStrengthEnum } from '../enum/password-strength.enum'; * Password Strength Options Interface */ export interface PasswordStrengthOptionsInterface { - passwordStrength?: PasswordStrengthEnum | null; + passwordStrength?: PasswordStrengthEnum | undefined; } diff --git a/packages/nestjs-password/src/services/password-creation.service.ts b/packages/nestjs-password/src/services/password-creation.service.ts index eed69ed9f..d21a9642e 100644 --- a/packages/nestjs-password/src/services/password-creation.service.ts +++ b/packages/nestjs-password/src/services/password-creation.service.ts @@ -14,6 +14,7 @@ import { PasswordHistoryPasswordInterface } from '../interfaces/password-history import { PasswordNotStrongException } from '../exceptions/password-not-strong.exception'; import { PasswordCurrentRequiredException } from '../exceptions/password-current-required.exception'; import { PasswordUsedRecentlyException } from '../exceptions/password-used-recently.exception'; +import { PasswordException } from '../exceptions/password.exception'; /** * Service with functions related to password creation @@ -74,23 +75,32 @@ export class PasswordCreationService ): Promise< Omit | (Omit & PasswordStorageInterface) > { - // extract properties - const { password } = object; - - // is the password in the object? - if (typeof password === 'string') { - // check strength - if ( - !this.passwordStrengthService.isStrong(password, { - passwordStrength: options?.passwordStrength, - }) - ) { - throw new PasswordNotStrongException(); + try { + // extract properties + const { password } = object; + + // is the password in the object? + if (typeof password === 'string') { + // check strength + if ( + !this.passwordStrengthService.isStrong(password, { + passwordStrength: options?.passwordStrength, + }) + ) { + throw new PasswordNotStrongException(); + } } - } - // finally hash it - return this.passwordStorageService.hashObject(object, options); + return this.passwordStorageService.hashObject(object, options); + } catch (err) { + if (err instanceof PasswordNotStrongException) { + throw err; + } + throw new PasswordException({ + message: 'Failed to create password', + originalError: err, + }); + } } public async validateCurrent( diff --git a/packages/nestjs-password/src/services/password-strength.service.ts b/packages/nestjs-password/src/services/password-strength.service.ts index a4e82c10b..f37fe0b66 100644 --- a/packages/nestjs-password/src/services/password-strength.service.ts +++ b/packages/nestjs-password/src/services/password-strength.service.ts @@ -24,10 +24,17 @@ export class PasswordStrengthService ) {} /** - * Method to check if password is strong + * Check if a password meets the minimum strength requirements. + * Uses zxcvbn to score password strength from 0-4: * - * @param password - the plain text password - * @returns password strength + * The minimum required strength can be specified via: + * 1. The options.passwordStrength parameter - If defined it will be used as the minimum required strength + * 2. The module settings minPasswordStrength - Global minimum strength setting + * 3. Defaults to PasswordStrengthEnum.None (0) - If no other strength requirements specified + * + * @param password - The password to check + * @param options - Optional strength validation options + * @returns True if password meets minimum strength, false otherwise */ isStrong( password: string, @@ -35,10 +42,9 @@ export class PasswordStrengthService ): boolean { const { passwordStrength } = options || {}; - // TODO: Should we allow overriding the minimum password strength even if the provided strength is lower than the configured minimum? const minStrength = - passwordStrength || - this.settings?.minPasswordStrength || + passwordStrength ?? + this.settings?.minPasswordStrength ?? PasswordStrengthEnum.None; // check strength of the password diff --git a/packages/nestjs-user/src/exceptions/user-role-password-exception.ts b/packages/nestjs-user/src/exceptions/user-role-password-exception.ts new file mode 100644 index 000000000..fda4a02d6 --- /dev/null +++ b/packages/nestjs-user/src/exceptions/user-role-password-exception.ts @@ -0,0 +1,15 @@ +import { RuntimeExceptionOptions } from '@concepta/nestjs-exception'; +import { HttpStatus } from '@nestjs/common'; +import { UserException } from './user-exception'; + +export class UserRolePasswordException extends UserException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'Unable to get password strength for role', + httpStatus: HttpStatus.BAD_REQUEST, + ...options, + }); + + this.errorCode = 'USER_ROLES_ERROR'; + } +} diff --git a/packages/nestjs-user/src/exceptions/user-roles-exception.ts b/packages/nestjs-user/src/exceptions/user-roles-exception.ts new file mode 100644 index 000000000..50303355f --- /dev/null +++ b/packages/nestjs-user/src/exceptions/user-roles-exception.ts @@ -0,0 +1,16 @@ +import { RuntimeExceptionOptions } from '@concepta/nestjs-exception'; +import { HttpStatus } from '@nestjs/common'; +import { UserException } from './user-exception'; + +export class UserRolesException extends UserException { + constructor(userId: string, options?: RuntimeExceptionOptions) { + super({ + message: 'Unable to get user roles for user ${userId}', + messageParams: [userId], + httpStatus: HttpStatus.BAD_REQUEST, + ...options, + }); + + this.errorCode = 'USER_ROLES_ERROR'; + } +} diff --git a/packages/nestjs-user/src/index.ts b/packages/nestjs-user/src/index.ts index d98452ed7..1da733c63 100644 --- a/packages/nestjs-user/src/index.ts +++ b/packages/nestjs-user/src/index.ts @@ -28,3 +28,4 @@ export { UserResource } from './user.types'; export { UserException } from './exceptions/user-exception'; export { UserBadRequestException } from './exceptions/user-bad-request-exception'; export { UserNotFoundException } from './exceptions/user-not-found-exception'; +export { UserRolesException } from './exceptions/user-roles-exception'; diff --git a/packages/nestjs-user/src/interfaces/user-role-service.interface.ts b/packages/nestjs-user/src/interfaces/user-role-service.interface.ts index 50efea933..8fbbe182a 100644 --- a/packages/nestjs-user/src/interfaces/user-role-service.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-role-service.interface.ts @@ -1,22 +1,14 @@ -import { - ReferenceId, - RoleOwnableInterface, - UserRolesInterface, -} from '@concepta/nestjs-common'; +import { ReferenceId, RoleOwnableInterface } from '@concepta/nestjs-common'; import { PasswordStrengthEnum } from '@concepta/nestjs-password'; export interface UserRoleServiceInterface { /** * Get user roles from either the user DTO or by looking up the user. * - * @param userDto - User DTO that may contain user roles - * @param userToUpdateId - Optional ID of user to lookup roles for + * @param userId - Optional ID of user to lookup roles for * @returns Array of role names, or empty array if no roles found */ - getUserRoles( - userDto: UserRolesInterface, - userToUpdateId?: ReferenceId, - ): Promise; + getUserRoles(userId?: ReferenceId): Promise; /** * Get password strength based on user roles. @@ -28,5 +20,17 @@ export interface UserRoleServiceInterface { */ resolvePasswordStrength( roles?: RoleOwnableInterface[], - ): PasswordStrengthEnum | null | undefined; + ): PasswordStrengthEnum | undefined; + + /** + * Get the password strength based on user roles + * + * @param userRoles - Optional array of roles from the user object + * @param userToUpdateId - Optional ID of user being updated + * @returns The resolved password strength enum value, or undefined if no roles found + */ + getPasswordStrength( + userRoles?: RoleOwnableInterface[], + userToUpdateId?: ReferenceId, + ): Promise; } diff --git a/packages/nestjs-user/src/services/user-password.service.ts b/packages/nestjs-user/src/services/user-password.service.ts index 434c203b2..84e319f39 100644 --- a/packages/nestjs-user/src/services/user-password.service.ts +++ b/packages/nestjs-user/src/services/user-password.service.ts @@ -14,7 +14,6 @@ import { PasswordCreationService, PasswordCreationServiceInterface, PasswordStorageInterface, - PasswordStrengthEnum, } from '@concepta/nestjs-password'; import { UserPasswordServiceInterface } from '../interfaces/user-password-service.interface'; @@ -44,11 +43,11 @@ export class UserPasswordService implements UserPasswordServiceInterface { @Inject(PasswordCreationService) protected readonly passwordCreationService: PasswordCreationServiceInterface, @Optional() - @Inject(UserRoleService) - private userRoleService?: UserRoleServiceInterface, - @Optional() @Inject(UserPasswordHistoryService) private userPasswordHistoryService?: UserPasswordHistoryServiceInterface, + @Optional() + @Inject(UserRoleService) + private userRoleService?: UserRoleServiceInterface, ) {} async setPassword( @@ -88,11 +87,13 @@ export class UserPasswordService implements UserPasswordServiceInterface { // create safe object const targetSafe = { ...passwordDto, password }; - - const passwordStrength = await this.getPasswordStrength( - passwordDto, - userToUpdateId, - ); + let passwordStrength; + if (this.userRoleService) { + passwordStrength = await this.userRoleService.getPasswordStrength( + passwordDto.userRoles, + userToUpdateId, + ); + } const userWithPasswordHashed = await this.passwordCreationService.createObject(targetSafe, { @@ -120,33 +121,6 @@ export class UserPasswordService implements UserPasswordServiceInterface { return passwordDto; } - /** - * Get the password strength based on user roles - * - * @param userDto - The user object containing roles - * @param userToUpdateId - Optional ID of user being updated - * @returns The resolved password strength enum value, or null/undefined if no roles service - */ - protected async getPasswordStrength( - userDto: UserRolesInterface, - userToUpdateId?: ReferenceId, - ): Promise { - let passwordStrength; - if ( - this.userRoleService && - this.userRoleService.getUserRoles && - this.userRoleService.resolvePasswordStrength - ) { - const roles = await this.userRoleService.getUserRoles( - userDto, - userToUpdateId, - ); - passwordStrength = await this.userRoleService.resolvePasswordStrength( - roles, - ); - } - return passwordStrength; - } async getPasswordStore( userId: ReferenceId, ): Promise { diff --git a/packages/nestjs-user/src/services/user-role.service.ts b/packages/nestjs-user/src/services/user-role.service.ts index d0d4c886d..c3d978fa3 100644 --- a/packages/nestjs-user/src/services/user-role.service.ts +++ b/packages/nestjs-user/src/services/user-role.service.ts @@ -12,6 +12,8 @@ import { UserSettingsInterface } from '../interfaces/user-settings.interface'; import { USER_MODULE_SETTINGS_TOKEN } from '../user.constants'; import { UserLookupService } from './user-lookup.service'; import { UserRoleServiceInterface } from '../interfaces/user-role-service.interface'; +import { UserRolesException } from '../exceptions/user-roles-exception'; +import { UserRolePasswordException } from '../exceptions/user-role-password-exception'; @Injectable() export class UserRoleService implements UserRoleServiceInterface { @@ -22,21 +24,21 @@ export class UserRoleService implements UserRoleServiceInterface { protected readonly userLookupService: UserLookupServiceInterface, ) {} - async getUserRoles( - userDto: UserRolesInterface, - userToUpdateId?: ReferenceId, - ): Promise { - // get roles based on user id - if (userToUpdateId) { - const user: (ReferenceIdInterface & UserRolesInterface) | null = - await this.userLookupService.byId(userToUpdateId); - if (user && user.userRoles) return user.userRoles; + async getUserRoles(userId?: ReferenceId): Promise { + if (userId) { + try { + const user: (ReferenceIdInterface & UserRolesInterface) | null = + await this.userLookupService.byId(userId); + if (user && user.userRoles) { + return user.userRoles; + } + } catch (err) { + throw new UserRolesException(userId, { + originalError: err, + }); + } } - // get roles from payload - if (userDto.userRoles && userDto.userRoles.length > 0) - return userDto.userRoles; - return []; } @@ -50,11 +52,39 @@ export class UserRoleService implements UserRoleServiceInterface { */ resolvePasswordStrength( roles?: RoleOwnableInterface[], - ): PasswordStrengthEnum | null | undefined { - return ( - roles && - this.userSettings.passwordStrength?.passwordStrengthTransform && - this.userSettings.passwordStrength?.passwordStrengthTransform({ roles }) - ); + ): PasswordStrengthEnum | undefined { + if (roles) { + try { + return ( + this.userSettings.passwordStrength?.passwordStrengthTransform && + this.userSettings.passwordStrength?.passwordStrengthTransform({ + roles, + }) + ); + } catch (err) { + throw new UserRolePasswordException({ + originalError: err, + }); + } + } + } + + /** + * Get the password strength based on user roles + * + * @param userRoles - The user roles + * @param userToUpdateId - Optional ID of user being updated + * @returns The resolved password strength enum value, or null/undefined if no roles service + */ + async getPasswordStrength( + userRoles?: RoleOwnableInterface[], + userToUpdateId?: ReferenceId, + ): Promise { + let passwordStrength = undefined; + + const roles = userRoles ?? (await this.getUserRoles(userToUpdateId)); + passwordStrength = await this.resolvePasswordStrength(roles); + + return passwordStrength; } } diff --git a/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts b/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts index 5da9028ab..513d508b6 100644 --- a/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts +++ b/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts @@ -1,12 +1,6 @@ -import supertest from 'supertest'; -import { randomUUID } from 'crypto'; -import { getDataSourceToken } from '@nestjs/typeorm'; -import { Test, TestingModule } from '@nestjs/testing'; -import { INestApplication } from '@nestjs/common'; -import { HttpAdapterHost } from '@nestjs/core'; -import { ExceptionsFilter } from '@concepta/nestjs-exception'; -import { IssueTokenService } from '@concepta/nestjs-authentication'; import { AccessControlService } from '@concepta/nestjs-access-control'; +import { IssueTokenService } from '@concepta/nestjs-authentication'; +import { ExceptionsFilter } from '@concepta/nestjs-exception'; import { PasswordCreationService, PasswordStorageInterface, @@ -15,16 +9,22 @@ import { PasswordValidationService, } from '@concepta/nestjs-password'; import { SeedingSource } from '@concepta/typeorm-seeding'; +import { INestApplication } from '@nestjs/common'; +import { HttpAdapterHost } from '@nestjs/core'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import { randomUUID } from 'crypto'; +import supertest from 'supertest'; -import { UserFactory } from './user.factory'; import { UserLookupService } from './services/user-lookup.service'; -import { UserPasswordHistoryFactory } from './user-password-history.factory'; -import { UserPasswordService } from './services/user-password.service'; import { UserPasswordHistoryLookupService } from './services/user-password-history-lookup.service'; +import { UserPasswordService } from './services/user-password.service'; +import { UserPasswordHistoryFactory } from './user-password-history.factory'; +import { UserFactory } from './user.factory'; import { AppModuleFixture } from './__fixtures__/app.module.fixture'; -import { UserEntityFixture } from './__fixtures__/user.entity.fixture'; import { UserPasswordHistoryEntityFixture } from './__fixtures__/user-password-history.entity.fixture'; +import { UserEntityFixture } from './__fixtures__/user.entity.fixture'; import { UserRoleService } from './services/user-role.service'; describe('User Controller (password e2e)', () => { @@ -282,13 +282,16 @@ describe('User Controller (password e2e)', () => { it('Should not update weak password for admin', async () => { passwordCreationService['settings'].requireCurrentToUpdate = false; userRoleService['userSettings'].passwordStrength = { - passwordStrengthTransform: ({ - roles, - }): PasswordStrengthEnum | null => { - if (roles.some((role) => role.role?.name === 'admin')) { + passwordStrengthTransform: ( + options, + ): PasswordStrengthEnum | undefined => { + if ( + options?.roles && + options?.roles.some((role) => role.role?.name === 'admin') + ) { return PasswordStrengthEnum.VeryStrong; } - return null; + return undefined; }, }; @@ -296,6 +299,7 @@ describe('User Controller (password e2e)', () => { ...fakeUser, userRoles: [ { + roleId: randomUUID(), role: { name: 'admin', }, @@ -319,16 +323,22 @@ describe('User Controller (password e2e)', () => { it('Should not update weak password for admin', async () => { passwordCreationService['settings'].requireCurrentToUpdate = false; userRoleService['userSettings'].passwordStrength = { - passwordStrengthTransform: ({ - roles, - }): PasswordStrengthEnum | null => { - if (roles.some((role) => role.role?.name === 'admin')) { + passwordStrengthTransform: ( + options, + ): PasswordStrengthEnum | undefined => { + if ( + options?.roles && + options?.roles.some((role) => role.role?.name === 'admin') + ) { return PasswordStrengthEnum.VeryStrong; } - if (roles.some((role) => role.role?.name === 'user')) { + if ( + options?.roles && + options?.roles.some((role) => role.role?.name === 'user') + ) { return PasswordStrengthEnum.None; } - return null; + return undefined; }, }; @@ -336,11 +346,13 @@ describe('User Controller (password e2e)', () => { ...fakeUser, userRoles: [ { + roleId: randomUUID(), role: { name: 'admin', }, }, { + roleId: randomUUID(), role: { name: 'user', }, @@ -364,13 +376,16 @@ describe('User Controller (password e2e)', () => { it('Should update with weak password for admin', async () => { passwordCreationService['settings'].requireCurrentToUpdate = false; userRoleService['userSettings'].passwordStrength = { - passwordStrengthTransform: ({ - roles, - }): PasswordStrengthEnum | null => { - if (roles.some((role) => role.role?.name === 'user')) { + passwordStrengthTransform: ( + options, + ): PasswordStrengthEnum | undefined => { + if ( + options?.roles && + options?.roles.some((role) => role.role?.name === 'user') + ) { return PasswordStrengthEnum.None; } - return null; + return undefined; }, }; @@ -378,6 +393,7 @@ describe('User Controller (password e2e)', () => { ...fakeUser, userRoles: [ { + roleId: randomUUID(), role: { name: 'user', }, @@ -397,16 +413,22 @@ describe('User Controller (password e2e)', () => { it('Should update password for admin and user ', async () => { passwordCreationService['settings'].requireCurrentToUpdate = false; userRoleService['userSettings'].passwordStrength = { - passwordStrengthTransform: ({ - roles, - }): PasswordStrengthEnum | null => { - if (roles.some((role) => role.role?.name === 'admin')) { + passwordStrengthTransform: ( + options, + ): PasswordStrengthEnum | undefined => { + if ( + options?.roles && + options?.roles.some((role) => role.role?.name === 'admin') + ) { return PasswordStrengthEnum.VeryStrong; } - if (roles.some((role) => role.role?.name === 'user')) { + if ( + options?.roles && + options?.roles.some((role) => role.role?.name === 'user') + ) { return PasswordStrengthEnum.None; } - return null; + return undefined; }, }; @@ -414,6 +436,7 @@ describe('User Controller (password e2e)', () => { ...fakeUser, userRoles: [ { + roleId: randomUUID(), role: { name: 'user', }, @@ -433,6 +456,7 @@ describe('User Controller (password e2e)', () => { ...fakeUser, userRoles: [ { + roleId: randomUUID(), role: { name: 'admin', }, diff --git a/packages/nestjs-user/src/user.module-definition.ts b/packages/nestjs-user/src/user.module-definition.ts index f8d9e8437..f518df038 100644 --- a/packages/nestjs-user/src/user.module-definition.ts +++ b/packages/nestjs-user/src/user.module-definition.ts @@ -213,8 +213,8 @@ export function createUserPasswordServiceProvider( new UserPasswordService( userLookUpService, passwordCreationService, - userRoleService, userPasswordHistoryService, + userRoleService, ), }; } diff --git a/packages/nestjs-user/src/user.types.ts b/packages/nestjs-user/src/user.types.ts index 4c91689b2..58ebf26c4 100644 --- a/packages/nestjs-user/src/user.types.ts +++ b/packages/nestjs-user/src/user.types.ts @@ -7,5 +7,5 @@ export enum UserResource { } export type PasswordStrengthTransform = ( - options: PasswordStrengthTransformOptionsInterface, -) => PasswordStrengthEnum | null; + options?: PasswordStrengthTransformOptionsInterface, +) => PasswordStrengthEnum | undefined; diff --git a/packages/nestjs-user/src/user.utils.ts b/packages/nestjs-user/src/user.utils.ts index fc5b3de3b..f484fabb1 100644 --- a/packages/nestjs-user/src/user.utils.ts +++ b/packages/nestjs-user/src/user.utils.ts @@ -1,18 +1,29 @@ import { PasswordStrengthEnum } from '@concepta/nestjs-password'; import { PasswordStrengthTransform } from './user.types'; -import { PasswordStrengthTransformOptionsInterface } from '@concepta/nestjs-common'; +import { + PasswordStrengthTransformOptionsInterface, + RoleOwnableInterface, +} from '@concepta/nestjs-common'; export const defaultPasswordStrengthTransform: PasswordStrengthTransform = ( - options: PasswordStrengthTransformOptionsInterface, -): PasswordStrengthEnum | null => { - const { roles } = options; + options?: PasswordStrengthTransformOptionsInterface, +): PasswordStrengthEnum | undefined => { + if (options) { + const { roles } = options; - if (roles.some((role) => role.role?.name === 'admin')) { - return PasswordStrengthEnum.VeryStrong; - } - if (roles.some((role) => role.role?.name === 'user')) { - return PasswordStrengthEnum.Strong; + if (roles) { + if ( + roles.some((role: RoleOwnableInterface) => role.role?.name === 'admin') + ) { + return PasswordStrengthEnum.VeryStrong; + } + if ( + roles.some((role: RoleOwnableInterface) => role.role?.name === 'user') + ) { + return PasswordStrengthEnum.Strong; + } + } } - return null; + return undefined; };