diff --git a/packages/nestjs-password/src/index.ts b/packages/nestjs-password/src/index.ts index a788a73c9..1b4d0905a 100644 --- a/packages/nestjs-password/src/index.ts +++ b/packages/nestjs-password/src/index.ts @@ -13,3 +13,4 @@ export * from './interfaces/password-storage-service.interface'; export * from './interfaces/password-validation-service.interface'; export * from './interfaces/password-creation-service.interface'; export { PasswordCreateObjectOptionsInterface } from './interfaces/password-create-object-options.interface'; +export { isPasswordStorage } from './utils/is-password-storage.typeguard'; diff --git a/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts b/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts index fedcb6770..8ba38d571 100644 --- a/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts @@ -2,6 +2,7 @@ import { PasswordPlainInterface } from '@concepta/ts-common'; import { PasswordStorageInterface } from './password-storage.interface'; import { PasswordCurrentPasswordInterface } from './password-current-password.interface'; import { PasswordCreateObjectOptionsInterface } from './password-create-object-options.interface'; +import { PasswordHistoryPasswordInterface } from './password-history-password.interface'; /** * Password Creation Service Interface @@ -31,6 +32,16 @@ export interface PasswordCreationServiceInterface { options: Partial, ) => Promise; + /** + * Validate the array of password stores to check for previous usage. + * + * @param options - Validate history options. + * @returns boolean Returns true if password has NOT been used withing configured range. + */ + validateHistory: ( + options: Partial, + ) => Promise; + /** * Check if attempt is valid. * diff --git a/packages/nestjs-password/src/interfaces/password-current-password.interface.ts b/packages/nestjs-password/src/interfaces/password-current-password.interface.ts index 07ddb90d6..44f3f0ae3 100644 --- a/packages/nestjs-password/src/interfaces/password-current-password.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-current-password.interface.ts @@ -1,6 +1,7 @@ +import { PasswordPlainInterface } from '@concepta/ts-common'; import { PasswordStorageInterface } from './password-storage.interface'; -export interface PasswordCurrentPasswordInterface { - password: string; +export interface PasswordCurrentPasswordInterface + extends PasswordPlainInterface { target: PasswordStorageInterface; } diff --git a/packages/nestjs-password/src/interfaces/password-history-password.interface.ts b/packages/nestjs-password/src/interfaces/password-history-password.interface.ts new file mode 100644 index 000000000..4068965de --- /dev/null +++ b/packages/nestjs-password/src/interfaces/password-history-password.interface.ts @@ -0,0 +1,7 @@ +import { PasswordPlainInterface } from '@concepta/ts-common'; +import { PasswordStorageInterface } from './password-storage.interface'; + +export interface PasswordHistoryPasswordInterface + extends PasswordPlainInterface { + targets: PasswordStorageInterface[]; +} diff --git a/packages/nestjs-password/src/services/password-creation.service.ts b/packages/nestjs-password/src/services/password-creation.service.ts index 7c1694e67..880760394 100644 --- a/packages/nestjs-password/src/services/password-creation.service.ts +++ b/packages/nestjs-password/src/services/password-creation.service.ts @@ -10,6 +10,7 @@ import { PasswordStorageService } from './password-storage.service'; import { PasswordValidationService } from './password-validation.service'; import { PasswordCreateObjectOptionsInterface } from '../interfaces/password-create-object-options.interface'; import { PasswordCurrentPasswordInterface } from '../interfaces/password-current-password.interface'; +import { PasswordHistoryPasswordInterface } from '../interfaces/password-history-password.interface'; /** * Service with functions related to password creation @@ -110,6 +111,41 @@ export class PasswordCreationService return true; } + public async validateHistory( + options: Partial, + ): Promise { + const { password, targets } = options || { + password: undefined, + targets: [], + }; + + // make sure the password is a string with some length + if ( + typeof password === 'string' && + password.length > 0 && + targets?.length + ) { + // validate each target + for (const target of targets) { + // check if historic password is valid + const isValid = await this.passwordValidationService.validateObject( + password, + target, + ); + + // is valid? + if (isValid) { + throw new Error( + 'The new password has been used too recently, please use a different password', + ); + } + } + } + + // valid by default + return true; + } + /** * Check if number of current attempt is allowed based on the amount of attempts left * if the number of attempts left is greater then diff --git a/packages/nestjs-password/src/utils/is-password-storage.typeguard.ts b/packages/nestjs-password/src/utils/is-password-storage.typeguard.ts new file mode 100644 index 000000000..60b2c91e0 --- /dev/null +++ b/packages/nestjs-password/src/utils/is-password-storage.typeguard.ts @@ -0,0 +1,10 @@ +import { PasswordStorageInterface } from '../interfaces/password-storage.interface'; + +export function isPasswordStorage( + target: unknown, +): target is PasswordStorageInterface { + return ( + typeof (target as PasswordStorageInterface).passwordHash === 'string' && + typeof (target as PasswordStorageInterface).passwordSalt === 'string' + ); +} diff --git a/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts b/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts index acd4b221e..c09001fd1 100644 --- a/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts +++ b/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts @@ -46,6 +46,11 @@ rules inject: [UserLookupCustomService], useFactory: async (userLookupService: UserLookupCustomService) => ({ userLookupService, + settings: { + passwordHistory: { + enabled: true, + }, + }, }), entities: { user: { diff --git a/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts index b8c9eb6e7..a67bf5c0d 100644 --- a/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts +++ b/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts @@ -18,6 +18,7 @@ import { UserAccessQueryService } from '../services/user-access-query.service'; import { ormConfig } from './ormconfig.fixture'; import { UserEntityFixture } from './user.entity.fixture'; +import { UserPasswordHistoryEntityFixture } from './user-password-history.entity.fixture'; const rules = new AccessControl(); rules @@ -50,11 +51,18 @@ rules settings: { invitationRequestEvent: InvitationAcceptedEventAsync, invitationGetUserEvent: InvitationGetUserEventAsync, + passwordHistory: { + enabled: true, + limitDays: 99, + }, }, entities: { user: { entity: UserEntityFixture, }, + 'user-password-history': { + entity: UserPasswordHistoryEntityFixture, + }, }, }), ], diff --git a/packages/nestjs-user/src/__fixtures__/ormconfig.fixture.ts b/packages/nestjs-user/src/__fixtures__/ormconfig.fixture.ts index 09afa557f..682ee1fc1 100644 --- a/packages/nestjs-user/src/__fixtures__/ormconfig.fixture.ts +++ b/packages/nestjs-user/src/__fixtures__/ormconfig.fixture.ts @@ -1,9 +1,10 @@ import { DataSourceOptions } from 'typeorm'; import { UserEntityFixture } from './user.entity.fixture'; +import { UserPasswordHistoryEntityFixture } from './user-password-history.entity.fixture'; export const ormConfig: DataSourceOptions = { type: 'sqlite', database: ':memory:', synchronize: true, - entities: [UserEntityFixture], + entities: [UserEntityFixture, UserPasswordHistoryEntityFixture], }; diff --git a/packages/nestjs-user/src/__fixtures__/user-password-history.entity.fixture.ts b/packages/nestjs-user/src/__fixtures__/user-password-history.entity.fixture.ts new file mode 100644 index 000000000..5d3197ee9 --- /dev/null +++ b/packages/nestjs-user/src/__fixtures__/user-password-history.entity.fixture.ts @@ -0,0 +1,5 @@ +import { Entity } from 'typeorm'; +import { UserPasswordHistorySqliteEntity } from '../entities/user-password-history-sqlite.entity'; + +@Entity() +export class UserPasswordHistoryEntityFixture extends UserPasswordHistorySqliteEntity {} diff --git a/packages/nestjs-user/src/config/user-default.config.ts b/packages/nestjs-user/src/config/user-default.config.ts index 7a8e957be..e4af884ef 100644 --- a/packages/nestjs-user/src/config/user-default.config.ts +++ b/packages/nestjs-user/src/config/user-default.config.ts @@ -1,11 +1,29 @@ import { registerAs } from '@nestjs/config'; import { UserSettingsInterface } from '../interfaces/user-settings.interface'; -import { USER_MODULE_DEFAULT_SETTINGS_TOKEN } from '../user.constants'; +import { + USER_MODULE_DEFAULT_SETTINGS_TOKEN, + USER_MODULE_USER_PASSWORD_HISTORY_LIMIT_DAYS_DEFAULT, +} from '../user.constants'; /** * Default configuration for User module. */ export const userDefaultConfig = registerAs( USER_MODULE_DEFAULT_SETTINGS_TOKEN, - (): UserSettingsInterface => ({}), + (): UserSettingsInterface => { + // password history tracking is disabled by default + const enabled = process.env?.USER_PASSWORD_HISTORY_ENABLED === 'true'; + + // determine default limitation days + const limitDays = process.env?.USER_PASSWORD_HISTORY_MAX_DAYS?.length + ? Number(process.env?.USER_PASSWORD_HISTORY_MAX_DAYS) + : USER_MODULE_USER_PASSWORD_HISTORY_LIMIT_DAYS_DEFAULT; + + return { + passwordHistory: { + enabled, + limitDays: isNaN(limitDays) || limitDays < 1 ? undefined : limitDays, + }, + }; + }, ); diff --git a/packages/nestjs-user/src/dto/user-password-history-create.dto.ts b/packages/nestjs-user/src/dto/user-password-history-create.dto.ts new file mode 100644 index 000000000..19ecdf361 --- /dev/null +++ b/packages/nestjs-user/src/dto/user-password-history-create.dto.ts @@ -0,0 +1,16 @@ +import { Exclude } from 'class-transformer'; +import { PickType } from '@nestjs/swagger'; +import { UserPasswordHistoryDto } from './user-password-history.dto'; +import { UserPasswordHistoryCreatableInterface } from '../interfaces/user-password-history-creatable.interface'; + +/** + * User Password History Create DTO + */ +@Exclude() +export class UserPasswordHistoryCreateDto + extends PickType(UserPasswordHistoryDto, [ + 'passwordHash', + 'passwordSalt', + 'userId', + ] as const) + implements UserPasswordHistoryCreatableInterface {} diff --git a/packages/nestjs-user/src/dto/user-password-history.dto.ts b/packages/nestjs-user/src/dto/user-password-history.dto.ts new file mode 100644 index 000000000..3df772d1f --- /dev/null +++ b/packages/nestjs-user/src/dto/user-password-history.dto.ts @@ -0,0 +1,49 @@ +import { IsString, IsUUID } from 'class-validator'; +import { Exclude, Expose } from 'class-transformer'; +import { CommonEntityDto } from '@concepta/nestjs-common'; +import { ApiProperty } from '@nestjs/swagger'; + +import { UserPasswordHistoryInterface } from '../interfaces/user-password-history.interface'; + +/** + * User Password History DTO + */ +@Exclude() +export class UserPasswordHistoryDto + extends CommonEntityDto + implements UserPasswordHistoryInterface +{ + /** + * Password Hash + */ + @Expose() + @ApiProperty({ + type: 'string', + description: 'Password Hash', + }) + @IsString() + passwordHash!: string; + + /** + * Password Salt + */ + @Expose() + @ApiProperty({ + type: 'string', + description: 'Password Salt', + }) + @IsString() + passwordSalt!: string; + + /** + * User ID + */ + @Expose() + @ApiProperty({ + type: 'string', + format: 'uuid', + description: 'User ID', + }) + @IsUUID() + userId!: string; +} diff --git a/packages/nestjs-user/src/entities/user-password-history-postgres.entity.ts b/packages/nestjs-user/src/entities/user-password-history-postgres.entity.ts new file mode 100644 index 000000000..26317036a --- /dev/null +++ b/packages/nestjs-user/src/entities/user-password-history-postgres.entity.ts @@ -0,0 +1,36 @@ +import { Column } from 'typeorm'; +import { ReferenceId } from '@concepta/ts-core'; +import { CommonPostgresEntity } from '@concepta/typeorm-common'; +import { UserPasswordHistoryEntityInterface } from '../interfaces/user-password-history-entity.interface'; +import { UserEntityInterface } from '../interfaces/user-entity.interface'; + +/** + * User Entity + */ +export abstract class UserPasswordHistoryPostgresEntity + extends CommonPostgresEntity + implements UserPasswordHistoryEntityInterface +{ + /** + * Password hash + */ + @Column({ type: 'text', nullable: true, default: null }) + passwordHash: string | null = null; + + /** + * Password salt + */ + @Column({ type: 'text', nullable: true, default: null }) + passwordSalt: string | null = null; + + /** + * User ID + */ + @Column({ type: 'uuid' }) + userId!: ReferenceId; + + /** + * Should be configured by the implementation + */ + user?: UserEntityInterface; +} diff --git a/packages/nestjs-user/src/entities/user-password-history-sqlite.entity.ts b/packages/nestjs-user/src/entities/user-password-history-sqlite.entity.ts new file mode 100644 index 000000000..bfebd3d57 --- /dev/null +++ b/packages/nestjs-user/src/entities/user-password-history-sqlite.entity.ts @@ -0,0 +1,36 @@ +import { Column } from 'typeorm'; +import { ReferenceId } from '@concepta/ts-core'; +import { CommonSqliteEntity } from '@concepta/typeorm-common'; +import { UserPasswordHistoryEntityInterface } from '../interfaces/user-password-history-entity.interface'; +import { UserEntityInterface } from '../interfaces/user-entity.interface'; + +/** + * User Entity + */ +export abstract class UserPasswordHistorySqliteEntity + extends CommonSqliteEntity + implements UserPasswordHistoryEntityInterface +{ + /** + * Password hash + */ + @Column({ type: 'text', nullable: true, default: null }) + passwordHash: string | null = null; + + /** + * Password salt + */ + @Column({ type: 'text', nullable: true, default: null }) + passwordSalt: string | null = null; + + /** + * User ID + */ + @Column({ type: 'uuid' }) + userId!: ReferenceId; + + /** + * Should be configured by the implementation + */ + user?: UserEntityInterface; +} diff --git a/packages/nestjs-user/src/entities/user-postgres.entity.ts b/packages/nestjs-user/src/entities/user-postgres.entity.ts index 7d6c0c6d0..0a33aa0b2 100644 --- a/packages/nestjs-user/src/entities/user-postgres.entity.ts +++ b/packages/nestjs-user/src/entities/user-postgres.entity.ts @@ -1,6 +1,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'; /** * User Entity @@ -38,4 +39,6 @@ export abstract class UserPostgresEntity */ @Column({ type: 'text', nullable: true, default: null }) passwordSalt: string | null = null; + + 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 7c98c3d02..8909ea186 100644 --- a/packages/nestjs-user/src/entities/user-sqlite.entity.ts +++ b/packages/nestjs-user/src/entities/user-sqlite.entity.ts @@ -1,6 +1,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'; export abstract class UserSqliteEntity extends CommonSqliteEntity @@ -35,4 +36,6 @@ export abstract class UserSqliteEntity */ @Column({ type: 'text', nullable: true, default: null }) passwordSalt: string | null = null; + + userPasswordHistory?: UserPasswordHistoryEntityInterface; } diff --git a/packages/nestjs-user/src/interfaces/user-entities-options.interface.ts b/packages/nestjs-user/src/interfaces/user-entities-options.interface.ts index 8a41fed85..0a8d20203 100644 --- a/packages/nestjs-user/src/interfaces/user-entities-options.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-entities-options.interface.ts @@ -1,9 +1,14 @@ import { UserEntityInterface } from './user-entity.interface'; import { TypeOrmExtEntityOptionInterface } from '@concepta/nestjs-typeorm-ext'; -import { USER_MODULE_USER_ENTITY_KEY } from '../user.constants'; +import { + USER_MODULE_USER_ENTITY_KEY, + USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY, +} from '../user.constants'; +import { UserPasswordHistoryEntityInterface } from './user-password-history-entity.interface'; export interface UserEntitiesOptionsInterface { entities: { [USER_MODULE_USER_ENTITY_KEY]: TypeOrmExtEntityOptionInterface; + [USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY]?: TypeOrmExtEntityOptionInterface; }; } diff --git a/packages/nestjs-user/src/interfaces/user-options.interface.ts b/packages/nestjs-user/src/interfaces/user-options.interface.ts index dc79aa75a..83593cc17 100644 --- a/packages/nestjs-user/src/interfaces/user-options.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-options.interface.ts @@ -3,11 +3,13 @@ import { UserSettingsInterface } from './user-settings.interface'; 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'; export interface UserOptionsInterface { settings?: UserSettingsInterface; userLookupService?: UserLookupServiceInterface; userMutateService?: UserMutateServiceInterface; userPasswordService?: UserPasswordServiceInterface; + userPasswordHistoryService?: UserPasswordHistoryServiceInterface; userAccessQueryService?: CanAccess; } diff --git a/packages/nestjs-user/src/interfaces/user-password-history-creatable.interface.ts b/packages/nestjs-user/src/interfaces/user-password-history-creatable.interface.ts new file mode 100644 index 000000000..84dddb23a --- /dev/null +++ b/packages/nestjs-user/src/interfaces/user-password-history-creatable.interface.ts @@ -0,0 +1,7 @@ +import { UserPasswordHistoryInterface } from './user-password-history.interface'; + +export interface UserPasswordHistoryCreatableInterface + extends Pick< + UserPasswordHistoryInterface, + 'passwordHash' | 'passwordSalt' | 'userId' + > {} diff --git a/packages/nestjs-user/src/interfaces/user-password-history-entity.interface.ts b/packages/nestjs-user/src/interfaces/user-password-history-entity.interface.ts new file mode 100644 index 000000000..47142be57 --- /dev/null +++ b/packages/nestjs-user/src/interfaces/user-password-history-entity.interface.ts @@ -0,0 +1,4 @@ +import { UserPasswordHistoryInterface } from './user-password-history.interface'; + +export interface UserPasswordHistoryEntityInterface + extends UserPasswordHistoryInterface {} diff --git a/packages/nestjs-user/src/interfaces/user-password-history-service.interface.ts b/packages/nestjs-user/src/interfaces/user-password-history-service.interface.ts new file mode 100644 index 000000000..22562532e --- /dev/null +++ b/packages/nestjs-user/src/interfaces/user-password-history-service.interface.ts @@ -0,0 +1,29 @@ +import { PasswordStorageInterface } from '@concepta/nestjs-password'; +import { ReferenceId, ReferenceIdInterface } from '@concepta/ts-core'; + +export interface UserPasswordHistoryServiceInterface { + /** + * Get the password history for the user id. + * + * Object must have reference id and password storage interface. + * + * @param userId - The id of the user + * @returns The password history for the user + */ + getHistory: ( + userId: ReferenceId, + ) => Promise<(ReferenceIdInterface & PasswordStorageInterface)[]>; + + /** + * Push one password history for the user id. + * + * Object must have reference id and password storage interface. + * + * @param userId - The id of the user + * @param passwordStore - One password history for the user + */ + pushHistory: ( + userId: ReferenceId, + passwordStore: PasswordStorageInterface, + ) => Promise; +} diff --git a/packages/nestjs-user/src/interfaces/user-password-history.interface.ts b/packages/nestjs-user/src/interfaces/user-password-history.interface.ts new file mode 100644 index 000000000..f43560319 --- /dev/null +++ b/packages/nestjs-user/src/interfaces/user-password-history.interface.ts @@ -0,0 +1,9 @@ +import { AuditInterface, ReferenceIdInterface } from '@concepta/ts-core'; +import { UserOwnableInterface } from '@concepta/ts-common'; +import { PasswordStorageInterface } from '@concepta/nestjs-password'; + +export interface UserPasswordHistoryInterface + extends ReferenceIdInterface, + PasswordStorageInterface, + UserOwnableInterface, + AuditInterface {} diff --git a/packages/nestjs-user/src/interfaces/user-password-service.interface.ts b/packages/nestjs-user/src/interfaces/user-password-service.interface.ts index 18823f8be..025261845 100644 --- a/packages/nestjs-user/src/interfaces/user-password-service.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-password-service.interface.ts @@ -11,14 +11,14 @@ import { export interface UserPasswordServiceInterface { /** - * Get the user being updated by id. + * Get the object containing the password store by user id. * * Object must have reference id and password storage interface. * * @param userId - The id of the user that is being updated * @returns The user being updated */ - getUserById: ( + getPasswordStore: ( userId: 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 5e3d5d851..910aeda11 100644 --- a/packages/nestjs-user/src/interfaces/user-settings.interface.ts +++ b/packages/nestjs-user/src/interfaces/user-settings.interface.ts @@ -18,4 +18,14 @@ export interface UserSettingsInterface { InvitationGetUserEventResponseInterface > >; + passwordHistory?: { + /** + * password history feature toggle + */ + enabled?: boolean; + /** + * number of days that password history limitation applies for + */ + limitDays?: number | undefined; + }; } diff --git a/packages/nestjs-user/src/services/user-mutate.service.ts b/packages/nestjs-user/src/services/user-mutate.service.ts index f15b423aa..95d4c78e5 100644 --- a/packages/nestjs-user/src/services/user-mutate.service.ts +++ b/packages/nestjs-user/src/services/user-mutate.service.ts @@ -50,7 +50,7 @@ export class UserMutateService // do we need to hash the password? if ('password' in user && typeof user.password === 'string') { // yes, hash it - return this.userPasswordService.setPassword(user); + return this.userPasswordService.setPassword(user, user?.id); } else { // no changes return user; diff --git a/packages/nestjs-user/src/services/user-password-history-lookup.service.ts b/packages/nestjs-user/src/services/user-password-history-lookup.service.ts new file mode 100644 index 000000000..1559fc2b2 --- /dev/null +++ b/packages/nestjs-user/src/services/user-password-history-lookup.service.ts @@ -0,0 +1,22 @@ +import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { LookupService, QueryOptionsInterface } from '@concepta/typeorm-common'; +import { InjectDynamicRepository } from '@concepta/nestjs-typeorm-ext'; + +import { USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY } from '../user.constants'; +import { UserPasswordHistoryEntityInterface } from '../interfaces/user-password-history-entity.interface'; +import { ReferenceId } from '@concepta/ts-core'; + +@Injectable() +export class UserPasswordHistoryLookupService extends LookupService { + constructor( + @InjectDynamicRepository(USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY) + protected readonly userPasswordHistoryRepo: Repository, + ) { + super(userPasswordHistoryRepo); + } + + async byUserId(userId: ReferenceId, queryOptions?: QueryOptionsInterface) { + return this.repository(queryOptions).findBy({ userId }); + } +} diff --git a/packages/nestjs-user/src/services/user-password-history-mutate.service.ts b/packages/nestjs-user/src/services/user-password-history-mutate.service.ts new file mode 100644 index 000000000..2a002083d --- /dev/null +++ b/packages/nestjs-user/src/services/user-password-history-mutate.service.ts @@ -0,0 +1,27 @@ +import { Repository } from 'typeorm'; +import { Injectable } from '@nestjs/common'; +import { MutateService } from '@concepta/typeorm-common'; +import { InjectDynamicRepository } from '@concepta/nestjs-typeorm-ext'; + +import { USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY } from '../user.constants'; +import { UserPasswordHistoryEntityInterface } from '../interfaces/user-password-history-entity.interface'; +import { UserPasswordHistoryCreatableInterface } from '../interfaces/user-password-history-creatable.interface'; +import { UserPasswordHistoryCreateDto } from '../dto/user-password-history-create.dto'; + +@Injectable() +export class UserPasswordHistoryMutateService extends MutateService< + UserPasswordHistoryEntityInterface, + UserPasswordHistoryCreatableInterface, + never, + never +> { + constructor( + @InjectDynamicRepository(USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY) + protected readonly userPasswordHistoryRepo: Repository, + ) { + super(userPasswordHistoryRepo); + } + + protected createDto = UserPasswordHistoryCreateDto; + protected updateDto!: never; +} diff --git a/packages/nestjs-user/src/services/user-password-history.service.ts b/packages/nestjs-user/src/services/user-password-history.service.ts new file mode 100644 index 000000000..a94860a0e --- /dev/null +++ b/packages/nestjs-user/src/services/user-password-history.service.ts @@ -0,0 +1,103 @@ +import { MoreThan } from 'typeorm'; +import { Inject, Injectable } from '@nestjs/common'; +import { ReferenceId, ReferenceIdInterface } from '@concepta/ts-core'; +import { PasswordStorageInterface } from '@concepta/nestjs-password'; + +import { UserPasswordHistoryLookupService } from './user-password-history-lookup.service'; +import { UserPasswordHistoryMutateService } from './user-password-history-mutate.service'; + +import { UserException } from '../exceptions/user-exception'; +import { UserPasswordHistoryServiceInterface } from '../interfaces/user-password-history-service.interface'; +import { UserSettingsInterface } from '../interfaces/user-settings.interface'; +import { USER_MODULE_SETTINGS_TOKEN } from '../user.constants'; +import { QueryOptionsInterface } from '@concepta/typeorm-common'; +import { FindManyOptions } from 'typeorm'; + +@Injectable() +export class UserPasswordHistoryService + implements UserPasswordHistoryServiceInterface +{ + constructor( + @Inject(USER_MODULE_SETTINGS_TOKEN) + protected readonly userSettings: UserSettingsInterface, + @Inject(UserPasswordHistoryLookupService) + protected readonly userPasswordHistoryLookupService: UserPasswordHistoryLookupService, + @Inject(UserPasswordHistoryMutateService) + protected readonly userPasswordHistoryMutateService: UserPasswordHistoryMutateService, + ) {} + + async getHistory( + userId: ReferenceId, + queryOptions?: QueryOptionsInterface, + ): Promise<(ReferenceIdInterface & PasswordStorageInterface)[]> { + let history: (ReferenceIdInterface & PasswordStorageInterface)[] | null; + + try { + // try to lookup the user + history = await this.userPasswordHistoryLookupService.find( + this.getHistoryFindManyOptions(userId), + queryOptions, + ); + } catch (e: unknown) { + throw new UserException( + 'Cannot update password, error while getting password history by user id', + e, + ); + } + + // return history if found or empty array + return history ?? []; + } + + async pushHistory( + userId: string, + passwordStore: PasswordStorageInterface, + queryOptions?: QueryOptionsInterface, + ): Promise { + try { + await this.userPasswordHistoryMutateService.create( + { + userId, + ...passwordStore, + }, + queryOptions, + ); + } catch (e: unknown) { + throw new UserException( + 'Cannot update password, error while pushing password history by user id', + e, + ); + } + } + + protected getHistoryFindManyOptions(userId: ReferenceId): FindManyOptions { + // the base query + const query: FindManyOptions & { + where: { + userId: ReferenceId; + dateCreated?: ReturnType; + }; + } = { + where: { + userId, + }, + order: { + dateCreated: 'ASC', + }, + }; + + // is there a limit days setting? + if (this.userSettings?.passwordHistory?.limitDays) { + // our limit date + const limitDate = new Date(); + // subtract limit days setting + limitDate.setDate( + limitDate.getDate() - this.userSettings.passwordHistory.limitDays, + ); + // set the created at query + query.where.dateCreated = MoreThan(limitDate); + } + + return query; + } +} diff --git a/packages/nestjs-user/src/services/user-password.service.ts b/packages/nestjs-user/src/services/user-password.service.ts index cc499746d..b90c7a050 100644 --- a/packages/nestjs-user/src/services/user-password.service.ts +++ b/packages/nestjs-user/src/services/user-password.service.ts @@ -1,4 +1,4 @@ -import { Inject, Injectable } from '@nestjs/common'; +import { Inject, Injectable, Optional } from '@nestjs/common'; import { ReferenceId, ReferenceIdInterface } from '@concepta/ts-core'; import { AuthenticatedUserInterface, @@ -6,6 +6,7 @@ import { PasswordPlainInterface, } from '@concepta/ts-common'; import { + isPasswordStorage, PasswordCreationService, PasswordCreationServiceInterface, PasswordStorageInterface, @@ -13,7 +14,9 @@ import { import { UserPasswordServiceInterface } from '../interfaces/user-password-service.interface'; import { UserLookupServiceInterface } from '../interfaces/user-lookup-service.interface'; +import { UserPasswordHistoryServiceInterface } from '../interfaces/user-password-history-service.interface'; 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'; @@ -33,6 +36,9 @@ export class UserPasswordService implements UserPasswordServiceInterface { protected readonly userLookupService: UserLookupServiceInterface, @Inject(PasswordCreationService) protected readonly passwordCreationService: PasswordCreationServiceInterface, + @Optional() + @Inject(UserPasswordHistoryService) + private userPasswordHistoryService?: UserPasswordHistoryServiceInterface, ) {} async setPassword( @@ -45,32 +51,59 @@ export class UserPasswordService implements UserPasswordServiceInterface { // break out the password const { password } = passwordDto ?? {}; + // user to update + let userToUpdate: + | (ReferenceIdInterface & PasswordStorageInterface) + | undefined = undefined; + // did we receive a password to set? if (typeof password === 'string') { // are we updating? if (userToUpdateId) { // yes, get the user - const userToUpdate = await this.getUserById(userToUpdateId); + userToUpdate = await this.getPasswordStore(userToUpdateId); + // call current password validation helper await this.validateCurrent( userToUpdate, passwordDto?.passwordCurrent, authorizedUser, ); + + // call password history validation helper + await this.validateHistory(userToUpdate, password); } + // create safe object const targetSafe = { ...passwordDto, password }; + // call the password creation service - return await this.passwordCreationService.createObject(targetSafe, { - required: false, - }); + const userWithPasswordHashed = + await this.passwordCreationService.createObject(targetSafe, { + required: false, + }); + + // push password history if necessary + if ( + this.userPasswordHistoryService && + userToUpdate && + isPasswordStorage(userWithPasswordHashed) + ) { + await this.userPasswordHistoryService.pushHistory( + userToUpdate.id, + userWithPasswordHashed, + ); + } + + // return user + return userWithPasswordHashed; } // return the object untouched return passwordDto; } - async getUserById( + async getPasswordStore( userId: ReferenceId, ): Promise { let user: (ReferenceIdInterface & Partial) | null; @@ -126,6 +159,33 @@ export class UserPasswordService implements UserPasswordServiceInterface { } } + // return true by default + return true; + } + + protected async validateHistory( + user: ReferenceIdInterface, + password?: string, + ): Promise { + // was a history service injected? + if (this.userPasswordHistoryService) { + // get password history for user + const passwordHistory = await this.userPasswordHistoryService.getHistory( + user.id, + ); + + // call password history validation helper + const isValid = await this.passwordCreationService.validateHistory({ + password, + targets: passwordHistory, + }); + + if (!isValid) { + throw new UserException(`Password has been used too recently.`); + } + } + + // return true by default return true; } } diff --git a/packages/nestjs-user/src/user-password-history.factory.ts b/packages/nestjs-user/src/user-password-history.factory.ts new file mode 100644 index 000000000..a199cc21f --- /dev/null +++ b/packages/nestjs-user/src/user-password-history.factory.ts @@ -0,0 +1,26 @@ +import { Factory } from '@concepta/typeorm-seeding'; +import { PasswordStorageService } from '@concepta/nestjs-password'; +import { UserPasswordHistoryEntityInterface } from './interfaces/user-password-history-entity.interface'; + +/** + * User password history factory + */ +export class UserPasswordHistoryFactory extends Factory { + private _passwordStorageService = new PasswordStorageService(); + + /** + * Factory callback function. + */ + protected async entity( + userPasswordHistory: UserPasswordHistoryEntityInterface, + ): Promise { + // generate fake password store + const passwordStore = await this._passwordStorageService.hash('Test1233'); + + userPasswordHistory.passwordHash = passwordStore.passwordHash; + userPasswordHistory.passwordSalt = passwordStore.passwordSalt; + + // return the new user + return userPasswordHistory; + } +} diff --git a/packages/nestjs-user/src/user.constants.ts b/packages/nestjs-user/src/user.constants.ts index 05c9e0df4..f468b1f98 100644 --- a/packages/nestjs-user/src/user.constants.ts +++ b/packages/nestjs-user/src/user.constants.ts @@ -3,3 +3,6 @@ export const USER_MODULE_SETTINGS_TOKEN = 'USER_MODULE_SETTINGS_TOKEN'; export const USER_MODULE_DEFAULT_SETTINGS_TOKEN = 'USER_MODULE_DEFAULT_SETTINGS_TOKEN'; export const USER_MODULE_USER_ENTITY_KEY = 'user'; +export const USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY = + 'user-password-history'; +export const USER_MODULE_USER_PASSWORD_HISTORY_LIMIT_DAYS_DEFAULT = 365 * 2; 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 291d8f347..19591c7b7 100644 --- a/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts +++ b/packages/nestjs-user/src/user.controller.pw.e2e-spec.ts @@ -7,6 +7,7 @@ import { IssueTokenService } from '@concepta/nestjs-authentication'; import { AccessControlService } from '@concepta/nestjs-access-control'; import { PasswordCreationService, + PasswordStorageInterface, PasswordStorageService, PasswordValidationService, } from '@concepta/nestjs-password'; @@ -14,9 +15,13 @@ import { SeedingSource } from '@concepta/typeorm-seeding'; 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 { AppModuleFixture } from './__fixtures__/app.module.fixture'; import { UserEntityFixture } from './__fixtures__/user.entity.fixture'; +import { UserPasswordHistoryEntityFixture } from './__fixtures__/user-password-history.entity.fixture'; describe('User Controller (password e2e)', () => { describe('Password Update Flow', () => { @@ -28,10 +33,13 @@ describe('User Controller (password e2e)', () => { let passwordStorageService: PasswordStorageService; let passwordCreationService: PasswordCreationService; let userLookupService: UserLookupService; + let userPasswordService: UserPasswordService; + let userPasswordHistoryLookupService: UserPasswordHistoryLookupService; let issueTokenService: IssueTokenService; let accessControlService: AccessControlService; const userId = randomUUID(); + const userOldPassword = 'Test1233'; const userPassword = 'Test1234'; const userNewPassword = 'Test6789'; @@ -61,6 +69,10 @@ describe('User Controller (password e2e)', () => { passwordStorageService = app.get(PasswordStorageService); passwordCreationService = app.get(PasswordCreationService); userLookupService = app.get(UserLookupService); + userPasswordService = app.get(UserPasswordService); + userPasswordHistoryLookupService = app.get( + UserPasswordHistoryLookupService, + ); issueTokenService = app.get(IssueTokenService); accessControlService = app.get(AccessControlService); authToken = await issueTokenService.accessToken({ sub: userId }); @@ -77,6 +89,11 @@ describe('User Controller (password e2e)', () => { entity: UserEntityFixture, }); + const userPasswordHistoryFactory = new UserPasswordHistoryFactory({ + seedingSource: seedingSource, + entity: UserPasswordHistoryEntityFixture, + }); + fakeUser = await userFactory.create( await passwordStorageService.hashObject({ id: userId, @@ -84,7 +101,12 @@ describe('User Controller (password e2e)', () => { }), ); - await userFactory.save(fakeUser); + await userPasswordHistoryFactory.create( + await passwordStorageService.hashObject({ + userId: userId, + password: userOldPassword, + }), + ); } afterEach(async () => { @@ -118,6 +140,26 @@ describe('User Controller (password e2e)', () => { } else { fail('User not found'); } + + const userPasswordHistory = + await userPasswordHistoryLookupService.byUserId(updatedUser.id); + expect(userPasswordHistory).toEqual(expect.any(Array)); + expect(userPasswordHistory.length).toEqual(2); + expect(userPasswordHistory).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + passwordHash: expect.any(String), + passwordSalt: expect.any(String), + }), + ]), + ); + expect( + await passwordValidationService.validate({ + password: userNewPassword, + passwordHash: userPasswordHistory[1].passwordHash as string, + passwordSalt: userPasswordHistory[1].passwordSalt as string, + }), + ).toEqual(true); }); it('Should fail to update password', async () => { @@ -144,6 +186,25 @@ describe('User Controller (password e2e)', () => { fail('User not found'); } }); + + it('Should fail to update password (used too recently)', async () => { + await userPasswordService.setPassword( + { password: userNewPassword }, + userId, + ); + + const test = await supertest(app.getHttpServer()) + .patch(`/user/${userId}`) + .set('Authorization', `bearer ${authToken}`) + .send({ + password: userNewPassword, + }) + .expect(400); + + expect(test.body.message).toEqual( + 'The new password has been used too recently, please use a different password', + ); + }); }); describe(`UserPasswordController WITHOUT current password required`, () => { diff --git a/packages/nestjs-user/src/user.controller.ts b/packages/nestjs-user/src/user.controller.ts index 68e8a03ee..9cfd28e28 100644 --- a/packages/nestjs-user/src/user.controller.ts +++ b/packages/nestjs-user/src/user.controller.ts @@ -165,7 +165,11 @@ export class UserController authorizededUser, ); } catch (e) { - throw new BadRequestException(e); + if (e instanceof Error) { + throw new BadRequestException(e.message); + } else { + throw new BadRequestException(); + } } return this.userCrudService.updateOne(crudRequest, hashedObject); diff --git a/packages/nestjs-user/src/user.module-definition.ts b/packages/nestjs-user/src/user.module-definition.ts index 7fd61436d..015660e36 100644 --- a/packages/nestjs-user/src/user.module-definition.ts +++ b/packages/nestjs-user/src/user.module-definition.ts @@ -15,6 +15,7 @@ import { import { USER_MODULE_SETTINGS_TOKEN, USER_MODULE_USER_ENTITY_KEY, + USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY, } from './user.constants'; import { UserOptionsInterface } from './interfaces/user-options.interface'; @@ -22,17 +23,21 @@ import { UserOptionsExtrasInterface } from './interfaces/user-options-extras.int import { UserEntitiesOptionsInterface } from './interfaces/user-entities-options.interface'; import { UserSettingsInterface } from './interfaces/user-settings.interface'; import { UserEntityInterface } from './interfaces/user-entity.interface'; +import { UserLookupServiceInterface } from './interfaces/user-lookup-service.interface'; +import { UserPasswordHistoryEntityInterface } from './interfaces/user-password-history-entity.interface'; import { UserCrudService } from './services/user-crud.service'; import { UserLookupService } from './services/user-lookup.service'; import { UserMutateService } from './services/user-mutate.service'; import { UserPasswordService } from './services/user-password.service'; +import { UserPasswordHistoryService } from './services/user-password-history.service'; +import { UserPasswordHistoryLookupService } from './services/user-password-history-lookup.service'; +import { UserPasswordHistoryMutateService } from './services/user-password-history-mutate.service'; +import { UserAccessQueryService } from './services/user-access-query.service'; 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 { UserLookupServiceInterface } from './interfaces/user-lookup-service.interface'; -import { UserAccessQueryService } from './services/user-access-query.service'; const RAW_OPTIONS_TOKEN = Symbol('__USER_MODULE_RAW_OPTIONS_TOKEN__'); @@ -91,10 +96,14 @@ export function createUserProviders(options: { PasswordCreationService, InvitationAcceptedListener, InvitationGetUserListener, + UserPasswordHistoryMutateService, createUserSettingsProvider(options.overrides), createUserLookupServiceProvider(options.overrides), createUserMutateServiceProvider(options.overrides), createUserPasswordServiceProvider(options.overrides), + createUserPasswordHistoryServiceProvider(options.overrides), + createUserPasswordHistoryLookupServiceProvider(), + createUserPasswordHistoryMutateServiceProvider(), createUserAccessQueryServiceProvider(options.overrides), ]; } @@ -108,6 +117,9 @@ export function createUserExports(): Required< UserMutateService, UserCrudService, UserPasswordService, + UserPasswordHistoryService, + UserPasswordHistoryLookupService, + UserPasswordHistoryMutateService, UserAccessQueryService, ]; } @@ -176,15 +188,140 @@ export function createUserPasswordServiceProvider( ): Provider { return { provide: UserPasswordService, - inject: [RAW_OPTIONS_TOKEN, UserLookupService, PasswordCreationService], + inject: [ + RAW_OPTIONS_TOKEN, + UserLookupService, + PasswordCreationService, + { + token: UserPasswordHistoryService, + optional: true, + }, + ], useFactory: async ( options: UserOptionsInterface, userLookUpService: UserLookupServiceInterface, passwordCreationService: PasswordCreationService, + userPasswordHistoryService?: UserPasswordHistoryService, ) => optionsOverrides?.userPasswordService ?? options.userPasswordService ?? - new UserPasswordService(userLookUpService, passwordCreationService), + new UserPasswordService( + userLookUpService, + passwordCreationService, + userPasswordHistoryService, + ), + }; +} + +export function createUserPasswordHistoryLookupServiceProvider(): Provider { + return { + provide: UserPasswordHistoryLookupService, + inject: [ + USER_MODULE_SETTINGS_TOKEN, + { + token: getDynamicRepositoryToken( + USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY, + ), + optional: true, + }, + ], + useFactory: async ( + settings: UserSettingsInterface, + userPasswordHistoryRepoToken?: Repository, + ) => { + if ( + settings?.passwordHistory?.enabled === true && + userPasswordHistoryRepoToken + ) { + return new UserPasswordHistoryLookupService( + userPasswordHistoryRepoToken, + ); + } + }, + }; +} + +export function createUserPasswordHistoryMutateServiceProvider(): Provider { + return { + provide: UserPasswordHistoryMutateService, + inject: [ + USER_MODULE_SETTINGS_TOKEN, + { + token: getDynamicRepositoryToken( + USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY, + ), + optional: true, + }, + ], + useFactory: async ( + settings: UserSettingsInterface, + userPasswordHistoryRepoToken?: Repository, + ) => { + if ( + settings?.passwordHistory?.enabled === true && + userPasswordHistoryRepoToken + ) { + return new UserPasswordHistoryMutateService( + userPasswordHistoryRepoToken, + ); + } + }, + }; +} + +export function createUserPasswordHistoryServiceProvider( + optionsOverrides?: UserOptions, +): Provider { + return { + provide: UserPasswordHistoryService, + inject: [ + RAW_OPTIONS_TOKEN, + USER_MODULE_SETTINGS_TOKEN, + { + token: getDynamicRepositoryToken( + USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY, + ), + optional: true, + }, + { + token: UserPasswordHistoryLookupService, + optional: true, + }, + { + token: UserPasswordHistoryMutateService, + optional: true, + }, + ], + useFactory: async ( + options: UserOptionsInterface, + settings: UserSettingsInterface, + userPasswordHistoryRepoToken?: Repository, + userPasswordHistoryLookupService?: UserPasswordHistoryLookupService, + userPasswordHistoryMutateService?: UserPasswordHistoryMutateService, + ) => { + // if password history is enabled? + if (settings?.passwordHistory?.enabled === true) { + // look for an overriding service + const overridingServiceOption = + optionsOverrides?.userPasswordHistoryService ?? + options.userPasswordHistoryService; + + // user overriding service, or create default service + if (overridingServiceOption) { + return overridingServiceOption; + } else if ( + userPasswordHistoryRepoToken && + userPasswordHistoryLookupService && + userPasswordHistoryMutateService + ) { + return new UserPasswordHistoryService( + settings, + userPasswordHistoryLookupService, + userPasswordHistoryMutateService, + ); + } + } + }, }; } diff --git a/packages/nestjs-user/src/user.module.spec.ts b/packages/nestjs-user/src/user.module.spec.ts index 39d9f823d..8fd15d0af 100644 --- a/packages/nestjs-user/src/user.module.spec.ts +++ b/packages/nestjs-user/src/user.module.spec.ts @@ -3,13 +3,19 @@ import { Test, TestingModule } from '@nestjs/testing'; import { getDynamicRepositoryToken } from '@concepta/nestjs-typeorm-ext'; import { PasswordCreationService } from '@concepta/nestjs-password'; -import { USER_MODULE_USER_ENTITY_KEY } from './user.constants'; +import { + USER_MODULE_USER_ENTITY_KEY, + USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY, +} from './user.constants'; import { UserModule } from './user.module'; import { UserCrudService } from './services/user-crud.service'; import { UserController } from './user.controller'; import { UserLookupService } from './services/user-lookup.service'; import { UserMutateService } from './services/user-mutate.service'; import { UserPasswordService } from './services/user-password.service'; +import { UserPasswordHistoryService } from './services/user-password-history.service'; +import { UserPasswordHistoryLookupService } from './services/user-password-history-lookup.service'; +import { UserPasswordHistoryMutateService } from './services/user-password-history-mutate.service'; import { UserAccessQueryService } from './services/user-access-query.service'; import { AppModuleFixture } from './__fixtures__/app.module.fixture'; @@ -21,9 +27,13 @@ describe('AppModule', () => { let userMutateService: UserMutateService; let userCrudService: UserCrudService; let userPasswordService: UserPasswordService; + let userPasswordHistoryService: UserPasswordHistoryService; + let userPasswordHistoryLookupService: UserPasswordHistoryLookupService; + let userPasswordHistoryMutateService: UserPasswordHistoryMutateService; let userAccessQueryService: UserAccessQueryService; let userController: UserController; let userRepo: Repository; + let userPasswordHistoryRepo: Repository; beforeEach(async () => { const testModule: TestingModule = await Test.createTestingModule({ @@ -34,10 +44,24 @@ describe('AppModule', () => { userRepo = testModule.get( getDynamicRepositoryToken(USER_MODULE_USER_ENTITY_KEY), ); + userPasswordHistoryRepo = testModule.get( + getDynamicRepositoryToken(USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY), + ); userLookupService = testModule.get(UserLookupService); userMutateService = testModule.get(UserMutateService); userPasswordService = testModule.get(UserPasswordService); + userPasswordHistoryService = testModule.get( + UserPasswordHistoryService, + ); + userPasswordHistoryLookupService = + testModule.get( + UserPasswordHistoryLookupService, + ); + userPasswordHistoryMutateService = + testModule.get( + UserPasswordHistoryMutateService, + ); userAccessQueryService = testModule.get( UserAccessQueryService, ); @@ -53,6 +77,7 @@ describe('AppModule', () => { it('should be loaded', async () => { expect(userModule).toBeInstanceOf(UserModule); expect(userRepo).toBeInstanceOf(Repository); + expect(userPasswordHistoryRepo).toBeInstanceOf(Repository); expect(userCrudService).toBeInstanceOf(UserCrudService); expect(userLookupService).toBeInstanceOf(UserLookupService); expect(userLookupService['repo']).toBeInstanceOf(Repository); @@ -70,6 +95,30 @@ describe('AppModule', () => { expect(userPasswordService['passwordCreationService']).toBeInstanceOf( PasswordCreationService, ); + expect(userPasswordService['userPasswordHistoryService']).toBeInstanceOf( + UserPasswordHistoryService, + ); + expect(userPasswordHistoryService).toBeInstanceOf( + UserPasswordHistoryService, + ); + expect( + userPasswordHistoryService['userPasswordHistoryLookupService'], + ).toBeInstanceOf(UserPasswordHistoryLookupService); + expect( + userPasswordHistoryService['userPasswordHistoryMutateService'], + ).toBeInstanceOf(UserPasswordHistoryMutateService); + expect(userPasswordHistoryLookupService).toBeInstanceOf( + UserPasswordHistoryLookupService, + ); + expect( + userPasswordHistoryLookupService['userPasswordHistoryRepo'], + ).toBeInstanceOf(Repository); + expect(userPasswordHistoryMutateService).toBeInstanceOf( + UserPasswordHistoryMutateService, + ); + expect( + userPasswordHistoryMutateService['userPasswordHistoryRepo'], + ).toBeInstanceOf(Repository); expect(userAccessQueryService).toBeInstanceOf(UserAccessQueryService); expect(userController).toBeInstanceOf(UserController); }); diff --git a/packages/ts-common/src/index.ts b/packages/ts-common/src/index.ts index d65ceb3fb..331b8b769 100644 --- a/packages/ts-common/src/index.ts +++ b/packages/ts-common/src/index.ts @@ -22,6 +22,7 @@ export { OrgMemberInterface } from './org/interfaces/org-member.interface'; export { UserInterface } from './user/interfaces/user.interface'; export { UserCreatableInterface } from './user/interfaces/user-creatable.interface'; export { UserUpdatableInterface } from './user/interfaces/user-updatable.interface'; +export { UserOwnableInterface } from './user/interfaces/user-ownable.interface'; export { FederatedInterface } from './federated/interfaces/federated.interface'; export { FederatedCreatableInterface } from './federated/interfaces/federated-creatable.interface'; diff --git a/packages/ts-common/src/user/interfaces/user-ownable.interface.ts b/packages/ts-common/src/user/interfaces/user-ownable.interface.ts new file mode 100644 index 000000000..c1e080f81 --- /dev/null +++ b/packages/ts-common/src/user/interfaces/user-ownable.interface.ts @@ -0,0 +1,7 @@ +import { ReferenceId } from '@concepta/ts-core'; +import { UserInterface } from './user.interface'; + +export interface UserOwnableInterface { + userId: ReferenceId; + user?: UserInterface; +} diff --git a/packages/typeorm-common/src/services/base.service.ts b/packages/typeorm-common/src/services/base.service.ts index 5cd2c1ef4..125f35c63 100644 --- a/packages/typeorm-common/src/services/base.service.ts +++ b/packages/typeorm-common/src/services/base.service.ts @@ -1,4 +1,4 @@ -import { FindOneOptions, Repository } from 'typeorm'; +import { FindManyOptions, FindOneOptions, Repository } from 'typeorm'; import { ReferenceIdInterface } from '@concepta/ts-core'; import { QueryOptionsInterface } from '../interfaces/query-options.interface'; @@ -23,19 +23,38 @@ export abstract class BaseService { this.repositoryProxy = new RepositoryProxy(repo); } + /** + * Find wrapper. + * + * @param options - Find many options + * @param queryOptions - Query options + */ + async find( + options: FindManyOptions, + queryOptions?: QueryOptionsInterface, + ): Promise> { + try { + // call the repo find + return this.repository(queryOptions).find(options); + } catch (e) { + // fatal orm error + throw new ReferenceLookupException(this.metadata.name, e); + } + } + /** * Find One wrapper. * - * @param findOneOptions - Find options + * @param options - Find one options * @param queryOptions - Query options */ async findOne( - findOneOptions: FindOneOptions, + options: FindOneOptions, queryOptions?: QueryOptionsInterface, ): Promise { try { // call the repo find one - return this.repository(queryOptions).findOne(findOneOptions); + return this.repository(queryOptions).findOne(options); } catch (e) { // fatal orm error throw new ReferenceLookupException(this.metadata.name, e);