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..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 @@ -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 021ba38c2..8bbf4b50c 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 { OrgOwnableInterface } from './org/interfaces/org-ownable.interface'; @@ -28,6 +29,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'; @@ -37,6 +40,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/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..05ffb4532 --- /dev/null +++ b/packages/nestjs-common/src/domain/password/interfaces/password-strength-transform-options.interface.ts @@ -0,0 +1,5 @@ +import { RoleOwnableInterface } from '../../role/interfaces/role-ownable.interface'; + +export interface PasswordStrengthTransformOptionsInterface { + 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 new file mode 100644 index 000000000..c3a9a96ae --- /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?: 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 257542366..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 @@ -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..88d549240 --- /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[]; +} 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..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 @@ -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..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 @@ -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,10 @@ export interface PasswordCreateObjectOptionsInterface { * Set to true if password is required. */ required?: boolean; + + /** + * Optional password strength requirement. If provided, will validate + * that password meets minimum strength requirements. + */ + 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 new file mode 100644 index 000000000..592c53ebe --- /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 | undefined; +} 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..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,3 +1,5 @@ +import { PasswordStrengthOptionsInterface } from './password-strength-options.interface'; + /** * Password Strength Service Interface */ @@ -6,6 +8,10 @@ export interface PasswordStrengthServiceInterface { * Check if Password is strong * * @param password - The plain text password + * @param options - The options */ - isStrong(password: string): 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 72f2af196..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,19 +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)) { - 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 51dd41b20..f37fe0b66 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 @@ -23,15 +24,28 @@ 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): boolean { - // Get min password Strength + isStrong( + password: string, + options?: PasswordStrengthOptionsInterface, + ): boolean { + const { passwordStrength } = options || {}; + const minStrength = - this.settings?.minPasswordStrength || PasswordStrengthEnum.None; + 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 e4af884ef..fd1363733 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 { defaultPasswordStrengthTransform } 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: { + passwordStrengthTransform: defaultPasswordStrengthTransform, + }, }; }, ); 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-roles.dto.ts b/packages/nestjs-user/src/dto/user-roles.dto.ts new file mode 100644 index 000000000..180c4583f --- /dev/null +++ b/packages/nestjs-user/src/dto/user-roles.dto.ts @@ -0,0 +1,17 @@ +import { + RoleOwnableInterface, + UserRolesInterface, +} from '@concepta/nestjs-common'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Exclude, Expose } from 'class-transformer'; + +@Exclude() +export class UserRolesDto implements UserRolesInterface { + @Expose() + @ApiPropertyOptional({ + type: 'array', + isArray: true, + description: 'User roles', + }) + userRoles?: RoleOwnableInterface[]; +} 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 {} 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/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-entity.interface.ts b/packages/nestjs-user/src/interfaces/user-entity.interface.ts index dcd35d273..86dc8c522 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, + UserRolesInterface {} 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 new file mode 100644 index 000000000..8fbbe182a --- /dev/null +++ b/packages/nestjs-user/src/interfaces/user-role-service.interface.ts @@ -0,0 +1,36 @@ +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 userId - Optional ID of user to lookup roles for + * @returns Array of role names, or empty array if no roles found + */ + getUserRoles(userId?: ReferenceId): Promise; + + /** + * Get password strength based on user roles. + * Uses the configured passwordStrengthTransform callback if available. + * + * @param roles - Array of roles to check against + * @returns Password strength enum value if callback exists and roles are provided, + * undefined otherwise + */ + resolvePasswordStrength( + roles?: RoleOwnableInterface[], + ): 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/interfaces/user-settings.interface.ts b/packages/nestjs-user/src/interfaces/user-settings.interface.ts index f13691ba9..9a3311c26 100644 --- a/packages/nestjs-user/src/interfaces/user-settings.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-settings.interface.ts @@ -7,6 +7,7 @@ import { InvitationGetUserEventPayloadInterface, InvitationGetUserEventResponseInterface, } from '@concepta/nestjs-common'; +import { PasswordStrengthTransform } from '../user.types'; export interface UserSettingsInterface { invitationRequestEvent?: EventClassInterface< @@ -28,4 +29,8 @@ export interface UserSettingsInterface { */ limitDays?: number | undefined; }; + + passwordStrength?: { + passwordStrengthTransform?: PasswordStrengthTransform; + }; } diff --git a/packages/nestjs-user/src/listeners/invitation-accepted-listener.ts b/packages/nestjs-user/src/listeners/invitation-accepted-listener.ts index 642545f35..0346d31e3 100644 --- a/packages/nestjs-user/src/listeners/invitation-accepted-listener.ts +++ b/packages/nestjs-user/src/listeners/invitation-accepted-listener.ts @@ -63,6 +63,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..e0cf19508 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 & 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 8a51b3c1f..84e319f39 100644 --- a/packages/nestjs-user/src/services/user-password.service.ts +++ b/packages/nestjs-user/src/services/user-password.service.ts @@ -1,5 +1,9 @@ import { HttpStatus, Inject, Injectable, Optional } from '@nestjs/common'; -import { ReferenceId, ReferenceIdInterface } from '@concepta/nestjs-common'; +import { + ReferenceId, + ReferenceIdInterface, + UserRolesInterface, +} from '@concepta/nestjs-common'; import { AuthenticatedUserInterface, PasswordPlainCurrentInterface, @@ -19,6 +23,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 { UserRoleService } from './user-role.service'; +import { UserRoleServiceInterface } from '../interfaces/user-role-service.interface'; /** * User password service @@ -39,11 +45,16 @@ export class UserPasswordService implements UserPasswordServiceInterface { @Optional() @Inject(UserPasswordHistoryService) private userPasswordHistoryService?: UserPasswordHistoryServiceInterface, + @Optional() + @Inject(UserRoleService) + private userRoleService?: UserRoleServiceInterface, ) {} async setPassword( passwordDto: Partial< - PasswordPlainInterface & PasswordPlainCurrentInterface + PasswordPlainInterface & + PasswordPlainCurrentInterface & + UserRolesInterface >, userToUpdateId?: ReferenceId, authorizedUser?: AuthenticatedUserInterface, @@ -76,11 +87,18 @@ export class UserPasswordService implements UserPasswordServiceInterface { // create safe object const targetSafe = { ...passwordDto, password }; + let passwordStrength; + if (this.userRoleService) { + passwordStrength = await this.userRoleService.getPasswordStrength( + passwordDto.userRoles, + userToUpdateId, + ); + } - // call the password creation service const userWithPasswordHashed = await this.passwordCreationService.createObject(targetSafe, { required: false, + passwordStrength, }); // push password history if necessary 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..c3d978fa3 --- /dev/null +++ b/packages/nestjs-user/src/services/user-role.service.ts @@ -0,0 +1,90 @@ +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'; +import { UserRolesException } from '../exceptions/user-roles-exception'; +import { UserRolePasswordException } from '../exceptions/user-role-password-exception'; + +@Injectable() +export class UserRoleService implements UserRoleServiceInterface { + constructor( + @Inject(USER_MODULE_SETTINGS_TOKEN) + protected readonly userSettings: UserSettingsInterface, + @Inject(UserLookupService) + protected readonly userLookupService: UserLookupServiceInterface, + ) {} + + 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, + }); + } + } + + return []; + } + + /** + * 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?: RoleOwnableInterface[], + ): 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 7f8eb04f8..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,29 +1,31 @@ -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, PasswordStorageService, + PasswordStrengthEnum, 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)', () => { describe('Password Update Flow', () => { @@ -36,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; @@ -74,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, ); @@ -98,12 +102,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 +241,241 @@ 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; + userRoleService['userSettings'].passwordStrength = { + passwordStrengthTransform: ( + options, + ): PasswordStrengthEnum | undefined => { + if ( + options?.roles && + options?.roles.some((role) => role.role?.name === 'admin') + ) { + return PasswordStrengthEnum.VeryStrong; + } + return undefined; + }, + }; + + jest.spyOn(userLookupService, 'byId').mockResolvedValue({ + ...fakeUser, + userRoles: [ + { + roleId: randomUUID(), + 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; + userRoleService['userSettings'].passwordStrength = { + passwordStrengthTransform: ( + options, + ): PasswordStrengthEnum | undefined => { + if ( + options?.roles && + options?.roles.some((role) => role.role?.name === 'admin') + ) { + return PasswordStrengthEnum.VeryStrong; + } + if ( + options?.roles && + options?.roles.some((role) => role.role?.name === 'user') + ) { + return PasswordStrengthEnum.None; + } + return undefined; + }, + }; + + jest.spyOn(userLookupService, 'byId').mockResolvedValue({ + ...fakeUser, + userRoles: [ + { + roleId: randomUUID(), + role: { + name: 'admin', + }, + }, + { + roleId: randomUUID(), + 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; + userRoleService['userSettings'].passwordStrength = { + passwordStrengthTransform: ( + options, + ): PasswordStrengthEnum | undefined => { + if ( + options?.roles && + options?.roles.some((role) => role.role?.name === 'user') + ) { + return PasswordStrengthEnum.None; + } + return undefined; + }, + }; + + jest.spyOn(userLookupService, 'byId').mockResolvedValue({ + ...fakeUser, + userRoles: [ + { + roleId: randomUUID(), + 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; + userRoleService['userSettings'].passwordStrength = { + passwordStrengthTransform: ( + options, + ): PasswordStrengthEnum | undefined => { + if ( + options?.roles && + options?.roles.some((role) => role.role?.name === 'admin') + ) { + return PasswordStrengthEnum.VeryStrong; + } + if ( + options?.roles && + options?.roles.some((role) => role.role?.name === 'user') + ) { + return PasswordStrengthEnum.None; + } + return undefined; + }, + }; + + jest.spyOn(userLookupService, 'byId').mockResolvedValue({ + ...fakeUser, + userRoles: [ + { + roleId: randomUUID(), + 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: [ + { + roleId: randomUUID(), + 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.module-definition.ts b/packages/nestjs-user/src/user.module-definition.ts index 015660e36..f518df038 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__'); @@ -101,6 +102,7 @@ export function createUserProviders(options: { createUserLookupServiceProvider(options.overrides), createUserMutateServiceProvider(options.overrides), createUserPasswordServiceProvider(options.overrides), + createUserRoleServiceProvider(options.overrides), createUserPasswordHistoryServiceProvider(options.overrides), createUserPasswordHistoryLookupServiceProvider(), createUserPasswordHistoryMutateServiceProvider(), @@ -117,6 +119,7 @@ export function createUserExports(): Required< UserMutateService, UserCrudService, UserPasswordService, + UserRoleService, UserPasswordHistoryService, UserPasswordHistoryLookupService, UserPasswordHistoryMutateService, @@ -192,6 +195,7 @@ export function createUserPasswordServiceProvider( RAW_OPTIONS_TOKEN, UserLookupService, PasswordCreationService, + UserRoleService, { token: UserPasswordHistoryService, optional: true, @@ -201,6 +205,7 @@ export function createUserPasswordServiceProvider( options: UserOptionsInterface, userLookUpService: UserLookupServiceInterface, passwordCreationService: PasswordCreationService, + userRoleService: UserRoleService, userPasswordHistoryService?: UserPasswordHistoryService, ) => optionsOverrides?.userPasswordService ?? @@ -209,10 +214,28 @@ export function createUserPasswordServiceProvider( userLookUpService, passwordCreationService, userPasswordHistoryService, + userRoleService, ), }; } +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.types.ts b/packages/nestjs-user/src/user.types.ts index b5559d5bf..58ebf26c4 100644 --- a/packages/nestjs-user/src/user.types.ts +++ b/packages/nestjs-user/src/user.types.ts @@ -1,4 +1,11 @@ +import { PasswordStrengthTransformOptionsInterface } from '@concepta/nestjs-common'; +import { PasswordStrengthEnum } from '@concepta/nestjs-password'; + export enum UserResource { 'One' = 'user', 'Many' = 'user-list', } + +export type PasswordStrengthTransform = ( + options?: PasswordStrengthTransformOptionsInterface, +) => PasswordStrengthEnum | undefined; diff --git a/packages/nestjs-user/src/user.utils.ts b/packages/nestjs-user/src/user.utils.ts new file mode 100644 index 000000000..f484fabb1 --- /dev/null +++ b/packages/nestjs-user/src/user.utils.ts @@ -0,0 +1,29 @@ +import { PasswordStrengthEnum } from '@concepta/nestjs-password'; +import { PasswordStrengthTransform } from './user.types'; +import { + PasswordStrengthTransformOptionsInterface, + RoleOwnableInterface, +} from '@concepta/nestjs-common'; + +export const defaultPasswordStrengthTransform: PasswordStrengthTransform = ( + options?: PasswordStrengthTransformOptionsInterface, +): PasswordStrengthEnum | undefined => { + if (options) { + const { roles } = options; + + 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 undefined; +};