Skip to content
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
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -212,7 +212,7 @@ postgres.integration.test: docker.create.network
$(DOCKER_COMPOSE_ARGS) $(DOCKER_COMPOSE) $(COMPOSE_FILE_ARGS) run -p 25432:5432 -d -T --no-deps --rm --name $$TEST_PG_CONTAINER_NAME teable-postgres; \
chmod +x scripts/wait-for; \
scripts/wait-for 127.0.0.1:25432 --timeout=15 -- echo 'pg database started successfully' && \
export PRISMA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:25432/e2e_test_teable?schema=public\&statement_cache_size=1 && \
export PRISMA_DATABASE_URL=postgresql://teable:teable@127.0.0.1:25432/e2e_test_teable?schema=public\&statement_cache_size=1\&connection_limit=20 && \
make postgres.mode && \
pnpm -F "./packages/**" run build && \
pnpm g:test-e2e-cover && \
Expand Down
2 changes: 1 addition & 1 deletion apps/nestjs-backend/src/cache/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ export interface ICacheStore {
mailType: MailType;
})[];
[key: `waitlist:invite-code:${string}`]: number;
[key: `signup-verification-rate-limit:${string}`]: { email: string; timestamp: number };
[key: `send-mail-rate-limit:${string}`]: boolean;
}

export interface IAttachmentSignatureCache {
Expand Down
4 changes: 0 additions & 4 deletions apps/nestjs-backend/src/configs/auth.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,6 @@ export const authConfig = registerAs('auth', () => ({
? Number(process.env.SIGNIN_ACCOUNT_LOCKOUT_MINUTES)
: undefined,
},
signupVerificationCodeRateLimitSeconds: process.env
.BACKEND_SIGNUP_VERIFICATION_CODE_RATE_LIMIT_SECONDS
? Number(process.env.BACKEND_SIGNUP_VERIFICATION_CODE_RATE_LIMIT_SECONDS)
: 30,
}));

export const AuthConfig = () => Inject(authConfig.KEY);
Expand Down
7 changes: 7 additions & 0 deletions apps/nestjs-backend/src/configs/threshold.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,13 @@ export const thresholdConfig = registerAs('threshold', () => ({
initialBackoff: Number(process.env.BACKEND_DB_DEADLOCK_INITIAL_BACKOFF ?? 100),
jitter: Number(process.env.BACKEND_DB_DEADLOCK_JITTER ?? 1.0),
},
changeEmailSendCodeMailRate: Number(process.env.BACKEND_CHANGE_EMAIL_SEND_CODE_MAIL_RATE ?? 30),
resetPasswordSendMailRate: Number(process.env.BACKEND_RESET_PASSWORD_SEND_MAIL_RATE ?? 30),
signupVerificationSendCodeMailRate: Number(
process.env.BACKEND_SIGNUP_VERIFICATION_CODE_RATE_LIMIT_SECONDS ??
process.env.BACKEND_SIGNUP_VERIFICATION_SEND_CODE_MAIL_RATE ??
30
),
}));

export const ThresholdConfig = () => Inject(thresholdConfig.KEY);
Expand Down
224 changes: 112 additions & 112 deletions apps/nestjs-backend/src/features/auth/local-auth/local-auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,10 @@ import { isEmpty } from 'lodash';
import ms from 'ms';
import { ClsService } from 'nestjs-cls';
import { CacheService } from '../../../cache/cache.service';
import type { ICacheStore } from '../../../cache/types';
import { AuthConfig, type IAuthConfig } from '../../../configs/auth.config';
import { BaseConfig, IBaseConfig } from '../../../configs/base.config';
import { MailConfig, type IMailConfig } from '../../../configs/mail.config';
import { IThresholdConfig, ThresholdConfig } from '../../../configs/threshold.config';
import { CustomHttpException } from '../../../custom.exception';
import { EventEmitterService } from '../../../event-emitter/event-emitter.service';
import { Events } from '../../../event-emitter/events';
Expand Down Expand Up @@ -48,6 +48,7 @@ export class LocalAuthService {
@AuthConfig() private readonly authConfig: IAuthConfig,
@MailConfig() private readonly mailConfig: IMailConfig,
@BaseConfig() private readonly baseConfig: IBaseConfig,
@ThresholdConfig() private readonly thresholdConfig: IThresholdConfig,
private readonly jwtService: JwtService,
private readonly settingService: SettingService,
private readonly turnstileService: TurnstileService
Expand Down Expand Up @@ -274,72 +275,52 @@ export class LocalAuthService {
}

async sendSignupVerificationCode(email: string) {
// Check rate limit: ensure interval between emails for the same address
// Backend rate limit is configured limit - 2 seconds (to account for network latency)
// If configured limit is 0, skip rate limiting entirely
const configuredLimit = this.authConfig.signupVerificationCodeRateLimitSeconds;
const backendRateLimit = configuredLimit > 0 ? configuredLimit - 2 : 0;

if (backendRateLimit > 0) {
const rateLimitKey = `signup-verification-rate-limit:${email}` as keyof ICacheStore;
const existingRateLimit = await this.cacheService.get(rateLimitKey);

if (existingRateLimit) {
this.logger.warn(`Signup verification rate limit exceeded - email: ${email}`);
throw new BadRequestException(
`Please wait ${configuredLimit} seconds before requesting a new code`
);
}
}
return await this.mailSenderService.checkSendMailRateLimit(
{
email,
rateLimitKey: 'signup-verification',
rateLimit: this.thresholdConfig.signupVerificationSendCodeMailRate,
},
async () => {
const code = getRandomString(4, RandomType.Number);
const token = await this.jwtSignupCode(email, code);

const code = getRandomString(4, RandomType.Number);
const token = await this.jwtSignupCode(email, code);
if (this.baseConfig.enableEmailCodeConsole) {
console.info('Signup Verification code: ', '\x1b[34m' + code + '\x1b[0m');
}

if (this.baseConfig.enableEmailCodeConsole) {
console.info('Signup Verification code: ', '\x1b[34m' + code + '\x1b[0m');
}
const user = await this.userService.getUserByEmail(email);
this.isRegisteredValidate(user);

const user = await this.userService.getUserByEmail(email);
this.isRegisteredValidate(user);

// Log verification code sending
this.logger.log(
`Sending signup verification code - email: ${email}, timestamp: ${new Date().toISOString()}`
);
// Log verification code sending
this.logger.log(
`Sending signup verification code - email: ${email}, timestamp: ${new Date().toISOString()}`
);

const emailOptions = await this.mailSenderService.sendEmailVerifyCodeEmailOptions({
title: 'Signup verification',
message: `Your verification code is ${code}, expires in ${this.authConfig.signupVerificationExpiresIn}.`,
});
const emailOptions = await this.mailSenderService.sendEmailVerifyCodeEmailOptions({
title: 'Signup verification',
message: `Your verification code is ${code}, expires in ${this.authConfig.signupVerificationExpiresIn}.`,
});

await this.mailSenderService.sendMail(
{
to: email,
await this.mailSenderService.sendMail(
{
to: email,

...emailOptions,
},
{
type: MailType.VerifyCode,
transporterName: MailTransporterType.Notify,
...emailOptions,
},
{
type: MailType.VerifyCode,
transporterName: MailTransporterType.Notify,
}
);
return {
token,
expiresTime: new Date(
ms(this.authConfig.signupVerificationExpiresIn) + Date.now()
).toISOString(),
};
}
);

// Set rate limit using setDetail for exact TTL without random addition
if (backendRateLimit > 0) {
const rateLimitKey = `signup-verification-rate-limit:${email}` as keyof ICacheStore;
await this.cacheService.setDetail(
rateLimitKey,
{ email, timestamp: Date.now() },
backendRateLimit
);
}

return {
token,
expiresTime: new Date(
ms(this.authConfig.signupVerificationExpiresIn) + Date.now()
).toISOString(),
};
}

async changePassword({ password, newPassword }: IChangePasswordRo) {
Expand All @@ -363,34 +344,43 @@ export class LocalAuthService {
}

async sendResetPasswordEmail(email: string) {
const user = await this.userService.getUserByEmail(email);
if (!user || (user.accounts.length === 0 && user.password == null)) {
throw new BadRequestException(`${email} not registered`);
}

const resetPasswordCode = getRandomString(30);

const url = `${this.mailConfig.origin}/auth/reset-password?code=${resetPasswordCode}`;
const resetPasswordEmailOptions = await this.mailSenderService.resetPasswordEmailOptions({
name: user.name,
email: user.email,
resetPasswordUrl: url,
});
await this.mailSenderService.sendMail(
return await this.mailSenderService.checkSendMailRateLimit(
{
to: user.email,
...resetPasswordEmailOptions,
email,
rateLimitKey: 'send-reset-password-email',
rateLimit: this.thresholdConfig.resetPasswordSendMailRate,
},
{
type: MailType.ResetPassword,
transporterName: MailTransporterType.Notify,
async () => {
const user = await this.userService.getUserByEmail(email);
if (!user || (user.accounts.length === 0 && user.password == null)) {
throw new BadRequestException(`${email} not registered`);
}

const resetPasswordCode = getRandomString(30);

const url = `${this.mailConfig.origin}/auth/reset-password?code=${resetPasswordCode}`;
const resetPasswordEmailOptions = await this.mailSenderService.resetPasswordEmailOptions({
name: user.name,
email: user.email,
resetPasswordUrl: url,
});
await this.mailSenderService.sendMail(
{
to: user.email,
...resetPasswordEmailOptions,
},
{
type: MailType.ResetPassword,
transporterName: MailTransporterType.Notify,
}
);
await this.cacheService.set(
`reset-password-email:${resetPasswordCode}`,
{ userId: user.id },
second(this.authConfig.resetPasswordEmailExpiresIn)
);
}
);
await this.cacheService.set(
`reset-password-email:${resetPasswordCode}`,
{ userId: user.id },
second(this.authConfig.resetPasswordEmailExpiresIn)
);
}

async resetPassword(code: string, newPassword: string) {
Expand Down Expand Up @@ -469,39 +459,49 @@ export class LocalAuthService {
'Password is incorrect',
HttpErrorCode.INVALID_CREDENTIALS
);
const user = await this.validateUserByEmail(email, password).catch(() => {
throw invalidPasswordError;
});
if (!user) {
throw invalidPasswordError;
}
const userByNewEmail = await this.userService.getUserByEmail(newEmail);
if (userByNewEmail) {
throw new ConflictException('New email is already registered');
}
const code = getRandomString(4, RandomType.Number);
const token = await this.jwtService.signAsync(
{ email, newEmail, code },
{ expiresIn: this.baseConfig.emailCodeExpiresIn }
);
if (this.baseConfig.enableEmailCodeConsole) {
console.info('Change Email Verification code: ', '\x1b[34m' + code + '\x1b[0m');
}
const emailOptions = await this.mailSenderService.sendEmailVerifyCodeEmailOptions({
title: 'Change Email verification',
message: `Your verification code is ${code}, expires in ${this.baseConfig.emailCodeExpiresIn}.`,
});
await this.mailSenderService.sendMail(

return await this.mailSenderService.checkSendMailRateLimit(
{
to: newEmail,
...emailOptions,
email: newEmail,
rateLimitKey: 'send-change-email-code',
rateLimit: this.thresholdConfig.changeEmailSendCodeMailRate,
},
{
type: MailType.VerifyCode,
transporterName: MailTransporterType.Notify,
async () => {
const user = await this.validateUserByEmail(email, password).catch(() => {
throw invalidPasswordError;
});
if (!user) {
throw invalidPasswordError;
}
const userByNewEmail = await this.userService.getUserByEmail(newEmail);
if (userByNewEmail) {
throw new ConflictException('New email is already registered');
}
const code = getRandomString(4, RandomType.Number);
const token = await this.jwtService.signAsync(
{ email, newEmail, code },
{ expiresIn: this.baseConfig.emailCodeExpiresIn }
);
if (this.baseConfig.enableEmailCodeConsole) {
console.info('Change Email Verification code: ', '\x1b[34m' + code + '\x1b[0m');
}
const emailOptions = await this.mailSenderService.sendEmailVerifyCodeEmailOptions({
title: 'Change Email verification',
message: `Your verification code is ${code}, expires in ${this.baseConfig.emailCodeExpiresIn}.`,
});
await this.mailSenderService.sendMail(
{
to: newEmail,
...emailOptions,
},
{
type: MailType.VerifyCode,
transporterName: MailTransporterType.Notify,
}
);
return { token };
}
);
return { token };
}

async joinWaitlist(email: string) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { Injectable, Logger } from '@nestjs/common';
import { MailerService } from '@nestjs-modules/mailer';
import { HttpErrorCode } from '@teable/core';
import type { IMailTransportConfig } from '@teable/openapi';
import { MailType, CollaboratorType, SettingKey, MailTransporterType } from '@teable/openapi';
import { isString } from 'lodash';
import { createTransport } from 'nodemailer';
import { CacheService } from '../../cache/cache.service';
import { IMailConfig, MailConfig } from '../../configs/mail.config';
import { CustomHttpException } from '../../custom.exception';
import { EventEmitterService } from '../../event-emitter/event-emitter.service';
import { Events } from '../../event-emitter/events';
import { SettingOpenApiService } from '../setting/open-api/setting-open-api.service';
Expand All @@ -19,7 +22,8 @@ export class MailSenderService {
private readonly mailService: MailerService,
@MailConfig() private readonly mailConfig: IMailConfig,
private readonly settingOpenApiService: SettingOpenApiService,
private readonly eventEmitterService: EventEmitterService
private readonly eventEmitterService: EventEmitterService,
private readonly cacheService: CacheService
) {
const { host, port, secure, auth, sender, senderName } = this.mailConfig;
this.defaultTransportConfig = {
Expand All @@ -35,6 +39,32 @@ export class MailSenderService {
};
}

async checkSendMailRateLimit<T>(
options: { email: string; rateLimitKey: string; rateLimit: number },
fn: () => Promise<T>
) {
const { email, rateLimitKey: _rateLimitKey, rateLimit: _rateLimit } = options;
// If rate limit is 0, skip rate limiting entirely
if (_rateLimit <= 0) {
return await fn();
}
const rateLimit = _rateLimit - 2; // 2 seconds for network latency
const rateLimitKey = `send-mail-rate-limit:${_rateLimitKey}:${email}` as const;
const existingRateLimit = await this.cacheService.get(rateLimitKey);
if (existingRateLimit) {
throw new CustomHttpException(
`Reached the rate limit of sending mail, please try again after ${rateLimit} seconds`,
HttpErrorCode.TOO_MANY_REQUESTS,
{
seconds: _rateLimit,
}
);
}
const result = await fn();
await this.cacheService.setDetail(rateLimitKey, true, rateLimit);
return result;
}

// https://nodemailer.com/smtp#connection-options
async createTransporter(config: IMailTransportConfig) {
const { connectionTimeout, greetingTimeout, dnsTimeout } = this.mailConfig;
Expand Down
Loading