Skip to content

Commit 1e79d07

Browse files
committed
feat: 30s email resend limit
1 parent d88ad2d commit 1e79d07

File tree

8 files changed

+146
-25
lines changed

8 files changed

+146
-25
lines changed

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/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: 65 additions & 12 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';
@@ -122,14 +123,18 @@ export class LocalAuthService {
122123
});
123124
}
124125

125-
private async verifySignup(body: ISignup) {
126+
private async verifySignup(body: ISignup, turnstileToken?: string, remoteIp?: string) {
126127
const setting = await this.settingService.getSetting();
127128
if (!setting?.enableEmailVerification) {
128129
return;
129130
}
130131
const { email, verification } = body;
131132
if (!verification) {
132-
const { token, expiresTime } = await this.sendSignupVerificationCode(email);
133+
const { token, expiresTime } = await this.sendSignupVerificationCode(
134+
email,
135+
turnstileToken,
136+
remoteIp
137+
);
133138
throw new CustomHttpException(
134139
'Verification is required',
135140
HttpErrorCode.UNPROCESSABLE_ENTITY,
@@ -161,11 +166,20 @@ export class LocalAuthService {
161166
turnstileToken?: string,
162167
remoteIp?: string
163168
): Promise<void> {
164-
if (!this.turnstileService.isTurnstileEnabled()) {
165-
return; // Turnstile is not enabled, skip validation
169+
const isTurnstileEnabled = this.turnstileService.isTurnstileEnabled();
170+
171+
this.logger.log(
172+
`Turnstile validation check - enabled: ${isTurnstileEnabled}, hasToken: ${!!turnstileToken}, tokenLength: ${turnstileToken?.length}, remoteIp: ${remoteIp}`
173+
);
174+
175+
if (!isTurnstileEnabled) {
176+
return;
166177
}
167178

168179
if (!turnstileToken) {
180+
this.logger.error(
181+
`Turnstile token is missing - enabled: ${isTurnstileEnabled}, remoteIp: ${remoteIp}`
182+
);
169183
throw new BadRequestException('Turnstile token is required');
170184
}
171185

@@ -202,20 +216,28 @@ export class LocalAuthService {
202216

203217
throw new BadRequestException(errorMessage);
204218
}
205-
206-
this.logger.debug('Turnstile validation successful', {
207-
hostname: validation.data?.hostname,
208-
action: validation.data?.action,
209-
});
210219
}
211220

212221
async signup(body: ISignup, remoteIp?: string) {
213222
const { email, password, defaultSpaceName, refMeta, inviteCode, turnstileToken } = body;
214223

224+
this.logger.log(
225+
`Signup attempt - email: ${email}, hasPassword: ${!!password}, hasTurnstileToken: ${!!turnstileToken}, tokenLength: ${turnstileToken?.length}, hasVerification: ${!!body.verification}, remoteIp: ${remoteIp}`
226+
);
227+
228+
// Check if email verification is needed first
229+
const setting = await this.settingService.getSetting();
230+
const needsEmailVerification = setting?.enableEmailVerification && !body.verification;
231+
215232
// Validate Turnstile token if enabled
216-
await this.validateTurnstileIfEnabled(turnstileToken, remoteIp);
233+
// This will be called once, either here or in sendSignupVerificationCode
234+
if (!needsEmailVerification) {
235+
// Only validate here if we're not sending verification code
236+
// (verification code sending will validate the token)
237+
await this.validateTurnstileIfEnabled(turnstileToken, remoteIp);
238+
}
217239

218-
await this.verifySignup(body);
240+
await this.verifySignup(body, turnstileToken, remoteIp);
219241

220242
const user = await this.userService.getUserByEmail(email);
221243
this.isRegisteredValidate(user);
@@ -251,18 +273,45 @@ export class LocalAuthService {
251273
return res;
252274
}
253275

254-
async sendSignupVerificationCode(email: string) {
276+
async sendSignupVerificationCode(email: string, turnstileToken?: string, remoteIp?: string) {
277+
this.logger.log(
278+
`Send verification code attempt - email: ${email}, hasTurnstileToken: ${!!turnstileToken}, tokenLength: ${turnstileToken?.length}, remoteIp: ${remoteIp}`
279+
);
280+
281+
// Validate Turnstile token if enabled
282+
await this.validateTurnstileIfEnabled(turnstileToken, remoteIp);
283+
284+
// Check rate limit: ensure 28 seconds between emails for the same address
285+
const rateLimitKey = `signup-verification-rate-limit:${email}` as keyof ICacheStore;
286+
const existingRateLimit = await this.cacheService.get(rateLimitKey);
287+
288+
if (existingRateLimit) {
289+
this.logger.warn(
290+
`Signup verification rate limit exceeded - email: ${email}, remoteIp: ${remoteIp}, timestamp: ${new Date().toISOString()}`
291+
);
292+
throw new BadRequestException('Please wait 30 seconds before requesting a new code');
293+
}
294+
255295
const code = getRandomString(4, RandomType.Number);
256296
const token = await this.jwtSignupCode(email, code);
297+
257298
if (this.baseConfig.enableEmailCodeConsole) {
258299
console.info('Signup Verification code: ', '\x1b[34m' + code + '\x1b[0m');
259300
}
301+
260302
const user = await this.userService.getUserByEmail(email);
261303
this.isRegisteredValidate(user);
304+
305+
// Log verification code sending
306+
this.logger.log(
307+
`Sending signup verification code - email: ${email}, remoteIp: ${remoteIp}, timestamp: ${new Date().toISOString()}, turnstileVerified: ${!!turnstileToken}`
308+
);
309+
262310
const emailOptions = await this.mailSenderService.sendEmailVerifyCodeEmailOptions({
263311
title: 'Signup verification',
264312
message: `Your verification code is ${code}, expires in ${this.authConfig.signupVerificationExpiresIn}.`,
265313
});
314+
266315
await this.mailSenderService.sendMail(
267316
{
268317
to: email,
@@ -274,6 +323,10 @@ export class LocalAuthService {
274323
transporterName: MailTransporterType.Notify,
275324
}
276325
);
326+
327+
// Set rate limit: 28 seconds using setDetail for exact TTL without random addition
328+
await this.cacheService.setDetail(rateLimitKey, { email, timestamp: Date.now() }, 28);
329+
277330
return {
278331
token,
279332
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/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
};

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

Lines changed: 50 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,8 @@ export const SignForm: FC<ISignForm> = (props) => {
3939
const [isLoading, setIsLoading] = useState<boolean>(false);
4040
const [error, setError] = useState<string>();
4141
const [turnstileToken, setTurnstileToken] = useState<string>();
42+
const [countdown, setCountdown] = useState<number>(0);
43+
const [turnstileKey, setTurnstileKey] = useState<number>(0);
4244
const emailRef = useRef<HTMLInputElement>(null);
4345

4446
const { data: setting } = useQuery({
@@ -60,8 +62,18 @@ export const SignForm: FC<ISignForm> = (props) => {
6062
setSignupVerificationToken(undefined);
6163
setError(undefined);
6264
setTurnstileToken(undefined);
65+
setCountdown(0);
6366
}, [type]);
6467

68+
// Countdown timer for send verification code button
69+
useEffect(() => {
70+
if (countdown <= 0) return;
71+
const timer = setTimeout(() => {
72+
setCountdown((prev) => prev - 1);
73+
}, 1000);
74+
return () => clearTimeout(timer);
75+
}, [countdown]);
76+
6577
const { mutate: submitMutation } = useMutation({
6678
mutationFn: ({ type, form }: { type: 'signin' | 'signup'; form: ISignin }) => {
6779
if (type === 'signin') {
@@ -86,6 +98,7 @@ export const SignForm: FC<ISignForm> = (props) => {
8698
if (error.data && typeof error.data === 'object' && 'token' in error.data) {
8799
setSignupVerificationToken(error.data.token as string);
88100
setError(undefined);
101+
setCountdown(30);
89102
} else {
90103
setError(error.message);
91104
}
@@ -109,13 +122,19 @@ export const SignForm: FC<ISignForm> = (props) => {
109122
default:
110123
setError(error.message);
111124
}
125+
// Reset turnstile token on any error to force re-verification
126+
setTurnstileToken(undefined);
127+
setTurnstileKey((prev) => prev + 1);
112128
setIsLoading(false);
113129
return true;
114130
},
115131
meta: {
116132
preventGlobalError: true,
117133
},
118134
onSuccess: () => {
135+
// Reset turnstile token after successful submission
136+
setTurnstileToken(undefined);
137+
setTurnstileKey((prev) => prev + 1);
119138
onSuccess?.();
120139
},
121140
});
@@ -124,9 +143,24 @@ export const SignForm: FC<ISignForm> = (props) => {
124143
mutate: sendSignupVerificationCodeMutation,
125144
isLoading: sendSignupVerificationCodeLoading,
126145
} = useMutation({
127-
mutationFn: (email: string) => sendSignupVerificationCode(email),
146+
mutationFn: ({ email, turnstileToken }: { email: string; turnstileToken?: string }) =>
147+
sendSignupVerificationCode(email, turnstileToken),
128148
onSuccess: (data) => {
129149
setSignupVerificationToken(data.data.token);
150+
// Start 30 second countdown
151+
setCountdown(30);
152+
// Reset turnstile token and force widget refresh
153+
setTurnstileToken(undefined);
154+
setTurnstileKey((prev) => prev + 1);
155+
},
156+
onError: (error: HttpError) => {
157+
// Reset turnstile on error
158+
setTurnstileToken(undefined);
159+
setTurnstileKey((prev) => prev + 1);
160+
setError(error.message);
161+
},
162+
meta: {
163+
preventGlobalError: true,
130164
},
131165
});
132166

@@ -317,7 +351,7 @@ export const SignForm: FC<ISignForm> = (props) => {
317351
onChange={(e) => setSignupVerificationCode(e.target.value)}
318352
/>
319353
<SendVerificationButton
320-
disabled={sendSignupVerificationCodeLoading}
354+
disabled={sendSignupVerificationCodeLoading || countdown > 0}
321355
onClick={(e) => {
322356
e.preventDefault();
323357
e.stopPropagation();
@@ -328,14 +362,25 @@ export const SignForm: FC<ISignForm> = (props) => {
328362
if (!email) {
329363
return;
330364
}
331-
const res = sendSignupVerificationCodeRoSchema.safeParse({ email });
365+
366+
// Check Turnstile verification if enabled
367+
if (turnstileSiteKey && !turnstileToken) {
368+
setError(t('auth:signError.turnstileRequired'));
369+
return;
370+
}
371+
372+
const res = sendSignupVerificationCodeRoSchema.safeParse({
373+
email,
374+
turnstileToken,
375+
});
332376
if (!res.success) {
333377
setError(fromZodError(res.error).message);
334378
return;
335379
}
336-
sendSignupVerificationCodeMutation(email);
380+
sendSignupVerificationCodeMutation({ email, turnstileToken });
337381
}}
338382
loading={sendSignupVerificationCodeLoading}
383+
countdown={countdown}
339384
/>
340385
</div>
341386
)}
@@ -345,6 +390,7 @@ export const SignForm: FC<ISignForm> = (props) => {
345390
{turnstileSiteKey && (
346391
<div className="flex justify-center">
347392
<TurnstileWidget
393+
key={turnstileKey}
348394
siteKey={turnstileSiteKey}
349395
onVerify={handleTurnstileVerify}
350396
onError={handleTurnstileError}

packages/openapi/src/auth/send-signup-verification-code.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export const SEND_SIGNUP_VERIFICATION_CODE = '/auth/send-signup-verification-cod
77

88
export const sendSignupVerificationCodeRoSchema = z.object({
99
email: z.string().email(),
10+
turnstileToken: z.string().optional(),
1011
});
1112

1213
export type ISendSignupVerificationCodeRo = z.infer<typeof sendSignupVerificationCodeRoSchema>;
@@ -44,5 +45,8 @@ export const sendSignupVerificationCodeRoute: RouteConfig = registerRoute({
4445
tags: ['auth'],
4546
});
4647

47-
export const sendSignupVerificationCode = (email: string) =>
48-
axios.post<ISendSignupVerificationCodeVo>(SEND_SIGNUP_VERIFICATION_CODE, { email });
48+
export const sendSignupVerificationCode = (email: string, turnstileToken?: string) =>
49+
axios.post<ISendSignupVerificationCodeVo>(SEND_SIGNUP_VERIFICATION_CODE, {
50+
email,
51+
turnstileToken,
52+
});

0 commit comments

Comments
 (0)