Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Feature/user password history #173

Merged
merged 12 commits into from
Jul 19, 2024
1 change: 1 addition & 0 deletions packages/nestjs-password/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -31,6 +32,16 @@ export interface PasswordCreationServiceInterface {
options: Partial<PasswordCurrentPasswordInterface>,
) => Promise<boolean>;

/**
* 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<PasswordHistoryPasswordInterface>,
) => Promise<boolean>;

/**
* Check if attempt is valid.
*
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { PasswordPlainInterface } from '@concepta/ts-common';
import { PasswordStorageInterface } from './password-storage.interface';

export interface PasswordHistoryPasswordInterface
extends PasswordPlainInterface {
targets: PasswordStorageInterface[];
}
36 changes: 36 additions & 0 deletions packages/nestjs-password/src/services/password-creation.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -110,6 +111,41 @@ export class PasswordCreationService
return true;
}

public async validateHistory(
options: Partial<PasswordHistoryPasswordInterface>,
): Promise<boolean> {
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
Expand Down
Original file line number Diff line number Diff line change
@@ -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'
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,11 @@ rules
inject: [UserLookupCustomService],
useFactory: async (userLookupService: UserLookupCustomService) => ({
userLookupService,
settings: {
passwordHistory: {
enabled: true,
},
},
}),
entities: {
user: {
Expand Down
8 changes: 8 additions & 0 deletions packages/nestjs-user/src/__fixtures__/app.module.fixture.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -50,11 +51,18 @@ rules
settings: {
invitationRequestEvent: InvitationAcceptedEventAsync,
invitationGetUserEvent: InvitationGetUserEventAsync,
passwordHistory: {
enabled: true,
limitDays: 99,
},
},
entities: {
user: {
entity: UserEntityFixture,
},
'user-password-history': {
entity: UserPasswordHistoryEntityFixture,
},
},
}),
],
Expand Down
3 changes: 2 additions & 1 deletion packages/nestjs-user/src/__fixtures__/ormconfig.fixture.ts
Original file line number Diff line number Diff line change
@@ -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],
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { Entity } from 'typeorm';
import { UserPasswordHistorySqliteEntity } from '../entities/user-password-history-sqlite.entity';

@Entity()
export class UserPasswordHistoryEntityFixture extends UserPasswordHistorySqliteEntity {}
22 changes: 20 additions & 2 deletions packages/nestjs-user/src/config/user-default.config.ts
Original file line number Diff line number Diff line change
@@ -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,
},
};
},
);
16 changes: 16 additions & 0 deletions packages/nestjs-user/src/dto/user-password-history-create.dto.ts
Original file line number Diff line number Diff line change
@@ -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 {}
49 changes: 49 additions & 0 deletions packages/nestjs-user/src/dto/user-password-history.dto.ts
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
3 changes: 3 additions & 0 deletions packages/nestjs-user/src/entities/user-postgres.entity.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -38,4 +39,6 @@ export abstract class UserPostgresEntity
*/
@Column({ type: 'text', nullable: true, default: null })
passwordSalt: string | null = null;

userPasswordHistory?: UserPasswordHistoryEntityInterface;
}
3 changes: 3 additions & 0 deletions packages/nestjs-user/src/entities/user-sqlite.entity.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -35,4 +36,6 @@ export abstract class UserSqliteEntity
*/
@Column({ type: 'text', nullable: true, default: null })
passwordSalt: string | null = null;

userPasswordHistory?: UserPasswordHistoryEntityInterface;
}
Original file line number Diff line number Diff line change
@@ -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<UserEntityInterface>;
[USER_MODULE_USER_PASSWORD_HISTORY_ENTITY_KEY]?: TypeOrmExtEntityOptionInterface<UserPasswordHistoryEntityInterface>;
};
}
2 changes: 2 additions & 0 deletions packages/nestjs-user/src/interfaces/user-options.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Loading
Loading