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/rate limiting #338

Merged
merged 7 commits into from
Jan 26, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class OtpServiceFixture implements AuthRecoveryOtpServiceInterface {
category,
type,
assignee,
active: true,
passcode: 'GOOD_PASSCODE',
expirationDate: new Date(),
dateCreated: new Date(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export class UserOtpEntityFixture
@Column()
passcode!: string;

@Column({ default: true })
active!: boolean;

@Column({ type: 'datetime' })
expirationDate!: Date;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { ReferenceAssignment } from '@concepta/nestjs-common';
import { OtpCreatableInterface } from '@concepta/nestjs-common';

export interface AuthRecoveryOtpSettingsInterface
extends Pick<OtpCreatableInterface, 'category' | 'type' | 'expiresIn'> {
extends Pick<OtpCreatableInterface, 'category' | 'type' | 'expiresIn'>,
Partial<Pick<OtpCreatableInterface, 'rateSeconds' | 'rateThreshold'>> {
assignment: ReferenceAssignment;
clearOtpOnCreate?: boolean;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -93,6 +100,8 @@ export class AuthRecoveryService implements AuthRecoveryServiceInterface {
},
queryOptions,
clearOnCreate: clearOtpOnCreate,
rateSeconds,
rateThreshold,
});

// send en email with a recover OTP
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class OtpServiceFixture implements AuthVerifyOtpServiceInterface {
category,
type,
assignee,
active: true,
passcode: 'GOOD_PASSCODE',
expirationDate: new Date(),
dateCreated: new Date(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ export class UserOtpEntityFixture
@Column()
passcode!: string;

@Column({ default: true })
active!: boolean;

@Column({ type: 'datetime' })
expirationDate!: Date;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,8 @@ import { ReferenceAssignment } from '@concepta/nestjs-common';
import { OtpCreatableInterface } from '@concepta/nestjs-common';

export interface AuthVerifyOtpSettingsInterface
extends Pick<OtpCreatableInterface, 'category' | 'type' | 'expiresIn'> {
extends Pick<OtpCreatableInterface, 'category' | 'type' | 'expiresIn'>,
Partial<Pick<OtpCreatableInterface, 'rateSeconds' | 'rateThreshold'>> {
assignment: ReferenceAssignment;
clearOtpOnCreate?: boolean;
}
Expand Down
16 changes: 11 additions & 5 deletions packages/nestjs-auth-verify/src/services/auth-verify.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -77,6 +84,8 @@ export class AuthVerifyService implements AuthVerifyServiceInterface {
},
queryOptions,
clearOnCreate: clearOtpOnCreate,
rateSeconds,
rateThreshold,
});

// send en email with a verify OTP
Expand All @@ -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 !!!!
}

/**
Expand Down
2 changes: 2 additions & 0 deletions packages/nestjs-common/src/domain/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,4 +3,17 @@ import { OtpInterface } from './otp.interface';
export interface OtpCreatableInterface
extends Pick<OtpInterface, 'category' | 'type' | 'assignee'> {
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;
}
Original file line number Diff line number Diff line change
@@ -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<OtpParamsInterface, 'assignment' | 'otp'>,
Partial<Pick<OtpCreatableInterface, 'rateSeconds' | 'rateThreshold'>> {
queryOptions?: O;
clearOnCreate?: boolean;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { OtpCreatableInterface } from './otp-creatable.interface';
import { OtpParamsInterface } from './otp-params.interface';

export interface OtpValidateLimitParamsInterface
extends Pick<OtpParamsInterface, 'assignment'>,
Pick<OtpCreatableInterface, 'assignee' | 'category'>,
Partial<Pick<OtpCreatableInterface, 'rateSeconds' | 'rateThreshold'>> {}
Original file line number Diff line number Diff line change
Expand Up @@ -25,4 +25,9 @@ export interface OtpInterface
* Date it will expire
*/
expirationDate: Date;

/**
* is active status
*/
active: boolean;
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export class OtpServiceFixture implements InvitationOtpServiceInterface {
category,
type,
assignee,
active: true,
passcode: 'GOOD_PASSCODE',
expirationDate: new Date(),
dateCreated: new Date(),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,9 @@ export class UserOtpEntityFixture
@Column()
passcode!: string;

@Column({ default: true })
active!: boolean;

@Column({ type: 'datetime' })
expirationDate!: Date;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { ReferenceAssignment } from '@concepta/nestjs-common';
import { OtpCreatableInterface } from '@concepta/nestjs-common';

export interface InvitationOtpSettingsInterface
extends Pick<OtpCreatableInterface, 'type' | 'expiresIn'>,
Partial<Pick<OtpCreatableInterface, 'rateSeconds' | 'rateThreshold'>> {
assignment: ReferenceAssignment;
clearOtpOnCreate?: boolean;
}
Original file line number Diff line number Diff line change
@@ -1,11 +1,4 @@
import { ReferenceAssignment } from '@concepta/nestjs-common';
import { OtpCreatableInterface } from '@concepta/nestjs-common';

export interface InvitationOtpSettingsInterface
extends Pick<OtpCreatableInterface, 'type' | 'expiresIn'> {
assignment: ReferenceAssignment;
clearOtpOnCreate?: boolean;
}
import { InvitationOtpSettingsInterface } from './invitation-otp-settings.interface';

export interface InvitationSettingsInterface {
email: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,14 @@ export class InvitationSendService {
category: string,
queryOptions?: QueryOptionsInterface,
): Promise<void> {
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({
Expand All @@ -52,6 +59,8 @@ export class InvitationSendService {
},
queryOptions,
clearOnCreate: clearOtpOnCreate,
rateSeconds,
rateThreshold,
});

// send the invite email
Expand Down
9 changes: 9 additions & 0 deletions packages/nestjs-otp/src/config/otp-default.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}),
);
19 changes: 18 additions & 1 deletion packages/nestjs-otp/src/dto/otp-create.dto.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/nestjs-otp/src/entities/otp-postgres.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
3 changes: 3 additions & 0 deletions packages/nestjs-otp/src/entities/otp-sqlite.entity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
*/
Expand Down
13 changes: 13 additions & 0 deletions packages/nestjs-otp/src/exceptions/otp-limit-reached.exception.ts
Original file line number Diff line number Diff line change
@@ -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';
}
}
19 changes: 19 additions & 0 deletions packages/nestjs-otp/src/interfaces/otp-settings.interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,23 @@ import { OtpTypeServiceInterface } from './otp-types-service.interface';
export interface OtpSettingsInterface {
types: LiteralObject<OtpTypeServiceInterface>;
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;
}
Loading
Loading