diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/otp/otp.service.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/otp/otp.service.fixture.ts index d40e743b..2b6661fe 100644 --- a/packages/nestjs-auth-recovery/src/__fixtures__/otp/otp.service.fixture.ts +++ b/packages/nestjs-auth-recovery/src/__fixtures__/otp/otp.service.fixture.ts @@ -19,6 +19,7 @@ export class OtpServiceFixture implements AuthRecoveryOtpServiceInterface { category, type, assignee, + active: true, passcode: 'GOOD_PASSCODE', expirationDate: new Date(), dateCreated: new Date(), diff --git a/packages/nestjs-auth-recovery/src/__fixtures__/user/entities/user-otp-entity.fixture.ts b/packages/nestjs-auth-recovery/src/__fixtures__/user/entities/user-otp-entity.fixture.ts index 512bf072..5467d068 100644 --- a/packages/nestjs-auth-recovery/src/__fixtures__/user/entities/user-otp-entity.fixture.ts +++ b/packages/nestjs-auth-recovery/src/__fixtures__/user/entities/user-otp-entity.fixture.ts @@ -21,6 +21,9 @@ export class UserOtpEntityFixture @Column() passcode!: string; + @Column({ default: true }) + active!: boolean; + @Column({ type: 'datetime' }) expirationDate!: Date; diff --git a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-settings.interface.ts b/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-settings.interface.ts index ed42e7a7..d690953b 100644 --- a/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-settings.interface.ts +++ b/packages/nestjs-auth-recovery/src/interfaces/auth-recovery-settings.interface.ts @@ -2,7 +2,8 @@ import { ReferenceAssignment } from '@concepta/nestjs-common'; import { OtpCreatableInterface } from '@concepta/nestjs-common'; export interface AuthRecoveryOtpSettingsInterface - extends Pick { + extends Pick, + Partial> { assignment: ReferenceAssignment; clearOtpOnCreate?: boolean; } diff --git a/packages/nestjs-auth-recovery/src/services/auth-recovery.service.ts b/packages/nestjs-auth-recovery/src/services/auth-recovery.service.ts index b9088177..35e34fb5 100644 --- a/packages/nestjs-auth-recovery/src/services/auth-recovery.service.ts +++ b/packages/nestjs-auth-recovery/src/services/auth-recovery.service.ts @@ -78,8 +78,15 @@ export class AuthRecoveryService implements AuthRecoveryServiceInterface { // did we find a user? if (user) { // extract required otp properties - const { category, assignment, type, expiresIn, clearOtpOnCreate } = - this.config.otp; + const { + category, + assignment, + type, + expiresIn, + clearOtpOnCreate, + rateSeconds, + rateThreshold, + } = this.config.otp; // create an OTP save it in the database const otp = await this.otpService.create({ assignment, @@ -93,6 +100,8 @@ export class AuthRecoveryService implements AuthRecoveryServiceInterface { }, queryOptions, clearOnCreate: clearOtpOnCreate, + rateSeconds, + rateThreshold, }); // send en email with a recover OTP diff --git a/packages/nestjs-auth-verify/src/__fixtures__/otp/otp.service.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/otp/otp.service.fixture.ts index b17feffe..0a65819a 100644 --- a/packages/nestjs-auth-verify/src/__fixtures__/otp/otp.service.fixture.ts +++ b/packages/nestjs-auth-verify/src/__fixtures__/otp/otp.service.fixture.ts @@ -20,6 +20,7 @@ export class OtpServiceFixture implements AuthVerifyOtpServiceInterface { category, type, assignee, + active: true, passcode: 'GOOD_PASSCODE', expirationDate: new Date(), dateCreated: new Date(), diff --git a/packages/nestjs-auth-verify/src/__fixtures__/user/entities/user-otp-entity.fixture.ts b/packages/nestjs-auth-verify/src/__fixtures__/user/entities/user-otp-entity.fixture.ts index 512bf072..5467d068 100644 --- a/packages/nestjs-auth-verify/src/__fixtures__/user/entities/user-otp-entity.fixture.ts +++ b/packages/nestjs-auth-verify/src/__fixtures__/user/entities/user-otp-entity.fixture.ts @@ -21,6 +21,9 @@ export class UserOtpEntityFixture @Column() passcode!: string; + @Column({ default: true }) + active!: boolean; + @Column({ type: 'datetime' }) expirationDate!: Date; diff --git a/packages/nestjs-auth-verify/src/interfaces/auth-verify-settings.interface.ts b/packages/nestjs-auth-verify/src/interfaces/auth-verify-settings.interface.ts index c4a87986..45571931 100644 --- a/packages/nestjs-auth-verify/src/interfaces/auth-verify-settings.interface.ts +++ b/packages/nestjs-auth-verify/src/interfaces/auth-verify-settings.interface.ts @@ -2,7 +2,8 @@ import { ReferenceAssignment } from '@concepta/nestjs-common'; import { OtpCreatableInterface } from '@concepta/nestjs-common'; export interface AuthVerifyOtpSettingsInterface - extends Pick { + extends Pick, + Partial> { assignment: ReferenceAssignment; clearOtpOnCreate?: boolean; } diff --git a/packages/nestjs-auth-verify/src/services/auth-verify.service.ts b/packages/nestjs-auth-verify/src/services/auth-verify.service.ts index 7efbd50d..8da57548 100644 --- a/packages/nestjs-auth-verify/src/services/auth-verify.service.ts +++ b/packages/nestjs-auth-verify/src/services/auth-verify.service.ts @@ -61,8 +61,15 @@ export class AuthVerifyService implements AuthVerifyServiceInterface { // did we find a user? if (user) { // extract required otp properties - const { category, assignment, type, expiresIn, clearOtpOnCreate } = - this.config.otp; + const { + category, + assignment, + type, + expiresIn, + clearOtpOnCreate, + rateSeconds, + rateThreshold, + } = this.config.otp; // create an OTP save it in the database const otp = await this.otpService.create({ @@ -77,6 +84,8 @@ export class AuthVerifyService implements AuthVerifyServiceInterface { }, queryOptions, clearOnCreate: clearOtpOnCreate, + rateSeconds, + rateThreshold, }); // send en email with a verify OTP @@ -86,9 +95,6 @@ export class AuthVerifyService implements AuthVerifyServiceInterface { resetTokenExp: otp.expirationDate, }); } - - // !!! Falling through to void is intentional !!!! - // !!! Do NOT give any indication if e-mail does not exist !!!! } /** diff --git a/packages/nestjs-common/src/domain/index.ts b/packages/nestjs-common/src/domain/index.ts index 9fae9502..883eeafd 100644 --- a/packages/nestjs-common/src/domain/index.ts +++ b/packages/nestjs-common/src/domain/index.ts @@ -36,7 +36,9 @@ export { RoleUpdatableInterface } from './role/interfaces/role-updatable.interfa export { RoleInterface } from './role/interfaces/role.interface'; export { OtpClearInterface } from './otp/interfaces/otp-clear.interface'; +export { OtpParamsInterface } from './otp/interfaces/otp-params.interface'; export { OtpCreateParamsInterface } from './otp/interfaces/otp-create-params.interface'; +export { OtpValidateLimitParamsInterface } from './otp/interfaces/otp-validate-limit-params.interface'; export { OtpCreatableInterface } from './otp/interfaces/otp-creatable.interface'; export { OtpCreateInterface } from './otp/interfaces/otp-create.interface'; export { OtpDeleteInterface } from './otp/interfaces/otp-delete.interface'; diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp-creatable.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp-creatable.interface.ts index 551d63c6..65f0a2d8 100644 --- a/packages/nestjs-common/src/domain/otp/interfaces/otp-creatable.interface.ts +++ b/packages/nestjs-common/src/domain/otp/interfaces/otp-creatable.interface.ts @@ -3,4 +3,17 @@ import { OtpInterface } from './otp.interface'; export interface OtpCreatableInterface extends Pick { expiresIn: string; + /** + * The minimum number of seconds that must pass between OTP generation requests. + * When provided, this will override the default rateSeconds setting. + */ + rateSeconds?: number; + + /** + * How many attempts before the user is blocked within the rateSeconds time window. + * When provided, this will override the default rateThreshold setting. + * For example, if rateSeconds is 60 and rateThreshold is 3, the user will be blocked + * after 3 failed attempts within 60 seconds. + */ + rateThreshold?: number; } diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp-create-params.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp-create-params.interface.ts index 8791780d..79c8ee9a 100644 --- a/packages/nestjs-common/src/domain/otp/interfaces/otp-create-params.interface.ts +++ b/packages/nestjs-common/src/domain/otp/interfaces/otp-create-params.interface.ts @@ -1,12 +1,11 @@ import { ReferenceQueryOptionsInterface } from '../../../reference/interfaces/reference-query-options.interface'; -import { ReferenceAssignment } from '../../../reference/interfaces/reference.types'; import { OtpCreatableInterface } from './otp-creatable.interface'; +import { OtpParamsInterface } from './otp-params.interface'; export interface OtpCreateParamsInterface< O extends ReferenceQueryOptionsInterface = ReferenceQueryOptionsInterface, -> { - assignment: ReferenceAssignment; - otp: OtpCreatableInterface; - clearOnCreate?: boolean; +> extends Pick, + Partial> { queryOptions?: O; + clearOnCreate?: boolean; } diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp-params.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp-params.interface.ts new file mode 100644 index 00000000..04bd1db1 --- /dev/null +++ b/packages/nestjs-common/src/domain/otp/interfaces/otp-params.interface.ts @@ -0,0 +1,11 @@ +import { ReferenceQueryOptionsInterface } from '../../../reference/interfaces/reference-query-options.interface'; +import { ReferenceAssignment } from '../../../reference/interfaces/reference.types'; +import { OtpCreatableInterface } from './otp-creatable.interface'; + +export interface OtpParamsInterface< + O extends ReferenceQueryOptionsInterface = ReferenceQueryOptionsInterface, +> { + assignment: ReferenceAssignment; + otp: OtpCreatableInterface; + queryOptions?: O; +} diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp-validate-limit-params.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp-validate-limit-params.interface.ts new file mode 100644 index 00000000..0e133a7a --- /dev/null +++ b/packages/nestjs-common/src/domain/otp/interfaces/otp-validate-limit-params.interface.ts @@ -0,0 +1,7 @@ +import { OtpCreatableInterface } from './otp-creatable.interface'; +import { OtpParamsInterface } from './otp-params.interface'; + +export interface OtpValidateLimitParamsInterface + extends Pick, + Pick, + Partial> {} diff --git a/packages/nestjs-common/src/domain/otp/interfaces/otp.interface.ts b/packages/nestjs-common/src/domain/otp/interfaces/otp.interface.ts index 6cadce8d..04f5abf3 100644 --- a/packages/nestjs-common/src/domain/otp/interfaces/otp.interface.ts +++ b/packages/nestjs-common/src/domain/otp/interfaces/otp.interface.ts @@ -25,4 +25,9 @@ export interface OtpInterface * Date it will expire */ expirationDate: Date; + + /** + * is active status + */ + active: boolean; } diff --git a/packages/nestjs-invitation/src/__fixtures__/otp/otp.service.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/otp/otp.service.fixture.ts index 0c880cdd..1df03205 100644 --- a/packages/nestjs-invitation/src/__fixtures__/otp/otp.service.fixture.ts +++ b/packages/nestjs-invitation/src/__fixtures__/otp/otp.service.fixture.ts @@ -20,6 +20,7 @@ export class OtpServiceFixture implements InvitationOtpServiceInterface { category, type, assignee, + active: true, passcode: 'GOOD_PASSCODE', expirationDate: new Date(), dateCreated: new Date(), diff --git a/packages/nestjs-invitation/src/__fixtures__/user/entities/user-otp-entity.fixture.ts b/packages/nestjs-invitation/src/__fixtures__/user/entities/user-otp-entity.fixture.ts index a4c36889..00647720 100644 --- a/packages/nestjs-invitation/src/__fixtures__/user/entities/user-otp-entity.fixture.ts +++ b/packages/nestjs-invitation/src/__fixtures__/user/entities/user-otp-entity.fixture.ts @@ -22,6 +22,9 @@ export class UserOtpEntityFixture @Column() passcode!: string; + @Column({ default: true }) + active!: boolean; + @Column({ type: 'datetime' }) expirationDate!: Date; diff --git a/packages/nestjs-invitation/src/interfaces/invitation-otp-settings.interface.ts b/packages/nestjs-invitation/src/interfaces/invitation-otp-settings.interface.ts new file mode 100644 index 00000000..e11ad110 --- /dev/null +++ b/packages/nestjs-invitation/src/interfaces/invitation-otp-settings.interface.ts @@ -0,0 +1,9 @@ +import { ReferenceAssignment } from '@concepta/nestjs-common'; +import { OtpCreatableInterface } from '@concepta/nestjs-common'; + +export interface InvitationOtpSettingsInterface + extends Pick, + Partial> { + assignment: ReferenceAssignment; + clearOtpOnCreate?: boolean; +} diff --git a/packages/nestjs-invitation/src/interfaces/invitation-settings.interface.ts b/packages/nestjs-invitation/src/interfaces/invitation-settings.interface.ts index cce1e5bd..aca54317 100644 --- a/packages/nestjs-invitation/src/interfaces/invitation-settings.interface.ts +++ b/packages/nestjs-invitation/src/interfaces/invitation-settings.interface.ts @@ -1,11 +1,4 @@ -import { ReferenceAssignment } from '@concepta/nestjs-common'; -import { OtpCreatableInterface } from '@concepta/nestjs-common'; - -export interface InvitationOtpSettingsInterface - extends Pick { - assignment: ReferenceAssignment; - clearOtpOnCreate?: boolean; -} +import { InvitationOtpSettingsInterface } from './invitation-otp-settings.interface'; export interface InvitationSettingsInterface { email: { diff --git a/packages/nestjs-invitation/src/services/invitation-send.service.ts b/packages/nestjs-invitation/src/services/invitation-send.service.ts index 23692bc4..069dfdbb 100644 --- a/packages/nestjs-invitation/src/services/invitation-send.service.ts +++ b/packages/nestjs-invitation/src/services/invitation-send.service.ts @@ -37,7 +37,14 @@ export class InvitationSendService { category: string, queryOptions?: QueryOptionsInterface, ): Promise { - const { assignment, type, expiresIn, clearOtpOnCreate } = this.settings.otp; + const { + assignment, + type, + expiresIn, + clearOtpOnCreate, + rateSeconds, + rateThreshold, + } = this.settings.otp; // create an OTP for this invite const otp = await this.otpService.create({ @@ -52,6 +59,8 @@ export class InvitationSendService { }, queryOptions, clearOnCreate: clearOtpOnCreate, + rateSeconds, + rateThreshold, }); // send the invite email diff --git a/packages/nestjs-otp/src/config/otp-default.config.ts b/packages/nestjs-otp/src/config/otp-default.config.ts index 3c63d1a9..72ac13ee 100644 --- a/packages/nestjs-otp/src/config/otp-default.config.ts +++ b/packages/nestjs-otp/src/config/otp-default.config.ts @@ -17,5 +17,14 @@ export const otpDefaultConfig = registerAs( }, }, clearOnCreate: process.env.OTP_CLEAR_ON_CREATE == 'true' ? true : false, + keepHistoryDays: process.env.OTP_KEEP_HISTORY_DAYS + ? Number.parseInt(process.env.OTP_KEEP_HISTORY_DAYS) + : undefined, + rateSeconds: process.env.OTP_RATE_SECONDS + ? Number.parseInt(process.env.OTP_RATE_SECONDS) + : undefined, + rateThreshold: process.env.OTP_RATE_THRESHOLD + ? Number.parseInt(process.env.OTP_RATE_THRESHOLD) + : undefined, }), ); diff --git a/packages/nestjs-otp/src/dto/otp-create.dto.ts b/packages/nestjs-otp/src/dto/otp-create.dto.ts index d01af0c5..a0d5c9d1 100644 --- a/packages/nestjs-otp/src/dto/otp-create.dto.ts +++ b/packages/nestjs-otp/src/dto/otp-create.dto.ts @@ -1,5 +1,5 @@ import { Exclude, Expose, Type } from 'class-transformer'; -import { IsString, ValidateNested } from 'class-validator'; +import { IsOptional, IsString, ValidateNested } from 'class-validator'; import { ReferenceIdInterface } from '@concepta/nestjs-common'; import { OtpCreatableInterface } from '@concepta/nestjs-common'; import { ReferenceIdDto } from '@concepta/nestjs-common'; @@ -32,6 +32,23 @@ export class OtpCreateDto implements OtpCreatableInterface { @IsString() expiresIn = ''; + /** + * The minimum number of seconds that must pass between OTP generation requests. + * This helps prevent abuse by rate limiting how frequently new OTPs can be created. + */ + @Expose() + @IsOptional() + rateSeconds?: number; + + /** + * How many attempts before the user is blocked within the rateSeconds time window. + * For example, if rateSeconds is 60 and rateThreshold is 3, the user will be blocked + * after 3 failed attempts within 60 seconds. + */ + @Expose() + @IsOptional() + rateThreshold?: number; + /** * Assignee */ diff --git a/packages/nestjs-otp/src/entities/otp-postgres.entity.ts b/packages/nestjs-otp/src/entities/otp-postgres.entity.ts index 0dbc8058..14a6cc61 100644 --- a/packages/nestjs-otp/src/entities/otp-postgres.entity.ts +++ b/packages/nestjs-otp/src/entities/otp-postgres.entity.ts @@ -22,6 +22,9 @@ export abstract class OtpPostgresEntity @Column({ type: 'timestamptz' }) expirationDate!: Date; + @Column({ default: true }) + active!: boolean; + /** * Should be overwrite by the table it will be assigned to */ diff --git a/packages/nestjs-otp/src/entities/otp-sqlite.entity.ts b/packages/nestjs-otp/src/entities/otp-sqlite.entity.ts index 080715b8..c358ce13 100644 --- a/packages/nestjs-otp/src/entities/otp-sqlite.entity.ts +++ b/packages/nestjs-otp/src/entities/otp-sqlite.entity.ts @@ -22,6 +22,9 @@ export abstract class OtpSqliteEntity @Column({ type: 'datetime' }) expirationDate!: Date; + @Column({ default: true }) + active!: boolean; + /** * Should be overwrite by the table it will be assigned to */ diff --git a/packages/nestjs-otp/src/exceptions/otp-limit-reached.exception.ts b/packages/nestjs-otp/src/exceptions/otp-limit-reached.exception.ts new file mode 100644 index 00000000..7a2d7c3f --- /dev/null +++ b/packages/nestjs-otp/src/exceptions/otp-limit-reached.exception.ts @@ -0,0 +1,13 @@ +import { RuntimeExceptionOptions } from '@concepta/nestjs-exception'; +import { OtpException } from './otp.exception'; + +export class OtpLimitReachedException extends OtpException { + constructor(options?: RuntimeExceptionOptions) { + super({ + message: 'OTP creation limit reached for the time window.', + ...options, + }); + + this.errorCode = 'OTP_LIMIT_REACHED_ERROR'; + } +} diff --git a/packages/nestjs-otp/src/interfaces/otp-settings.interface.ts b/packages/nestjs-otp/src/interfaces/otp-settings.interface.ts index 7dc658e0..91d4e283 100644 --- a/packages/nestjs-otp/src/interfaces/otp-settings.interface.ts +++ b/packages/nestjs-otp/src/interfaces/otp-settings.interface.ts @@ -4,4 +4,23 @@ import { OtpTypeServiceInterface } from './otp-types-service.interface'; export interface OtpSettingsInterface { types: LiteralObject; clearOnCreate: boolean; + + /** + * Number of days to retain OTP history. When set, OTPs will be marked inactive instead of deleted. + * If undefined, OTPs will be permanently deleted rather than retained. + */ + keepHistoryDays?: number; + + /** + * The minimum number of seconds that must pass between OTP generation requests. + * This helps prevent abuse by rate limiting how frequently new OTPs can be created. + */ + rateSeconds?: number; + + /** + * How many attempts before the user is blocked within the rateSeconds time window. + * For example, if rateSeconds is 60 and rateThreshold is 3, the user will be blocked + * after 3 failed attempts within 60 seconds. + */ + rateThreshold?: number; } diff --git a/packages/nestjs-otp/src/services/otp.service.spec.ts b/packages/nestjs-otp/src/services/otp.service.spec.ts index b1a9cca6..a27014ce 100644 --- a/packages/nestjs-otp/src/services/otp.service.spec.ts +++ b/packages/nestjs-otp/src/services/otp.service.spec.ts @@ -14,6 +14,7 @@ import { UserOtpEntityFixture } from '../__fixtures__/entities/user-otp-entity.f import { UserFactoryFixture } from '../__fixtures__/factories/user.factory.fixture'; import { UserOtpFactoryFixture } from '../__fixtures__/factories/user-otp.factory.fixture'; import { OTP_MODULE_REPOSITORIES_TOKEN } from '../otp.constants'; +import { OtpLimitReachedException } from '../exceptions/otp-limit-reached.exception'; describe('OtpModule', () => { const CATEGORY_DEFAULT = 'CATEGORY_DEFAULT'; @@ -48,6 +49,8 @@ describe('OtpModule', () => { options: Pick & Partial>, clearOnCreate?: boolean, + rateSeconds?: number, + rateThreshold?: number, ) => await otpService.create({ assignment: 'userOtp', @@ -59,6 +62,8 @@ describe('OtpModule', () => { }, queryOptions: {}, clearOnCreate, + rateSeconds, + rateThreshold, }); // try to delete @@ -72,9 +77,14 @@ describe('OtpModule', () => { ) => await otpService.validate('userOtp', otp, deleteIfValid); beforeEach(async () => { - const connectionName = `test_${connectionNumber++}`; - process.env.OTP_CLEAR_ON_CREATE = 'true'; + // process.env.OTP_CLEAR_ON_CREATE = 'true'; + // process.env.OTP_RATE_SECONDS = '10'; + // process.env.OTP_RATE_THRESHOLD = '2'; + await initModule(); + }); + const initModule = async () => { + const connectionName = `test_${connectionNumber++}`; testModule = await Test.createTestingModule({ imports: [ TypeOrmExtModule.forRoot({ @@ -112,9 +122,12 @@ describe('OtpModule', () => { OTP_MODULE_REPOSITORIES_TOKEN, ); repository = allRepo.userOtp; - }); + }; afterEach(() => { + process.env.OTP_CLEAR_ON_CREATE = undefined; + process.env.OTP_RATE_SECONDS = undefined; + process.env.OTP_RATE_THRESHOLD = undefined; jest.clearAllMocks(); testModule.close(); }); @@ -176,7 +189,7 @@ describe('OtpModule', () => { it('create with success and check previous otp invalid', async () => { const assignee = await factoryCreateUser(); const otp = await defaultCreateOtp({ assignee }); - const otp_2 = await defaultCreateOtp({ assignee }); + const otp_2 = await defaultCreateOtp({ assignee }, true); // make sure previous was deleted expect(await defaultIsValidOtp(otp)).toBeNull(); @@ -240,6 +253,120 @@ describe('OtpModule', () => { expect(e).toBeInstanceOf(OtpTypeNotDefinedException); } }); + + describe('create with limit ', () => { + it('create with fail limit', async () => { + process.env.OTP_CLEAR_ON_CREATE = 'true'; + process.env.OTP_RATE_SECONDS = '10'; + process.env.OTP_RATE_THRESHOLD = '2'; + await initModule(); + const assignee = await factoryCreateUser(); + await defaultCreateOtp({ assignee }); + await defaultCreateOtp({ assignee }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + try { + await defaultCreateOtp({ assignee }); + fail('Expected OtpLimitReachedException to be thrown'); + } catch (e) { + expect(e).toBeInstanceOf(OtpLimitReachedException); + } + }); + + it('create with fail limit 2', async () => { + process.env.OTP_CLEAR_ON_CREATE = 'true'; + process.env.OTP_RATE_SECONDS = '10'; + process.env.OTP_RATE_THRESHOLD = '3'; + await initModule(); + const assignee = await factoryCreateUser(); + await defaultCreateOtp({ assignee }); + await defaultCreateOtp({ assignee }); + await defaultCreateOtp({ assignee }); + try { + await defaultCreateOtp({ assignee }); + fail('Expected OtpLimitReachedException to be thrown'); + } catch (e) { + expect(e).toBeInstanceOf(OtpLimitReachedException); + } + }); + + it('create with success limit using override', async () => { + process.env.OTP_CLEAR_ON_CREATE = 'true'; + process.env.OTP_RATE_SECONDS = `10`; + process.env.OTP_RATE_THRESHOLD = '5'; + const clearOnCreate = false; + const rateSeconds = 10; + const rateThreshold = 3; + await initModule(); + const assignee = await factoryCreateUser(); + await defaultCreateOtp( + { assignee }, + clearOnCreate, + rateSeconds, + rateThreshold, + ); + await defaultCreateOtp( + { assignee }, + clearOnCreate, + rateSeconds, + rateThreshold, + ); + await defaultCreateOtp( + { assignee }, + clearOnCreate, + rateSeconds, + rateThreshold, + ); + try { + await defaultCreateOtp( + { assignee }, + clearOnCreate, + rateSeconds, + rateThreshold, + ); + fail('Expected OtpLimitReachedException to be thrown'); + } catch (e) { + expect(e).toBeInstanceOf(OtpLimitReachedException); + } + }); + + it('create with success limit', async () => { + process.env.OTP_CLEAR_ON_CREATE = 'true'; + process.env.OTP_RATE_SECONDS = '10'; + process.env.OTP_RATE_THRESHOLD = '4'; + await initModule(); + const assignee = await factoryCreateUser(); + await defaultCreateOtp({ assignee }); + await defaultCreateOtp({ assignee }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + const otp = await defaultCreateOtp({ assignee }); + + expect(otp.category).toBe(CATEGORY_DEFAULT); + expect(otp.type).toBe('uuid'); + expect(typeof otp.passcode).toBe('string'); + expect(otp.passcode.length).toBeGreaterThan(0); + expect(otp.expirationDate).toBeInstanceOf(Date); + expect(otp.assignee.id).toBeTruthy(); + }); + + it('create with success with limit 2', async () => { + process.env.OTP_CLEAR_ON_CREATE = 'true'; + process.env.OTP_RATE_SECONDS = '1'; + process.env.OTP_RATE_THRESHOLD = '4'; + await initModule(); + const assignee = await factoryCreateUser(); + await defaultCreateOtp({ assignee }); + await defaultCreateOtp({ assignee }); + await new Promise((resolve) => setTimeout(resolve, 2000)); + const otp = await defaultCreateOtp({ assignee }); + + expect(otp.category).toBe(CATEGORY_DEFAULT); + expect(otp.type).toBe('uuid'); + expect(typeof otp.passcode).toBe('string'); + expect(otp.passcode.length).toBeGreaterThan(0); + expect(otp.expirationDate).toBeInstanceOf(Date); + expect(otp.assignee.id).toBeTruthy(); + }); + }); }); describe('otpService delete', () => { diff --git a/packages/nestjs-otp/src/services/otp.service.ts b/packages/nestjs-otp/src/services/otp.service.ts index 00e55c6d..1ebb9152 100644 --- a/packages/nestjs-otp/src/services/otp.service.ts +++ b/packages/nestjs-otp/src/services/otp.service.ts @@ -1,13 +1,19 @@ import ms from 'ms'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; -import { DeepPartial, Repository } from 'typeorm'; +import { + DeepPartial, + FindOptionsWhere, + LessThanOrEqual, + Repository, +} from 'typeorm'; import { Inject, Injectable, Type } from '@nestjs/common'; import { ReferenceAssigneeInterface, ReferenceAssignment, ReferenceId, OtpCreateParamsInterface, + OtpValidateLimitParamsInterface, } from '@concepta/nestjs-common'; import { OtpInterface } from '@concepta/nestjs-common'; import { @@ -26,6 +32,7 @@ import { OtpServiceInterface } from '../interfaces/otp-service.interface'; import { OtpCreateDto } from '../dto/otp-create.dto'; import { OtpTypeNotDefinedException } from '../exceptions/otp-type-not-defined.exception'; import { OtpEntityNotFoundException } from '../exceptions/otp-entity-not-found.exception'; +import { OtpLimitReachedException } from '../exceptions/otp-limit-reached.exception'; @Injectable() export class OtpService implements OtpServiceInterface { @@ -41,8 +48,17 @@ export class OtpService implements OtpServiceInterface { * * @param params - The otp params */ - async create(params: OtpCreateParamsInterface): Promise { - const { assignment, otp, queryOptions, clearOnCreate } = params; + async create( + params: OtpCreateParamsInterface, + ): Promise { + const { + assignment, + otp, + queryOptions, + clearOnCreate, + rateSeconds, + rateThreshold, + } = params; if (!this.settings.types[otp.type]) throw new OtpTypeNotDefinedException(otp.type); @@ -50,17 +66,24 @@ export class OtpService implements OtpServiceInterface { // get the assignment repo const assignmentRepo = this.getAssignmentRepo(assignment); - // try to find the relationship - try { - // validate the data - const dto = await this.validateDto(OtpCreateDto, otp); + // validate the data + const dto = await this.validateDto(OtpCreateDto, otp); - // generate a passcode - const passcode = this.settings.types[otp.type].generator(); + // generate a passcode + const passcode = this.settings.types[otp.type].generator(); - // break out the vars - const { category, type, assignee, expiresIn } = dto; + // break out the vars + const { category, type, assignee, expiresIn } = dto; + // check if amount of otp by time frame has been reached + await this.validateOtpCreationLimit({ + assignment, + assignee, + category, + rateSeconds, + rateThreshold, + }); + try { // generate the expiration date const expirationDate = this.getExpirationDate(expiresIn); @@ -73,14 +96,27 @@ export class OtpService implements OtpServiceInterface { // nested query options const nestedQueryOptions = { ...queryOptions, transaction }; + // clear history if defined + if ( + this.settings.keepHistoryDays && + this.settings.keepHistoryDays > 0 + ) + this.clearHistory(assignment, otp, queryOptions); + // if clearOnCreate was defined, use it, otherwise get default settings const shouldClear = clearOnCreate === true || clearOnCreate === false ? clearOnCreate : this.settings.clearOnCreate; - if (shouldClear) - await this.clear(assignment, dto, nestedQueryOptions); + if (shouldClear) { + // this should make inactive instead of delete + await this.inactivatePreviousOtp( + assignment, + dto, + nestedQueryOptions, + ); + } return repoProxy.repository(nestedQueryOptions).save({ category, @@ -88,6 +124,7 @@ export class OtpService implements OtpServiceInterface { assignee, passcode, expirationDate, + active: true, }); }); } catch (e) { @@ -97,6 +134,42 @@ export class OtpService implements OtpServiceInterface { } } + private async validateOtpCreationLimit( + params: OtpValidateLimitParamsInterface, + ): Promise { + const { assignment, assignee, category, rateSeconds, rateThreshold } = + params; + + // check if validation config should be overridden + const finalRateSeconds = + rateSeconds && rateSeconds >= 0 ? rateSeconds : this.settings.rateSeconds; + const finalOtpLimit = + rateThreshold && rateThreshold >= 0 + ? rateThreshold + : this.settings.rateThreshold; + + // only check if it was defined + if (finalRateSeconds && finalOtpLimit) { + const cutoffDate = new Date(); + cutoffDate.setSeconds(cutoffDate.getSeconds() - finalRateSeconds); + + // get all active and inactive + const recentOtps = await this.getAssignedOtps(assignment, { + assignee, + category, + }); + + // get otp in the time frame + const recentOtpCount = recentOtps.filter( + (otp) => otp.dateCreated > cutoffDate, + ).length; + + if (recentOtpCount >= finalOtpLimit) { + throw new OtpLimitReachedException(); + } + } + } + /** * Check if otp is valid * @@ -111,7 +184,14 @@ export class OtpService implements OtpServiceInterface { queryOptions?: QueryOptionsInterface, ): Promise { // get otp from an assigned user for a category - const assignedOtp = await this.getByPasscode(assignment, otp, queryOptions); + const assignedOtp = await this.getActiveByPasscode( + assignment, + { + ...otp, + active: true, + }, + queryOptions, + ); // check if otp is expired const now = new Date(); @@ -199,16 +279,39 @@ export class OtpService implements OtpServiceInterface { } } + async clearHistory( + assignment: ReferenceAssignment, + otp: Pick, + queryOptions?: QueryOptionsInterface, + ): Promise { + const keepHistoryDays = this.settings.keepHistoryDays; + // get only otps based on date for history days + const assignedOtps = await this.getAssignedOtps( + assignment, + otp, + queryOptions, + keepHistoryDays, + ); + + // Map to get ids + const assignedOtpIds = assignedOtps.map((assignedOtp) => assignedOtp.id); + + if (assignedOtpIds.length > 0) + await this.deleteOtp(assignment, assignedOtpIds, queryOptions); + } + /** - * Get all OTPs for assignee. + * Get all OTPs for assignee. of filtered by date based on keep history days * * @param assignment - The assignment of the check * @param otp - The otp to get assignments */ + // TODO: recieve query in parameters protected async getAssignedOtps( assignment: ReferenceAssignment, otp: Pick, queryOptions?: QueryOptionsInterface, + keepHistoryDays?: number, ): Promise { // get the assignment repo const assignmentRepo = this.getAssignmentRepo(assignment); @@ -221,12 +324,18 @@ export class OtpService implements OtpServiceInterface { // try to find the relationships try { + // simple query or query by date from history + const query: + | FindOptionsWhere[] + | FindOptionsWhere = this.buildFindQuery( + assignee.id, + category, + keepHistoryDays, + ); + // make the query const assignments = await repoProxy.repository(queryOptions).find({ - where: { - assignee: { id: assignee.id }, - category, - }, + where: query, relations: ['assignee'], }); @@ -239,6 +348,29 @@ export class OtpService implements OtpServiceInterface { } } + private buildFindQuery( + assigneeId: string, + category: string, + keepHistoryDays?: number, + ) { + let query: + | FindOptionsWhere[] + | FindOptionsWhere = { + assignee: { id: assigneeId }, + category, + }; + // filter by date + if (keepHistoryDays) { + const cutoffDate = new Date(); + cutoffDate.setDate(cutoffDate.getDate() - keepHistoryDays); + query = { + assignee: { id: assigneeId }, + category, + dateCreated: LessThanOrEqual(cutoffDate), + }; + } + return query; + } protected async getByPasscode( assignment: ReferenceAssignment, otp: Pick, @@ -273,6 +405,41 @@ export class OtpService implements OtpServiceInterface { } } + protected async getActiveByPasscode( + assignment: ReferenceAssignment, + otp: Pick, + queryOptions?: QueryOptionsInterface, + ): Promise { + // break out properties + const { category, passcode, active } = otp; + + // get the assignment repo + const assignmentRepo = this.getAssignmentRepo(assignment); + + // new repo proxy + const repoProxy = new RepositoryProxy(assignmentRepo); + + // try to find the assignment + try { + // make the query + const assignment = await repoProxy.repository(queryOptions).findOne({ + where: { + category, + passcode, + active, + }, + relations: ['assignee'], + }); + + // return the otps from assignee + return assignment; + } catch (e) { + throw new ReferenceLookupException(assignmentRepo.metadata.targetName, { + originalError: e, + }); + } + } + /** * Get the assignment repo for the given assignment. * @@ -315,6 +482,39 @@ export class OtpService implements OtpServiceInterface { return dto; } + protected async inactivatePreviousOtp( + assignment: ReferenceAssignment, + otp: Pick, + queryOptions?: QueryOptionsInterface, + ): Promise { + // get the assignment repo + const assignmentRepo = this.getAssignmentRepo(assignment); + + // break out the args + const { assignee, category } = otp; + + // new repo proxy + const repoProxy = new RepositoryProxy(assignmentRepo); + + // try to find the relationships + try { + // make previous inactive + await repoProxy.repository(queryOptions).update( + { + assignee: { id: assignee.id }, + category, + }, + { + active: false, + }, + ); + } catch (e) { + throw new ReferenceLookupException(assignmentRepo.metadata.targetName, { + originalError: e, + }); + } + } + // TODO: move this to a help function private getExpirationDate(expiresIn: string) { const now = new Date();