From 378df55280c46f747b45c95cf2e977195bedde39 Mon Sep 17 00:00:00 2001 From: Marshall Sorenson Date: Thu, 7 Dec 2023 11:53:56 -0500 Subject: [PATCH] feat(password): more password module improvements --- packages/nestjs-auth-github/package.json | 1 + .../src/auth-github.module.spec.ts | 2 + .../auth-local-validate-user.service.ts | 8 +- packages/nestjs-auth-recovery/package.json | 1 + .../src/__fixtures__/app.module.db.fixture.ts | 2 + .../src/__fixtures__/app.module.fixture.ts | 2 +- packages/nestjs-org/package.json | 1 + .../invitation-accepted-listener.spec.ts | 2 + .../config/password-default.config.spec.ts | 6 +- .../src/config/password-default.config.ts | 4 +- .../password-creation-service.interface.ts | 27 +++- .../password-storage-service.interface.ts | 31 ++-- .../password-validation-service.interface.ts | 13 +- .../password-creation.service.spec.ts | 152 ++++++++++++++---- .../src/services/password-creation.service.ts | 109 ++++++++++++- .../services/password-storage.service.spec.ts | 77 +++++---- .../src/services/password-storage.service.ts | 67 +++++--- .../password-validation.service.spec.ts | 30 ++-- .../services/password-validation.service.ts | 29 ++-- .../src/06-typeorm-ext/app.module.ts | 2 + .../__fixtures__/app.module.custom.fixture.ts | 2 + .../src/__fixtures__/app.module.fixture.ts | 2 + .../src/services/user-mutate.service.ts | 10 +- packages/nestjs-user/src/user.controller.ts | 18 ++- .../nestjs-user/src/user.module-definition.ts | 16 +- 25 files changed, 457 insertions(+), 157 deletions(-) diff --git a/packages/nestjs-auth-github/package.json b/packages/nestjs-auth-github/package.json index be608c9d7..199e335f5 100644 --- a/packages/nestjs-auth-github/package.json +++ b/packages/nestjs-auth-github/package.json @@ -28,6 +28,7 @@ "devDependencies": { "@concepta/nestjs-crud": "^4.0.0-alpha.35", "@concepta/nestjs-jwt": "^4.0.0-alpha.35", + "@concepta/nestjs-password": "^4.0.0-alpha.35", "@concepta/nestjs-typeorm-ext": "^4.0.0-alpha.35", "@concepta/nestjs-user": "^4.0.0-alpha.35", "@nestjs/testing": "^9.0.0", diff --git a/packages/nestjs-auth-github/src/auth-github.module.spec.ts b/packages/nestjs-auth-github/src/auth-github.module.spec.ts index e7bc820e6..b92ed7037 100644 --- a/packages/nestjs-auth-github/src/auth-github.module.spec.ts +++ b/packages/nestjs-auth-github/src/auth-github.module.spec.ts @@ -8,6 +8,7 @@ import { UserLookupService, UserMutateService, } from '@concepta/nestjs-user'; +import { PasswordModule } from '@concepta/nestjs-password'; import { FederatedModule } from '@concepta/nestjs-federated'; import { AuthGithubController } from './auth-github.controller'; import { AuthGithubModule } from './auth-github.module'; @@ -44,6 +45,7 @@ describe(AuthGithubModule, () => { }, }), CrudModule.forRoot({}), + PasswordModule.forRoot({}), UserModule.forRoot({ entities: { user: { diff --git a/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.ts b/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.ts index 865d55b56..e53cfe152 100644 --- a/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.ts +++ b/packages/nestjs-auth-local/src/services/auth-local-validate-user.service.ts @@ -46,10 +46,10 @@ export class AuthLocalValidateUserService } // validate password - const isValid = await this.passwordValidationService.validateObject({ - passwordPlain: dto.password, - object: user, - }); + const isValid = await this.passwordValidationService.validateObject( + dto.password, + user, + ); // password is valid? if (!isValid) { diff --git a/packages/nestjs-auth-recovery/package.json b/packages/nestjs-auth-recovery/package.json index f54f56a0f..3e73166f9 100644 --- a/packages/nestjs-auth-recovery/package.json +++ b/packages/nestjs-auth-recovery/package.json @@ -25,6 +25,7 @@ "@concepta/nestjs-crud": "^4.0.0-alpha.35", "@concepta/nestjs-email": "^4.0.0-alpha.35", "@concepta/nestjs-otp": "^4.0.0-alpha.35", + "@concepta/nestjs-password": "^4.0.0-alpha.35", "@concepta/nestjs-typeorm-ext": "^4.0.0-alpha.35", "@concepta/nestjs-user": "^4.0.0-alpha.35", "@concepta/typeorm-seeding": "^4.0.0-beta.0", diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/app.module.db.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/app.module.db.fixture.ts index 2e86df449..7825ee3a3 100644 --- a/packages/nestjs-auth-recovery/src/__fixtures__/app.module.db.fixture.ts +++ b/packages/nestjs-auth-recovery/src/__fixtures__/app.module.db.fixture.ts @@ -16,6 +16,7 @@ import { UserEntityFixture } from './user/entities/user-entity.fixture'; import { default as ormConfig } from './ormconfig.fixture'; import { MailerServiceFixture } from './email/mailer.service.fixture'; +import { PasswordModule } from '@concepta/nestjs-password'; @Module({ imports: [ @@ -42,6 +43,7 @@ import { MailerServiceFixture } from './email/mailer.service.fixture'; }, }, }), + PasswordModule.forRoot({}), UserModule.forRoot({ entities: { user: { diff --git a/packages/nestjs-invitation/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/app.module.fixture.ts index ccf731b06..85c3482a7 100644 --- a/packages/nestjs-invitation/src/__fixtures__/app.module.fixture.ts +++ b/packages/nestjs-invitation/src/__fixtures__/app.module.fixture.ts @@ -24,7 +24,6 @@ import { default as ormConfig } from './ormconfig.fixture'; @Module({ imports: [ EventModule.forRoot({}), - PasswordModule.forRoot({}), TypeOrmExtModule.forRoot(ormConfig), CrudModule.forRoot({}), MailerModule.forRoot({ transport: { host: '' } }), @@ -58,6 +57,7 @@ import { default as ormConfig } from './ormconfig.fixture'; }, }, }), + PasswordModule.forRoot({}), UserModule.forRoot({ settings: { invitationRequestEvent: InvitationAcceptedEventAsync, diff --git a/packages/nestjs-org/package.json b/packages/nestjs-org/package.json index e7912e04a..2c7a302cf 100644 --- a/packages/nestjs-org/package.json +++ b/packages/nestjs-org/package.json @@ -26,6 +26,7 @@ }, "devDependencies": { "@concepta/nestjs-invitation": "^4.0.0-alpha.35", + "@concepta/nestjs-password": "^4.0.0-alpha.35", "@concepta/nestjs-user": "^4.0.0-alpha.35", "@concepta/typeorm-seeding": "^4.0.0-beta.0", "@faker-js/faker": "^6.0.0-alpha.6", diff --git a/packages/nestjs-org/src/listeners/invitation-accepted-listener.spec.ts b/packages/nestjs-org/src/listeners/invitation-accepted-listener.spec.ts index cda2d3658..2d973240a 100644 --- a/packages/nestjs-org/src/listeners/invitation-accepted-listener.spec.ts +++ b/packages/nestjs-org/src/listeners/invitation-accepted-listener.spec.ts @@ -24,6 +24,7 @@ import { InvitationEntityFixture } from '../__fixtures__/invitation.entity.fixtu import { OrgFactory } from '../seeding/org.factory'; import { OrgEntityInterface } from '../interfaces/org-entity.interface'; import { OwnerFactoryFixture } from '../__fixtures__/owner-factory.fixture'; +import { PasswordModule } from '@concepta/nestjs-password'; describe(InvitationAcceptedListener, () => { const category = INVITATION_MODULE_CATEGORY_ORG_KEY; @@ -51,6 +52,7 @@ describe(InvitationAcceptedListener, () => { InvitationEntityFixture, ], }), + PasswordModule.forRoot({}), UserModule.forRoot({ entities: { user: { diff --git a/packages/nestjs-password/src/config/password-default.config.spec.ts b/packages/nestjs-password/src/config/password-default.config.spec.ts index 45fd7f09d..fd6698bfc 100644 --- a/packages/nestjs-password/src/config/password-default.config.spec.ts +++ b/packages/nestjs-password/src/config/password-default.config.spec.ts @@ -30,7 +30,7 @@ describe('password configuration', () => { expect(config).toMatchObject({ maxPasswordAttempts: 3, - minPasswordStrength: 8, + minPasswordStrength: 0, }); }); @@ -39,7 +39,7 @@ describe('password configuration', () => { const config = await passwordDefaultConfig(); expect(config.maxPasswordAttempts).toBe(3); - expect(config.minPasswordStrength).toBe(8); + expect(config.minPasswordStrength).toBe(0); }); it('configProcessNotNull', async () => { @@ -92,7 +92,7 @@ describe('password configuration', () => { expect(config).toMatchObject({ maxPasswordAttempts: 3, - minPasswordStrength: 8, + minPasswordStrength: 0, }); }); }); diff --git a/packages/nestjs-password/src/config/password-default.config.ts b/packages/nestjs-password/src/config/password-default.config.ts index bf403f1ff..f8366f93b 100644 --- a/packages/nestjs-password/src/config/password-default.config.ts +++ b/packages/nestjs-password/src/config/password-default.config.ts @@ -17,6 +17,8 @@ export const passwordDefaultConfig = registerAs( minPasswordStrength: process.env.PASSWORD_MIN_PASSWORD_STRENGTH ? Number.parseInt(process.env.PASSWORD_MIN_PASSWORD_STRENGTH) - : 8, + : process.env?.NODE_ENV === 'production' + ? 8 + : 0, }), ); 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 833089093..d8cdaff28 100644 --- a/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-creation-service.interface.ts @@ -1,13 +1,32 @@ +import { PasswordPlainInterface } from '@concepta/ts-common'; +import { PasswordStorageInterface } from './password-storage.interface'; + /** * Password Creation Service Interface */ export interface PasswordCreationServiceInterface { /** - * Check if password is strong - * @param password - * @returns + * Create password for an object (optionally). + * + * @param object An object containing the new password to hash. + * @param options.salt Optional salt. If not provided, one will be generated. + * @param options.required Set to true if password is required. + * @param options.currentPassword Optional current password object to validate. + * @returns A new object with the password hashed, with salt added. */ - isStrong(password: string): boolean; + createObject( + object: T, + options?: { + salt?: string; + required?: boolean; + currentPassword?: { + password: string; + object: PasswordStorageInterface; + }; + }, + ): Promise< + Omit | (Omit & PasswordStorageInterface) + >; /** * Check if attempt is valid diff --git a/packages/nestjs-password/src/interfaces/password-storage-service.interface.ts b/packages/nestjs-password/src/interfaces/password-storage-service.interface.ts index bff620444..a9ea4bfae 100644 --- a/packages/nestjs-password/src/interfaces/password-storage-service.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-storage-service.interface.ts @@ -14,33 +14,30 @@ export interface PasswordStorageServiceInterface { * Hash a password using a salt, if no * was passed, then generate one automatically. * - * @param password Password to be hashed - * @param salt Optional salt. If not provided, one will be generated. + * @param options.password Password to be hashed + * @param options.salt Optional salt. If not provided, one will be generated. */ - hash(password: string, salt?: string): Promise; + hash( + password: string, + options?: { + salt?: string; + }, + ): Promise; /** * Hash password for an object. * * @param object An object containing the new password to hash. - * @param salt Optional salt. If not provided, one will be generated. + * @param options.salt Optional salt. If not provided, one will be generated. + * @param options.required Set to true if password is required. * @returns A new object with the password hashed, with salt added. */ hashObject( object: T, - salt?: string, - ): Promise & PasswordStorageInterface>; - - /** - * Hash password for an object if password property exists. - * - * @param object An object containing the new password to hash. - * @param salt Optional salt. If not provided, one will be generated. - * @returns A new object with the password hashed, with salt added. - */ - hashObjectOptional>( - object: T, - salt?: string, + options?: { + salt?: string; + required?: boolean; + }, ): Promise< Omit | (Omit & PasswordStorageInterface) >; diff --git a/packages/nestjs-password/src/interfaces/password-validation-service.interface.ts b/packages/nestjs-password/src/interfaces/password-validation-service.interface.ts index 44eac477b..34ec69aa9 100644 --- a/packages/nestjs-password/src/interfaces/password-validation-service.interface.ts +++ b/packages/nestjs-password/src/interfaces/password-validation-service.interface.ts @@ -7,12 +7,12 @@ export interface PasswordValidationServiceInterface { /** * Validate if password matches and its valid. * - * @param options.passwordPlain Plain text password + * @param options.password Plain text password * @param options.passwordHash Password hashed * @param options.passwordSalt salt to be used on plain password to see it match */ validate(options: { - passwordPlain: string; + password: string; passwordHash: string; passwordSalt: string; }): Promise; @@ -23,8 +23,9 @@ export interface PasswordValidationServiceInterface { * @param options.passwordPlain Plain text password * @param options.object The object on which the password and salt are stored */ - validateObject(options: { - passwordPlain: string; - object: T; - }): Promise; + validateObject( + passwordPlain: string, + + object: T, + ): Promise; } diff --git a/packages/nestjs-password/src/services/password-creation.service.spec.ts b/packages/nestjs-password/src/services/password-creation.service.spec.ts index 9a596bceb..fec38df1b 100644 --- a/packages/nestjs-password/src/services/password-creation.service.spec.ts +++ b/packages/nestjs-password/src/services/password-creation.service.spec.ts @@ -1,76 +1,168 @@ -import { mock } from 'jest-mock-extended'; +import { PasswordPlainInterface } from '@concepta/ts-common'; import { PasswordStrengthEnum } from '../enum/password-strength.enum'; import { PasswordSettingsInterface } from '../interfaces/password-settings.interface'; +import { PasswordStorageInterface } from '../interfaces/password-storage.interface'; import { PasswordCreationService } from './password-creation.service'; +import { PasswordStorageService } from './password-storage.service'; import { PasswordStrengthService } from './password-strength.service'; +import { PasswordValidationService } from './password-validation.service'; -describe('PasswordCreationService', () => { - const PASSWORD_MEDIUM = 'AS12378'; - - let service: PasswordCreationService; +describe(PasswordCreationService, () => { + let passwordCreationService: PasswordCreationService; + let passwordStorageService: PasswordStorageService; + let passwordValidationService: PasswordValidationService; let passwordStrengthService: PasswordStrengthService; - let spyIsStrong: jest.SpyInstance; - const config = { + const config: PasswordSettingsInterface = { maxPasswordAttempts: 5, - minPasswordStrength: PasswordStrengthEnum.Strong, - } as PasswordSettingsInterface; + minPasswordStrength: PasswordStrengthEnum.Medium, + }; - beforeEach(async () => { - passwordStrengthService = mock(); - spyIsStrong = jest.spyOn(passwordStrengthService, 'isStrong'); + const PASSWORD_WEAK = 'secret'; + const PASSWORD_MEDIUM = 'F*h#1d*fQ@XB'; - service = new PasswordCreationService(passwordStrengthService, config); + beforeEach(async () => { + passwordStorageService = new PasswordStorageService(); + passwordValidationService = new PasswordValidationService(); + passwordStrengthService = new PasswordStrengthService(config); + + passwordCreationService = new PasswordCreationService( + config, + passwordStorageService, + passwordValidationService, + passwordStrengthService, + ); }); it('should be defined', () => { - expect(service).toBeDefined(); + expect(passwordCreationService).toBeDefined(); }); - it('PasswordCreationService.isStrong', async () => { - await service.isStrong(PASSWORD_MEDIUM); - - expect(spyIsStrong).toBeCalled(); + describe(PasswordCreationService.prototype.createObject, () => { + it('should NOT create a password on object WITHOUT password being provided', async () => { + type TestIn = Partial & { foo: string }; + type TestOut = Partial & { foo: string }; + + // encrypt password + const passwordStorageObject: TestOut = + await passwordCreationService.createObject({ foo: 'bar' }); + + expect(passwordStorageObject).toEqual({ foo: 'bar' }); + }); + + it('should create a password on object WITHOUT current password requirement', async () => { + // encrypt password + const passwordStorageObject: PasswordStorageInterface = + await passwordCreationService.createObject({ + password: PASSWORD_MEDIUM, + }); + + expect(typeof passwordStorageObject.passwordHash).toEqual('string'); + expect(typeof passwordStorageObject.passwordSalt).toEqual('string'); + }); + + it('should create a password on object WITH a VALID current password requirement', async () => { + // encrypt "current" password + const passwordStorageObjectCurrent: PasswordStorageInterface = + await passwordStorageService.hashObject({ + password: 'current-password-string', + }); + + const passwordStorageObject: PasswordStorageInterface = + await passwordCreationService.createObject( + { + password: PASSWORD_MEDIUM, + }, + { + currentPassword: { + password: 'current-password-string', + object: passwordStorageObjectCurrent, + }, + }, + ); + + expect(typeof passwordStorageObject.passwordHash).toEqual('string'); + expect(typeof passwordStorageObject.passwordSalt).toEqual('string'); + }); + + it('should NOT create a password on object WITH an INVALID current password requirement', async () => { + const t = async () => { + // encrypt "current" password + const passwordStorageObjectCurrent: PasswordStorageInterface = + await passwordStorageService.hashObject({ + password: 'current-password-string', + }); + + await passwordCreationService.createObject( + { + password: PASSWORD_MEDIUM, + }, + { + currentPassword: { + password: 'bad-current-password-string', + object: passwordStorageObjectCurrent, + }, + }, + ); + }; + + await expect(t).rejects.toThrow(Error); + await expect(t).rejects.toThrow( + 'Current password that was supplied is not valid', + ); + }); + + it('should NOT create a password on object WITH a WEAK password', async () => { + const t = async () => { + // try to create on object with a weak password + await passwordCreationService.createObject({ + password: PASSWORD_WEAK, + }); + }; + + await expect(t).rejects.toThrow(Error); + await expect(t).rejects.toThrow('Password is not strong enough'); + }); }); it('PasswordCreationService.checkAttempt', () => { - let canAttemptOneMore = service.checkAttempt(0); + let canAttemptOneMore = passwordCreationService.checkAttempt(0); expect(canAttemptOneMore).toBe(true); - canAttemptOneMore = service.checkAttempt(); + canAttemptOneMore = passwordCreationService.checkAttempt(); expect(canAttemptOneMore).toBe(true); - canAttemptOneMore = service.checkAttempt(1); + canAttemptOneMore = passwordCreationService.checkAttempt(1); expect(canAttemptOneMore).toBe(true); - canAttemptOneMore = service.checkAttempt(2); + canAttemptOneMore = passwordCreationService.checkAttempt(2); expect(canAttemptOneMore).toBe(true); - canAttemptOneMore = service.checkAttempt(5); + canAttemptOneMore = passwordCreationService.checkAttempt(5); expect(canAttemptOneMore).toBe(true); - canAttemptOneMore = service.checkAttempt(6); + canAttemptOneMore = passwordCreationService.checkAttempt(6); expect(canAttemptOneMore).toBe(false); }); it('PasswordCreationService.checkAttempt', () => { - let attemptsLeft = service.checkAttemptLeft(1); + let attemptsLeft = passwordCreationService.checkAttemptLeft(1); expect(attemptsLeft).toBe(4); - attemptsLeft = service.checkAttemptLeft(0); + attemptsLeft = passwordCreationService.checkAttemptLeft(0); expect(attemptsLeft).toBe(5); - attemptsLeft = service.checkAttemptLeft(); + attemptsLeft = passwordCreationService.checkAttemptLeft(); expect(attemptsLeft).toBe(5); - attemptsLeft = service.checkAttemptLeft(2); + attemptsLeft = passwordCreationService.checkAttemptLeft(2); expect(attemptsLeft).toBe(3); - attemptsLeft = service.checkAttemptLeft(5); + attemptsLeft = passwordCreationService.checkAttemptLeft(5); expect(attemptsLeft).toBe(0); - attemptsLeft = service.checkAttemptLeft(6); + attemptsLeft = passwordCreationService.checkAttemptLeft(6); expect(attemptsLeft).toBe(-1); }); }); diff --git a/packages/nestjs-password/src/services/password-creation.service.ts b/packages/nestjs-password/src/services/password-creation.service.ts index dd08bd9ec..ca49748e3 100644 --- a/packages/nestjs-password/src/services/password-creation.service.ts +++ b/packages/nestjs-password/src/services/password-creation.service.ts @@ -4,6 +4,10 @@ import { PASSWORD_MODULE_SETTINGS_TOKEN } from '../password.constants'; import { PasswordSettingsInterface } from '../interfaces/password-settings.interface'; import { PasswordCreationServiceInterface } from '../interfaces/password-creation-service.interface'; import { PasswordStrengthService } from './password-strength.service'; +import { PasswordPlainInterface } from '@concepta/ts-common'; +import { PasswordStorageInterface } from '../interfaces/password-storage.interface'; +import { PasswordStorageService } from './password-storage.service'; +import { PasswordValidationService } from './password-validation.service'; /** * Service with functions related to password creation @@ -18,18 +22,111 @@ export class PasswordCreationService * Constructor */ constructor( - private passwordStrengthService: PasswordStrengthService, @Inject(PASSWORD_MODULE_SETTINGS_TOKEN) private settings: PasswordSettingsInterface, + private passwordStorageService: PasswordStorageService, + private passwordValidationService: PasswordValidationService, + private passwordStrengthService: PasswordStrengthService, ) {} /** - * Check if password is strong - * @param password - * @returns + * Create password for an object. + * + * @param object An object containing the new password to hash. + * @param options.salt Optional salt. If not provided, one will be generated. + * @param options.required Set to true if password is required. + * @param options.currentPassword Optional current password object to validate. + * @returns A new object with the password hashed, with salt added. + */ + async createObject( + object: T, + options?: { + salt?: string; + required?: boolean; + currentPassword?: { + password: string; + object: PasswordStorageInterface; + }; + }, + ): Promise & PasswordStorageInterface>; + + /** + * Create password for an object. + * + * @param object An object containing the new password to hash. + * @param options.salt Optional salt. If not provided, one will be generated. + * @param options.required Set to true if password is required. + * @param options.currentPassword Optional current password object to validate. + * @returns A new object with the password hashed, with salt added. + */ + async createObject>( + object: Partial, + options?: { + salt?: string; + required?: boolean; + currentPassword?: { + password: string; + object: PasswordStorageInterface; + }; + }, + ): Promise< + | Omit + | (Omit & Partial) + >; + + /** + * Create password for an object. + * + * @param object An object containing the new password to hash. + * @param options.salt Optional salt. If not provided, one will be generated. + * @param options.required Set to true if password is required. + * @param options.currentPassword Optional current password object to validate. + * @returns A new object with the password hashed, with salt added. */ - isStrong(password: string): boolean { - return this.passwordStrengthService.isStrong(password); + async createObject( + object: T, + options?: { + salt?: string; + required?: boolean; + currentPassword?: { + password: string; + object: PasswordStorageInterface; + }; + }, + ): Promise< + Omit | (Omit & PasswordStorageInterface) + > { + // extract properties + const { password } = object; + const { currentPassword } = options ?? {}; + + // is the password in the object? + if (typeof password === 'string') { + // maybe check current password + if (currentPassword) { + // call validation service + const currentPasswordIsValid = + await this.passwordValidationService.validateObject( + currentPassword.password, + currentPassword.object, + ); + + // is it valid? + if (!currentPasswordIsValid) { + // current password they supplied is not valid + throw new Error('Current password that was supplied is not valid'); + } + } + + // check strength + if (!this.passwordStrengthService.isStrong(password)) { + throw new Error('Password is not strong enough'); + } + + return this.passwordStorageService.hashObject(object, options); + } + + return object; } /** diff --git a/packages/nestjs-password/src/services/password-storage.service.spec.ts b/packages/nestjs-password/src/services/password-storage.service.spec.ts index e180e3f2e..89bccd129 100644 --- a/packages/nestjs-password/src/services/password-storage.service.spec.ts +++ b/packages/nestjs-password/src/services/password-storage.service.spec.ts @@ -3,7 +3,7 @@ import { PasswordStorageInterface } from '../interfaces/password-storage.interfa import { PasswordStorageService } from './password-storage.service'; import { PasswordValidationService } from './password-validation.service'; -describe('PasswordStorageService', () => { +describe(PasswordStorageService, () => { let storageService: PasswordStorageService; let validationService: PasswordValidationService; @@ -39,7 +39,7 @@ describe('PasswordStorageService', () => { // check if password encrypt can be decrypted const isValid = await validationService.validate({ - passwordPlain: PASSWORD_MEDIUM, + password: PASSWORD_MEDIUM, passwordHash: passwordStorageObject.passwordHash ?? '', passwordSalt: passwordStorageObject.passwordSalt ?? '', }); @@ -50,13 +50,15 @@ describe('PasswordStorageService', () => { it('should generate a password hash from provided salt', async () => { // encrypt password const passwordStorageObject: PasswordStorageInterface = - await storageService.hash(PASSWORD_MEDIUM, PASSWORD_SALT); + await storageService.hash(PASSWORD_MEDIUM, { + salt: PASSWORD_SALT, + }); expect(passwordStorageObject.passwordSalt).toEqual(PASSWORD_SALT); // check if password encrypt can be decrypted const isValid = await validationService.validate({ - passwordPlain: PASSWORD_MEDIUM, + password: PASSWORD_MEDIUM, passwordHash: passwordStorageObject.passwordHash ?? '', passwordSalt: passwordStorageObject.passwordSalt ?? '', }); @@ -69,15 +71,20 @@ describe('PasswordStorageService', () => { it('should generate a password on object without providing a salt', async () => { // encrypt password const passwordStorageObject: PasswordStorageInterface = - await storageService.hashObject({ password: PASSWORD_MEDIUM }); + await storageService.hashObject( + { password: PASSWORD_MEDIUM }, + { + required: true, + }, + ); expect(typeof passwordStorageObject.passwordSalt).toEqual('string'); // check if password encrypt can be decrypted - const isValid = await validationService.validateObject({ - passwordPlain: PASSWORD_MEDIUM, - object: passwordStorageObject, - }); + const isValid = await validationService.validateObject( + PASSWORD_MEDIUM, + passwordStorageObject, + ); expect(isValid).toEqual(true); }); @@ -89,26 +96,26 @@ describe('PasswordStorageService', () => { { password: PASSWORD_MEDIUM, }, - PASSWORD_SALT, + { + salt: PASSWORD_SALT, + }, ); expect(passwordStorageObject.passwordSalt).toEqual(PASSWORD_SALT); // check if password encrypt can be decrypted - const isValid = await validationService.validateObject({ - passwordPlain: PASSWORD_MEDIUM, - object: passwordStorageObject, - }); + const isValid = await validationService.validateObject( + PASSWORD_MEDIUM, + passwordStorageObject, + ); expect(isValid).toEqual(true); }); - }); - describe(PasswordStorageService.prototype.hashObjectOptional, () => { it('should generate a password on object without providing a salt', async () => { // encrypt password const passwordStorageObject: Partial = - await storageService.hashObjectOptional({ password: PASSWORD_MEDIUM }); + await storageService.hashObject({ password: PASSWORD_MEDIUM }); expect(typeof passwordStorageObject.passwordSalt).toEqual('string'); @@ -117,10 +124,10 @@ describe('PasswordStorageService', () => { typeof passwordStorageObject.passwordSalt === 'string' ) { // check if password encrypt can be decrypted - const isValid = await validationService.validateObject({ - passwordPlain: PASSWORD_MEDIUM, - object: passwordStorageObject as PasswordStorageInterface, - }); + const isValid = await validationService.validateObject( + PASSWORD_MEDIUM, + passwordStorageObject as PasswordStorageInterface, + ); expect(isValid).toEqual(true); } else { @@ -131,9 +138,11 @@ describe('PasswordStorageService', () => { it('should generate a password on object with provided salt', async () => { // encrypt password const passwordStorageObject: Partial = - await storageService.hashObjectOptional( + await storageService.hashObject( { password: PASSWORD_MEDIUM }, - PASSWORD_SALT, + { + salt: PASSWORD_SALT, + }, ); expect(passwordStorageObject.passwordSalt).toEqual(PASSWORD_SALT); @@ -143,10 +152,10 @@ describe('PasswordStorageService', () => { typeof passwordStorageObject.passwordSalt === 'string' ) { // check if password encrypt can be decrypted - const isValid = await validationService.validateObject({ - passwordPlain: PASSWORD_MEDIUM, - object: passwordStorageObject as PasswordStorageInterface, - }); + const isValid = await validationService.validateObject( + PASSWORD_MEDIUM, + passwordStorageObject as PasswordStorageInterface, + ); expect(isValid).toEqual(true); } else { @@ -157,10 +166,22 @@ describe('PasswordStorageService', () => { it('should NOT generate a password on object (non provided)', async () => { // encrypt password const passwordStorageObject: Partial = - await storageService.hashObjectOptional({}); + await storageService.hashObject({}, { required: false }); expect(typeof passwordStorageObject.passwordHash).toEqual('undefined'); expect(typeof passwordStorageObject.passwordSalt).toEqual('undefined'); }); + + it('should FAIL to generate a password on object (non provided, but required)', async () => { + const t = async () => { + // encrypt password + await storageService.hashObject({}, { required: true }); + }; + + expect(t).rejects.toThrow(Error); + expect(t).rejects.toThrow( + 'Password is required for hashing, but non was provided.', + ); + }); }); }); diff --git a/packages/nestjs-password/src/services/password-storage.service.ts b/packages/nestjs-password/src/services/password-storage.service.ts index df78263f9..0c31941cf 100644 --- a/packages/nestjs-password/src/services/password-storage.service.ts +++ b/packages/nestjs-password/src/services/password-storage.service.ts @@ -25,8 +25,11 @@ export class PasswordStorageService implements PasswordStorageServiceInterface { */ async hash( password: string, - salt?: string, + options?: { + salt?: string; + }, ): Promise { + let { salt } = options ?? {}; if (!salt) salt = await this.generateSalt(); return { @@ -39,54 +42,74 @@ export class PasswordStorageService implements PasswordStorageServiceInterface { * Hash password for an object. * * @param object An object containing the new password to hash. - * @param salt Optional salt. If not provided, one will be generated. + * @param options.salt Optional salt. If not provided, one will be generated. + * @param options.required Set to true if password is required. * @returns A new object with the password hashed, with salt added. */ async hashObject( object: T, - salt?: string, - ): Promise & PasswordStorageInterface> { - // extract password property - const { password, ...safeObject } = object; + options?: { + salt?: string; + required?: boolean; + }, + ): Promise & PasswordStorageInterface>; - // hash the password - const hashed = await this.hash(password, salt); - - // return the object with password hashed - return { - ...safeObject, - ...hashed, - }; - } + /** + * Hash password for an object if the password property exists. + * + * @param object An object containing the new password to hash. + * @param options.salt Optional salt. If not provided, one will be generated. + * @param options.required Set to true if password is required. + * @returns A new object with the password hashed, with salt added. + */ + async hashObject( + object: Partial, + options?: { + salt?: string; + required?: boolean; + }, + ): Promise< + Omit | (Omit & PasswordStorageInterface) + >; /** - * Hash password for an object if password property exists. + * Hash password for an object. * * @param object An object containing the new password to hash. - * @param salt Optional salt. If not provided, one will be generated. + * @param options.salt Optional salt. If not provided, one will be generated. + * @param options.required Set to true if password is required. * @returns A new object with the password hashed, with salt added. */ - async hashObjectOptional>( + async hashObject( object: T, - salt?: string, + options?: { + salt?: string; + required?: boolean; + }, ): Promise< Omit | (Omit & PasswordStorageInterface) > { // extract password property + const { salt, required = true } = options ?? {}; const { password, ...safeObject } = object; // is the password in the object? if (typeof password === 'string') { // hash the password - const hashed = await this.hash(password, salt); + const hashed = await this.hash(password, { salt }); // return the object with password hashed return { ...safeObject, ...hashed, }; - } else { - return safeObject; + } else if (required === true) { + // password is required, not good + throw new Error( + 'Password is required for hashing, but non was provided.', + ); } + + return safeObject; } } diff --git a/packages/nestjs-password/src/services/password-validation.service.spec.ts b/packages/nestjs-password/src/services/password-validation.service.spec.ts index 9439a854a..981da273e 100644 --- a/packages/nestjs-password/src/services/password-validation.service.spec.ts +++ b/packages/nestjs-password/src/services/password-validation.service.spec.ts @@ -25,7 +25,7 @@ describe('PasswordValidationService', () => { // check if password encrypt can be decrypted const isValid = await validationService.validate({ - passwordPlain: PASSWORD_MEDIUM, + password: PASSWORD_MEDIUM, passwordHash: passwordStorageObject.passwordHash ?? '', passwordSalt: passwordStorageObject.passwordSalt ?? '', }); @@ -39,7 +39,7 @@ describe('PasswordValidationService', () => { // check if password encrypt can be decrypted const isValid = await validationService.validate({ - passwordPlain: PASSWORD_MEDIUM, + password: PASSWORD_MEDIUM, passwordHash: 'foo', passwordSalt: fakeSalt, }); @@ -55,10 +55,10 @@ describe('PasswordValidationService', () => { await storageService.hash(PASSWORD_MEDIUM); // check if password encrypt can be decrypted - const isValid = await validationService.validateObject({ - passwordPlain: PASSWORD_MEDIUM, - object: passwordStorageObject, - }); + const isValid = await validationService.validateObject( + PASSWORD_MEDIUM, + passwordStorageObject, + ); expect(isValid).toEqual(true); }); @@ -67,10 +67,20 @@ describe('PasswordValidationService', () => { // fake salt const fakeSalt = await storageService.generateSalt(); - // check if password encrypt can be decrypted - const isValid = await validationService.validateObject({ - passwordPlain: PASSWORD_MEDIUM, - object: { passwordHash: 'foo', passwordSalt: fakeSalt }, + // try to validate fake + const isValid = await validationService.validateObject(PASSWORD_MEDIUM, { + passwordHash: 'foo', + passwordSalt: fakeSalt, + }); + + expect(isValid).toEqual(false); + }); + + it('should NOT validate a null hash/salt combination on an object', async () => { + // null hash and/or salt should return false + const isValid = await validationService.validateObject(PASSWORD_MEDIUM, { + passwordHash: null, + passwordSalt: null, }); expect(isValid).toEqual(false); diff --git a/packages/nestjs-password/src/services/password-validation.service.ts b/packages/nestjs-password/src/services/password-validation.service.ts index 59690651a..e592a8847 100644 --- a/packages/nestjs-password/src/services/password-validation.service.ts +++ b/packages/nestjs-password/src/services/password-validation.service.ts @@ -13,17 +13,17 @@ export class PasswordValidationService /** * Validate if password matches and its valid. * - * @param options.passwordPlain Plain text password + * @param options.password Plain text password * @param options.passwordHash Password hashed * @param options.passwordSalt salt to be used on plain password to see it match */ async validate(options: { - passwordPlain: string; + password: string; passwordHash: string; passwordSalt: string; }): Promise { return CryptUtil.validatePassword( - options.passwordPlain, + options.password, options.passwordHash, options.passwordSalt, ); @@ -35,15 +35,22 @@ export class PasswordValidationService * @param passwordPlain Plain text password * @param object The object on which the password and salt are stored */ - async validateObject(options: { - passwordPlain: string; - object: T; - }): Promise { - const { passwordPlain, object } = options; + async validateObject( + password: string, + object: T, + ): Promise { + const { passwordHash, passwordSalt } = object; + + // hash or salt is null on object? + if (passwordHash === null || passwordSalt === null) { + // yep, automatic invalid + return false; + } + return this.validate({ - passwordPlain, - passwordHash: object.passwordHash ?? '', - passwordSalt: object.passwordSalt ?? '', + password, + passwordHash, + passwordSalt, }); } } diff --git a/packages/nestjs-samples/src/06-typeorm-ext/app.module.ts b/packages/nestjs-samples/src/06-typeorm-ext/app.module.ts index afeb1b6f2..2522a32a8 100644 --- a/packages/nestjs-samples/src/06-typeorm-ext/app.module.ts +++ b/packages/nestjs-samples/src/06-typeorm-ext/app.module.ts @@ -2,6 +2,7 @@ import { DataSource } from 'typeorm'; import { Module } from '@nestjs/common'; import { CrudModule } from '@concepta/nestjs-crud'; import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; +import { PasswordModule } from '@concepta/nestjs-password'; import { UserModule } from '@concepta/nestjs-user'; import { UserEntity } from './user/user.entity'; @@ -13,6 +14,7 @@ import { UserEntity } from './user/user.entity'; entities: [UserEntity], }), CrudModule.forRoot({}), + PasswordModule.forRoot({}), UserModule.forRoot({ entities: { user: { 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 de6856723..ba476cdb3 100644 --- a/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts +++ b/packages/nestjs-user/src/__fixtures__/app.module.custom.fixture.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; import { CrudModule } from '@concepta/nestjs-crud'; import { EventModule } from '@concepta/nestjs-event'; +import { PasswordModule } from '@concepta/nestjs-password'; import { createUserRepositoryFixture } from './create-user-repository.fixture'; import { UserModule } from '../user.module'; @@ -15,6 +16,7 @@ import { UserEntityFixture } from './user.entity.fixture'; TypeOrmExtModule.forRoot(ormConfig), CrudModule.forRoot({}), EventModule.forRoot({}), + PasswordModule.forRoot({}), UserModule.forRootAsync({ imports: [UserModuleCustomFixture], inject: [UserLookupCustomService], diff --git a/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts b/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts index 0da3d451c..5a2b66ce4 100644 --- a/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts +++ b/packages/nestjs-user/src/__fixtures__/app.module.fixture.ts @@ -1,6 +1,7 @@ import { Module } from '@nestjs/common'; import { TypeOrmExtModule } from '@concepta/nestjs-typeorm-ext'; import { CrudModule } from '@concepta/nestjs-crud'; +import { PasswordModule } from '@concepta/nestjs-password'; import { EventModule } from '@concepta/nestjs-event'; import { UserModule } from '../user.module'; @@ -14,6 +15,7 @@ import { InvitationGetUserEventAsync } from './events/invitation-get-user.event' TypeOrmExtModule.forRoot(ormConfig), CrudModule.forRoot({}), EventModule.forRoot({}), + PasswordModule.forRoot({}), UserModule.forRoot({ settings: { invitationRequestEvent: InvitationAcceptedEventAsync, diff --git a/packages/nestjs-user/src/services/user-mutate.service.ts b/packages/nestjs-user/src/services/user-mutate.service.ts index ab517f632..14a0a8fe7 100644 --- a/packages/nestjs-user/src/services/user-mutate.service.ts +++ b/packages/nestjs-user/src/services/user-mutate.service.ts @@ -6,7 +6,7 @@ import { UserCreatableInterface, UserUpdatableInterface, } from '@concepta/ts-common'; -import { PasswordStorageService } from '@concepta/nestjs-password'; +import { PasswordCreationService } from '@concepta/nestjs-password'; import { InjectDynamicRepository } from '@concepta/nestjs-typeorm-ext'; import { USER_MODULE_USER_ENTITY_KEY } from '../user.constants'; @@ -34,12 +34,12 @@ export class UserMutateService * Constructor * * @param repo instance of the user repo - * @param passwordStorageService + * @param passwordCreationService */ constructor( @InjectDynamicRepository(USER_MODULE_USER_ENTITY_KEY) repo: Repository, - private passwordStorageService: PasswordStorageService, + private passwordCreationService: PasswordCreationService, ) { super(repo); } @@ -50,7 +50,9 @@ export class UserMutateService // do we need to hash the password? if ('password' in user && typeof user.password === 'string') { // yes, hash it - return this.passwordStorageService.hashObject(user); + return this.passwordCreationService.createObject(user, { + required: false, + }); } else { // no changes return user; diff --git a/packages/nestjs-user/src/user.controller.ts b/packages/nestjs-user/src/user.controller.ts index 809dad037..bb75788ff 100644 --- a/packages/nestjs-user/src/user.controller.ts +++ b/packages/nestjs-user/src/user.controller.ts @@ -17,7 +17,7 @@ import { UserCreatableInterface, UserUpdatableInterface, } from '@concepta/ts-common'; -import { PasswordStorageService } from '@concepta/nestjs-password'; +import { PasswordCreationService } from '@concepta/nestjs-password'; import { AccessControlCreateMany, AccessControlCreateOne, @@ -59,11 +59,11 @@ export class UserController * Constructor. * * @param userCrudService instance of the user crud service - * @param passwordStorageService instance of password service + * @param passwordCreationService instance of password creation service */ constructor( private userCrudService: UserCrudService, - private passwordStorageService: PasswordStorageService, + private passwordCreationService: PasswordCreationService, ) {} /** @@ -107,7 +107,9 @@ export class UserController for (const userCreateDto of userCreateManyDto.bulk) { // hash it hashed.push( - await this.passwordStorageService.hashObjectOptional(userCreateDto), + await this.passwordCreationService.createObject(userCreateDto, { + required: false, + }), ); } @@ -130,7 +132,9 @@ export class UserController // call crud service to create return this.userCrudService.createOne( crudRequest, - await this.passwordStorageService.hashObjectOptional(userCreateDto), + await this.passwordCreationService.createObject(userCreateDto, { + required: false, + }), ); } @@ -148,7 +152,9 @@ export class UserController ) { return this.userCrudService.updateOne( crudRequest, - await this.passwordStorageService.hashObjectOptional(userUpdateDto), + await this.passwordCreationService.createObject(userUpdateDto, { + required: false, + }), ); } diff --git a/packages/nestjs-user/src/user.module-definition.ts b/packages/nestjs-user/src/user.module-definition.ts index 33b642946..ebf4c53f5 100644 --- a/packages/nestjs-user/src/user.module-definition.ts +++ b/packages/nestjs-user/src/user.module-definition.ts @@ -6,7 +6,12 @@ import { } from '@nestjs/common'; import { ConfigModule } from '@nestjs/config'; import { createSettingsProvider } from '@concepta/nestjs-common'; -import { PasswordStorageService } from '@concepta/nestjs-password'; +import { + PasswordCreationService, + PasswordStorageService, + PasswordStrengthService, + PasswordValidationService, +} from '@concepta/nestjs-password'; import { getDynamicRepositoryToken, TypeOrmExtModule, @@ -85,7 +90,10 @@ export function createUserProviders(options: { return [ ...(options.providers ?? []), UserCrudService, + PasswordCreationService, + PasswordValidationService, PasswordStorageService, + PasswordStrengthService, InvitationAcceptedListener, InvitationGetUserListener, createUserSettingsProvider(options.overrides), @@ -151,15 +159,15 @@ export function createUserMutateServiceProvider( inject: [ RAW_OPTIONS_TOKEN, getDynamicRepositoryToken(USER_MODULE_USER_ENTITY_KEY), - PasswordStorageService, + PasswordCreationService, ], useFactory: async ( options: UserOptionsInterface, userRepo: Repository, - passwordStorageService: PasswordStorageService, + passwordCreationService: PasswordCreationService, ) => optionsOverrides?.userMutateService ?? options.userMutateService ?? - new UserMutateService(userRepo, passwordStorageService), + new UserMutateService(userRepo, passwordCreationService), }; }