Skip to content

Commit d28a2e1

Browse files
committed
feat: add cloudflare turnstile
1 parent 651a09c commit d28a2e1

File tree

26 files changed

+599
-22
lines changed

26 files changed

+599
-22
lines changed

apps/nestjs-backend/src/features/auth/auth.module.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import { SocialModule } from './social/social.module';
1818
import { AccessTokenStrategy } from './strategies/access-token.strategy';
1919
import { JwtStrategy } from './strategies/jwt.strategy';
2020
import { SessionStrategy } from './strategies/session.strategy';
21+
import { TurnstileModule } from './turnstile/turnstile.module';
2122

2223
@Module({
2324
imports: [
@@ -30,6 +31,7 @@ import { SessionStrategy } from './strategies/session.strategy';
3031
}),
3132
SocialModule,
3233
PermissionModule,
34+
TurnstileModule,
3335
JwtModule.registerAsync({
3436
useFactory: (config: IAuthConfig) => ({
3537
secret: config.jwt.secret,

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ export class LocalAuthController {
5151
@UseGuards(LocalAuthGuard)
5252
@HttpCode(200)
5353
@Post('signin')
54-
async signin(@Req() req: Express.Request): Promise<IUserMeVo> {
54+
async signin(@Req() req: Request): Promise<IUserMeVo> {
5555
return req.user as IUserMeVo;
5656
}
5757

@@ -60,9 +60,11 @@ export class LocalAuthController {
6060
async signup(
6161
@Body(new ZodValidationPipe(signupSchema)) body: ISignup,
6262
@Res({ passthrough: true }) res: Response,
63-
@Req() req: Express.Request
63+
@Req() req: Request
6464
): Promise<IUserMeVo> {
65-
const user = pickUserMe(await this.authService.signup(body));
65+
const remoteIp =
66+
req.ip || req.connection.remoteAddress || (req.headers['x-forwarded-for'] as string);
67+
const user = pickUserMe(await this.authService.signup(body, remoteIp));
6668
// set cookie, passport login
6769
await new Promise<void>((resolve, reject) => {
6870
req.login(user, (err) => (err ? reject(err) : resolve()));

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

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,13 @@ import { UserModule } from '../../user/user.module';
88
import { SessionStoreService } from '../session/session-store.service';
99
import { SessionModule } from '../session/session.module';
1010
import { LocalStrategy } from '../strategies/local.strategy';
11+
import { TurnstileModule } from '../turnstile/turnstile.module';
1112
import { LocalAuthController } from './local-auth.controller';
1213
import { LocalAuthService } from './local-auth.service';
1314

1415
@Module({
1516
imports: [
17+
TurnstileModule,
1618
SettingModule,
1719
UserModule,
1820
SessionModule,

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

Lines changed: 80 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { MailSenderService } from '../../mail-sender/mail-sender.service';
3030
import { SettingService } from '../../setting/setting.service';
3131
import { UserService } from '../../user/user.service';
3232
import { SessionStoreService } from '../session/session-store.service';
33+
import { TurnstileService } from '../turnstile/turnstile.service';
3334

3435
@Injectable()
3536
export class LocalAuthService {
@@ -47,7 +48,8 @@ export class LocalAuthService {
4748
@MailConfig() private readonly mailConfig: IMailConfig,
4849
@BaseConfig() private readonly baseConfig: IBaseConfig,
4950
private readonly jwtService: JwtService,
50-
private readonly settingService: SettingService
51+
private readonly settingService: SettingService,
52+
private readonly turnstileService: TurnstileService
5153
) {}
5254

5355
private async encodePassword(password: string) {
@@ -91,6 +93,22 @@ export class LocalAuthService {
9193
return (await this.comparePassword(pass, password, salt)) ? { ...result, password } : null;
9294
}
9395

96+
/**
97+
* Validate user by email and password with Turnstile verification
98+
*/
99+
async validateUserByEmailWithTurnstile(
100+
email: string,
101+
pass: string,
102+
turnstileToken?: string,
103+
remoteIp?: string
104+
) {
105+
// Validate Turnstile token if enabled
106+
await this.validateTurnstileIfEnabled(turnstileToken, remoteIp);
107+
108+
// Proceed with normal user validation
109+
return this.validateUserByEmail(email, pass);
110+
}
111+
94112
private jwtSignupCode(email: string, code: string) {
95113
return this.jwtService.signAsync(
96114
{ email, code },
@@ -136,8 +154,67 @@ export class LocalAuthService {
136154
}
137155
}
138156

139-
async signup(body: ISignup) {
140-
const { email, password, defaultSpaceName, refMeta, inviteCode } = body;
157+
/**
158+
* Validate Turnstile token if Turnstile is enabled
159+
*/
160+
private async validateTurnstileIfEnabled(
161+
turnstileToken?: string,
162+
remoteIp?: string
163+
): Promise<void> {
164+
if (!this.turnstileService.isTurnstileEnabled()) {
165+
return; // Turnstile is not enabled, skip validation
166+
}
167+
168+
if (!turnstileToken) {
169+
throw new BadRequestException('Turnstile token is required');
170+
}
171+
172+
const validation = await this.turnstileService.validateTurnstileTokenWithRetry(
173+
turnstileToken,
174+
remoteIp
175+
);
176+
177+
if (!validation.valid) {
178+
this.logger.warn('Turnstile validation failed', {
179+
reason: validation.reason,
180+
remoteIp,
181+
});
182+
183+
let errorMessage = 'Verification failed. Please try again.';
184+
185+
switch (validation.reason) {
186+
case 'turnstile_disabled':
187+
errorMessage = 'Verification service is not available';
188+
break;
189+
case 'invalid_token_format':
190+
case 'token_too_long':
191+
errorMessage = 'Invalid verification token';
192+
break;
193+
case 'turnstile_failed':
194+
errorMessage = 'Verification failed. Please refresh and try again.';
195+
break;
196+
case 'api_error':
197+
case 'internal_error':
198+
case 'max_retries_exceeded':
199+
errorMessage = 'Verification service temporarily unavailable. Please try again.';
200+
break;
201+
}
202+
203+
throw new BadRequestException(errorMessage);
204+
}
205+
206+
this.logger.debug('Turnstile validation successful', {
207+
hostname: validation.data?.hostname,
208+
action: validation.data?.action,
209+
});
210+
}
211+
212+
async signup(body: ISignup, remoteIp?: string) {
213+
const { email, password, defaultSpaceName, refMeta, inviteCode, turnstileToken } = body;
214+
215+
// Validate Turnstile token if enabled
216+
await this.validateTurnstileIfEnabled(turnstileToken, remoteIp);
217+
141218
await this.verifySignup(body);
142219

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

apps/nestjs-backend/src/features/auth/strategies/local.strategy.spec.ts

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
1+
/* eslint-disable @typescript-eslint/naming-convention */
12
/* eslint-disable sonarjs/no-duplicate-string */
23
import type { TestingModule } from '@nestjs/testing';
34
import { Test } from '@nestjs/testing';
5+
import type { Request } from 'express';
46
import { mockDeep, mockReset } from 'vitest-mock-extended';
57
import { CacheService } from '../../../cache/cache.service';
68
import { GlobalModule } from '../../../global/global.module';
@@ -14,6 +16,15 @@ describe('LocalStrategy', () => {
1416
const cacheService = mockDeep<CacheService>();
1517
const testEmail = '[email protected]';
1618
const testPassword = '12345678a';
19+
const mokeReq = {
20+
ip: '127.0.0.1',
21+
connection: {
22+
remoteAddress: '127.0.0.1',
23+
},
24+
headers: {
25+
'x-forwarded-for': '127.0.0.1',
26+
},
27+
} as unknown as Request;
1728

1829
beforeEach(async () => {
1930
const module: TestingModule = await Test.createTestingModule({
@@ -41,7 +52,7 @@ describe('LocalStrategy', () => {
4152
maxLoginAttempts: 0,
4253
accountLockoutMinutes: 0,
4354
};
44-
await expect(localStrategy.validate(testEmail, testPassword)).rejects.toThrow(
55+
await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toThrow(
4556
'Email or password is incorrect'
4657
);
4758
});
@@ -57,7 +68,7 @@ describe('LocalStrategy', () => {
5768
return undefined;
5869
});
5970

60-
await expect(localStrategy.validate(testEmail, testPassword)).rejects.toThrow(
71+
await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toThrow(
6172
'Your account has been locked out, please try again after 10 minutes'
6273
);
6374
});
@@ -74,7 +85,7 @@ describe('LocalStrategy', () => {
7485
return undefined;
7586
});
7687

77-
await expect(localStrategy.validate(testEmail, testPassword)).rejects.toMatchObject({
88+
await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toMatchObject({
7889
response: 'Email or password is incorrect',
7990
});
8091
expect(cacheService.setDetail).toHaveBeenCalledWith(`signin:attempts:${testEmail}`, 3, 30);
@@ -92,7 +103,7 @@ describe('LocalStrategy', () => {
92103
return undefined;
93104
});
94105

95-
await expect(localStrategy.validate(testEmail, testPassword)).rejects.toMatchObject({
106+
await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toMatchObject({
96107
response: 'Your account has been locked out, please try again after 10 minutes',
97108
});
98109
expect(cacheService.set).toHaveBeenCalledWith(`signin:lockout:${testEmail}`, true, 10);
@@ -106,7 +117,7 @@ describe('LocalStrategy', () => {
106117
};
107118
cacheService.get.mockImplementation(async () => undefined);
108119

109-
await expect(localStrategy.validate(testEmail, testPassword)).rejects.toMatchObject({
120+
await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toMatchObject({
110121
response: 'Email or password is incorrect',
111122
});
112123
expect(cacheService.setDetail).toHaveBeenCalledWith(`signin:attempts:${testEmail}`, 1, 30);

apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import { BadRequestException, Injectable } from '@nestjs/common';
33
import { PassportStrategy } from '@nestjs/passport';
44
import { HttpErrorCode } from '@teable/core';
5+
import type { Request } from 'express';
56
import { Strategy } from 'passport-local';
67
import { CacheService } from '../../../cache/cache.service';
78
import { AuthConfig, IAuthConfig } from '../../../configs/auth.config';
@@ -21,12 +22,21 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
2122
super({
2223
usernameField: 'email',
2324
passwordField: 'password',
25+
passReqToCallback: true,
2426
});
2527
}
2628

27-
async validate(email: string, password: string) {
29+
async validate(req: Request, email: string, password: string) {
2830
try {
29-
const user = await this.authService.validateUserByEmail(email, password);
31+
const turnstileToken = req.body?.turnstileToken;
32+
const remoteIp =
33+
req.ip || req.connection.remoteAddress || (req.headers['x-forwarded-for'] as string);
34+
const user = await this.authService.validateUserByEmailWithTurnstile(
35+
email,
36+
password,
37+
turnstileToken,
38+
remoteIp
39+
);
3040
if (!user) {
3141
throw new CustomHttpException(
3242
'Email or password is incorrect',
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
import { Module } from '@nestjs/common';
2+
import { TurnstileService } from './turnstile.service';
3+
4+
@Module({
5+
providers: [TurnstileService],
6+
exports: [TurnstileService],
7+
})
8+
export class TurnstileModule {}

0 commit comments

Comments
 (0)