Skip to content

Commit acf2bc6

Browse files
authored
feat: 30s email resend limit (#1951)
1 parent d88ad2d commit acf2bc6

13 files changed

Lines changed: 177 additions & 24 deletions

File tree

apps/nestjs-backend/src/cache/cache.service.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ export class CacheService<T extends ICacheStore = ICacheStore> {
3131
async setDetail<TKey extends keyof T>(
3232
key: TKey,
3333
value: T[TKey],
34-
ttl?: number | string
34+
ttl?: number | string // seconds
3535
): Promise<void> {
3636
const numberTTL = typeof ttl === 'string' ? second(ttl) : ttl;
3737
await this.cacheManager.set(key as string, value, numberTTL ? numberTTL * 1000 : undefined);

apps/nestjs-backend/src/cache/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ export interface ICacheStore {
3131
mailType: MailType;
3232
})[];
3333
[key: `waitlist:invite-code:${string}`]: number;
34+
[key: `signup-verification-rate-limit:${string}`]: { email: string; timestamp: number };
3435
}
3536

3637
export interface IAttachmentSignatureCache {

apps/nestjs-backend/src/configs/auth.config.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,6 +74,10 @@ export const authConfig = registerAs('auth', () => ({
7474
? Number(process.env.SIGNIN_ACCOUNT_LOCKOUT_MINUTES)
7575
: undefined,
7676
},
77+
signupVerificationCodeRateLimitSeconds: process.env
78+
.BACKEND_SIGNUP_VERIFICATION_CODE_RATE_LIMIT_SECONDS
79+
? Number(process.env.BACKEND_SIGNUP_VERIFICATION_CODE_RATE_LIMIT_SECONDS)
80+
: 30,
7781
}));
7882

7983
export const AuthConfig = () => Inject(authConfig.KEY);

apps/nestjs-backend/src/features/auth/local-auth/local-auth.controller.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -117,9 +117,13 @@ export class LocalAuthController {
117117
@HttpCode(200)
118118
async sendSignupVerificationCode(
119119
@Body(new ZodValidationPipe(sendSignupVerificationCodeRoSchema))
120-
body: ISendSignupVerificationCodeRo
120+
body: ISendSignupVerificationCodeRo,
121+
@Req() req: Request
121122
) {
122-
return this.authService.sendSignupVerificationCode(body.email);
123+
const remoteIp =
124+
req.ip || req.connection.remoteAddress || (req.headers['x-forwarded-for'] as string);
125+
126+
return this.authService.sendSignupVerificationCode(body.email, body.turnstileToken, remoteIp);
123127
}
124128

125129
@Patch('/change-password')

apps/nestjs-backend/src/features/auth/local-auth/local-auth.service.ts

Lines changed: 64 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import { isEmpty } from 'lodash';
1717
import ms from 'ms';
1818
import { ClsService } from 'nestjs-cls';
1919
import { CacheService } from '../../../cache/cache.service';
20+
import type { ICacheStore } from '../../../cache/types';
2021
import { AuthConfig, type IAuthConfig } from '../../../configs/auth.config';
2122
import { BaseConfig, IBaseConfig } from '../../../configs/base.config';
2223
import { MailConfig, type IMailConfig } from '../../../configs/mail.config';
@@ -161,11 +162,20 @@ export class LocalAuthService {
161162
turnstileToken?: string,
162163
remoteIp?: string
163164
): Promise<void> {
164-
if (!this.turnstileService.isTurnstileEnabled()) {
165-
return; // Turnstile is not enabled, skip validation
165+
const isTurnstileEnabled = this.turnstileService.isTurnstileEnabled();
166+
167+
this.logger.log(
168+
`Turnstile validation check - enabled: ${isTurnstileEnabled}, hasToken: ${!!turnstileToken}, tokenLength: ${turnstileToken?.length}, remoteIp: ${remoteIp}`
169+
);
170+
171+
if (!isTurnstileEnabled) {
172+
return;
166173
}
167174

168175
if (!turnstileToken) {
176+
this.logger.error(
177+
`Turnstile token is missing - enabled: ${isTurnstileEnabled}, remoteIp: ${remoteIp}`
178+
);
169179
throw new BadRequestException('Turnstile token is required');
170180
}
171181

@@ -202,17 +212,15 @@ export class LocalAuthService {
202212

203213
throw new BadRequestException(errorMessage);
204214
}
205-
206-
this.logger.debug('Turnstile validation successful', {
207-
hostname: validation.data?.hostname,
208-
action: validation.data?.action,
209-
});
210215
}
211216

212217
async signup(body: ISignup, remoteIp?: string) {
213218
const { email, password, defaultSpaceName, refMeta, inviteCode, turnstileToken } = body;
214219

215-
// Validate Turnstile token if enabled
220+
this.logger.log(
221+
`Signup attempt - email: ${email}, hasPassword: ${!!password}, hasTurnstileToken: ${!!turnstileToken}, tokenLength: ${turnstileToken?.length}, hasVerification: ${!!body.verification}, remoteIp: ${remoteIp}`
222+
);
223+
216224
await this.validateTurnstileIfEnabled(turnstileToken, remoteIp);
217225

218226
await this.verifySignup(body);
@@ -251,18 +259,54 @@ export class LocalAuthService {
251259
return res;
252260
}
253261

254-
async sendSignupVerificationCode(email: string) {
262+
async sendSignupVerificationCode(email: string, turnstileToken?: string, remoteIp?: string) {
263+
this.logger.log(
264+
`Send verification code attempt - email: ${email}, hasTurnstileToken: ${!!turnstileToken}, tokenLength: ${turnstileToken?.length}, remoteIp: ${remoteIp}`
265+
);
266+
267+
// Validate Turnstile token if enabled
268+
await this.validateTurnstileIfEnabled(turnstileToken, remoteIp);
269+
270+
// Check rate limit: ensure interval between emails for the same address
271+
// Backend rate limit is configured limit - 2 seconds (to account for network latency)
272+
// If configured limit is 0, skip rate limiting entirely
273+
const configuredLimit = this.authConfig.signupVerificationCodeRateLimitSeconds;
274+
const backendRateLimit = configuredLimit > 0 ? configuredLimit - 2 : 0;
275+
276+
if (backendRateLimit > 0) {
277+
const rateLimitKey = `signup-verification-rate-limit:${email}` as keyof ICacheStore;
278+
const existingRateLimit = await this.cacheService.get(rateLimitKey);
279+
280+
if (existingRateLimit) {
281+
this.logger.warn(
282+
`Signup verification rate limit exceeded - email: ${email}, remoteIp: ${remoteIp}, timestamp: ${new Date().toISOString()}`
283+
);
284+
throw new BadRequestException(
285+
`Please wait ${configuredLimit} seconds before requesting a new code`
286+
);
287+
}
288+
}
289+
255290
const code = getRandomString(4, RandomType.Number);
256291
const token = await this.jwtSignupCode(email, code);
292+
257293
if (this.baseConfig.enableEmailCodeConsole) {
258294
console.info('Signup Verification code: ', '\x1b[34m' + code + '\x1b[0m');
259295
}
296+
260297
const user = await this.userService.getUserByEmail(email);
261298
this.isRegisteredValidate(user);
299+
300+
// Log verification code sending
301+
this.logger.log(
302+
`Sending signup verification code - email: ${email}, remoteIp: ${remoteIp}, timestamp: ${new Date().toISOString()}, turnstileVerified: ${!!turnstileToken}`
303+
);
304+
262305
const emailOptions = await this.mailSenderService.sendEmailVerifyCodeEmailOptions({
263306
title: 'Signup verification',
264307
message: `Your verification code is ${code}, expires in ${this.authConfig.signupVerificationExpiresIn}.`,
265308
});
309+
266310
await this.mailSenderService.sendMail(
267311
{
268312
to: email,
@@ -274,6 +318,17 @@ export class LocalAuthService {
274318
transporterName: MailTransporterType.Notify,
275319
}
276320
);
321+
322+
// Set rate limit using setDetail for exact TTL without random addition
323+
if (backendRateLimit > 0) {
324+
const rateLimitKey = `signup-verification-rate-limit:${email}` as keyof ICacheStore;
325+
await this.cacheService.setDetail(
326+
rateLimitKey,
327+
{ email, timestamp: Date.now() },
328+
backendRateLimit
329+
);
330+
}
331+
277332
return {
278333
token,
279334
expiresTime: new Date(

apps/nestjs-backend/src/features/auth/turnstile/turnstile.service.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,14 @@ export class TurnstileService {
3333
this.turnstileSiteKey = this.configService.get<string>('TURNSTILE_SITE_KEY') || '';
3434
this.isEnabled = Boolean(this.turnstileSiteKey && this.turnstileSecretKey);
3535

36+
this.logger.log(
37+
`Turnstile Service Initialization - isEnabled: ${this.isEnabled}, hasSiteKey: ${!!this.turnstileSiteKey}, hasSecretKey: ${!!this.turnstileSecretKey}, siteKeyLength: ${this.turnstileSiteKey?.length}, secretKeyLength: ${this.turnstileSecretKey?.length}`
38+
);
39+
3640
if (this.isEnabled) {
3741
this.logger.log('Turnstile validation is enabled');
3842
} else {
39-
this.logger.log('Turnstile validation is disabled - missing site key or secret key');
43+
this.logger.warn('Turnstile validation is disabled - missing site key or secret key');
4044
}
4145
}
4246

apps/nestjs-backend/src/features/setting/open-api/setting-open-api.controller.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import {
2727
ISetSettingMailTransportConfigRo,
2828
SettingKey,
2929
} from '@teable/openapi';
30+
import { AuthConfig, type IAuthConfig } from '../../../configs/auth.config';
3031
import { ZodValidationPipe } from '../../../zod.validation.pipe';
3132
import { Permissions } from '../../auth/decorators/permissions.decorator';
3233
import { Public } from '../../auth/decorators/public.decorator';
@@ -37,7 +38,8 @@ import { SettingOpenApiService } from './setting-open-api.service';
3738
export class SettingOpenApiController {
3839
constructor(
3940
private readonly settingOpenApiService: SettingOpenApiService,
40-
private readonly turnstileService: TurnstileService
41+
private readonly turnstileService: TurnstileService,
42+
@AuthConfig() private readonly authConfig: IAuthConfig
4143
) {}
4244

4345
/**
@@ -85,6 +87,8 @@ export class SettingOpenApiController {
8587
appGenerationEnabled: Boolean(appConfig?.apiKey),
8688
webSearchEnabled: Boolean(webSearchConfig?.apiKey),
8789
turnstileSiteKey: this.turnstileService.getTurnstileSiteKey(),
90+
signupVerificationCodeRateLimitSeconds:
91+
this.authConfig.signupVerificationCodeRateLimitSeconds,
8892
};
8993
}
9094

apps/nestjs-backend/test/auth.e2e-spec.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,9 @@ describe('Auth Controller (e2e)', () => {
6060
const authTestEmail = 'auth@test-auth.com';
6161

6262
beforeAll(async () => {
63+
// Disable signup verification code rate limit for E2E tests
64+
process.env.BACKEND_SIGNUP_VERIFICATION_CODE_RATE_LIMIT_SECONDS = '0';
65+
6366
const appCtx = await initApp();
6467
app = appCtx.app;
6568
clsService = app.get(ClsService);

apps/nextjs-app/.env.example

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,5 @@ BACKEND_PERFORMANCE_CACHE=redis://default:teable@127.0.0.1:6379/0
186186
# cloudflare turnstile config for auth verify
187187
TURNSTILE_SITE_KEY=1x00000000000000000000AA
188188
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
189+
190+
BACKEND_SIGNUP_VERIFICATION_CODE_RATE_LIMIT_SECONDS=30

apps/nextjs-app/src/features/auth/components/SendVerificationButton.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,14 @@ interface SendVerificationButtonProps {
99
onClick: (e: React.MouseEvent<HTMLButtonElement>) => void;
1010
disabled: boolean;
1111
loading?: boolean;
12+
countdown?: number;
1213
}
1314

1415
export const SendVerificationButton = ({
1516
disabled,
1617
onClick,
1718
loading,
19+
countdown = 0,
1820
}: SendVerificationButtonProps) => {
1921
const { t } = useTranslation(authConfig.i18nNamespaces);
2022
const [isSuccess, setIsSuccess] = useState(false);
@@ -37,23 +39,30 @@ export const SendVerificationButton = ({
3739
};
3840
}, [loading]);
3941

42+
const getButtonText = () => {
43+
if (countdown > 0) {
44+
return `${t('auth:button.resend')} (${countdown}s)`;
45+
}
46+
return t('auth:button.resend');
47+
};
48+
4049
return (
4150
<Button
4251
variant={'outline'}
4352
className="mt-4 w-full"
4453
disabled={disabled}
4554
onClick={(e) => {
46-
if (isSuccess) {
55+
if (isSuccess || countdown > 0) {
4756
return;
4857
}
4958
onClick(e);
5059
}}
5160
>
5261
{loading && <Spin />}
53-
{!loading && isSuccess && (
62+
{!loading && isSuccess && countdown === 0 && (
5463
<Check className="size-4 animate-bounce text-green-500 dark:text-green-400" />
5564
)}
56-
{t('auth:button.resend')}
65+
{getButtonText()}
5766
</Button>
5867
);
5968
};

0 commit comments

Comments
 (0)