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: 2 additions & 0 deletions apps/nestjs-backend/src/features/auth/auth.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { SocialModule } from './social/social.module';
import { AccessTokenStrategy } from './strategies/access-token.strategy';
import { JwtStrategy } from './strategies/jwt.strategy';
import { SessionStrategy } from './strategies/session.strategy';
import { TurnstileModule } from './turnstile/turnstile.module';

@Module({
imports: [
Expand All @@ -30,6 +31,7 @@ import { SessionStrategy } from './strategies/session.strategy';
}),
SocialModule,
PermissionModule,
TurnstileModule,
JwtModule.registerAsync({
useFactory: (config: IAuthConfig) => ({
secret: config.jwt.secret,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ export class LocalAuthController {
@UseGuards(LocalAuthGuard)
@HttpCode(200)
@Post('signin')
async signin(@Req() req: Express.Request): Promise<IUserMeVo> {
async signin(@Req() req: Request): Promise<IUserMeVo> {
return req.user as IUserMeVo;
}

Expand All @@ -60,9 +60,11 @@ export class LocalAuthController {
async signup(
@Body(new ZodValidationPipe(signupSchema)) body: ISignup,
@Res({ passthrough: true }) res: Response,
@Req() req: Express.Request
@Req() req: Request
): Promise<IUserMeVo> {
const user = pickUserMe(await this.authService.signup(body));
const remoteIp =
req.ip || req.connection.remoteAddress || (req.headers['x-forwarded-for'] as string);
const user = pickUserMe(await this.authService.signup(body, remoteIp));
// set cookie, passport login
await new Promise<void>((resolve, reject) => {
req.login(user, (err) => (err ? reject(err) : resolve()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,13 @@ import { UserModule } from '../../user/user.module';
import { SessionStoreService } from '../session/session-store.service';
import { SessionModule } from '../session/session.module';
import { LocalStrategy } from '../strategies/local.strategy';
import { TurnstileModule } from '../turnstile/turnstile.module';
import { LocalAuthController } from './local-auth.controller';
import { LocalAuthService } from './local-auth.service';

@Module({
imports: [
TurnstileModule,
SettingModule,
UserModule,
SessionModule,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import { MailSenderService } from '../../mail-sender/mail-sender.service';
import { SettingService } from '../../setting/setting.service';
import { UserService } from '../../user/user.service';
import { SessionStoreService } from '../session/session-store.service';
import { TurnstileService } from '../turnstile/turnstile.service';

@Injectable()
export class LocalAuthService {
Expand All @@ -47,7 +48,8 @@ export class LocalAuthService {
@MailConfig() private readonly mailConfig: IMailConfig,
@BaseConfig() private readonly baseConfig: IBaseConfig,
private readonly jwtService: JwtService,
private readonly settingService: SettingService
private readonly settingService: SettingService,
private readonly turnstileService: TurnstileService
) {}

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

/**
* Validate user by email and password with Turnstile verification
*/
async validateUserByEmailWithTurnstile(
email: string,
pass: string,
turnstileToken?: string,
remoteIp?: string
) {
// Validate Turnstile token if enabled
await this.validateTurnstileIfEnabled(turnstileToken, remoteIp);

// Proceed with normal user validation
return this.validateUserByEmail(email, pass);
}

private jwtSignupCode(email: string, code: string) {
return this.jwtService.signAsync(
{ email, code },
Expand Down Expand Up @@ -136,8 +154,67 @@ export class LocalAuthService {
}
}

async signup(body: ISignup) {
const { email, password, defaultSpaceName, refMeta, inviteCode } = body;
/**
* Validate Turnstile token if Turnstile is enabled
*/
private async validateTurnstileIfEnabled(
turnstileToken?: string,
remoteIp?: string
): Promise<void> {
if (!this.turnstileService.isTurnstileEnabled()) {
return; // Turnstile is not enabled, skip validation
}

if (!turnstileToken) {
throw new BadRequestException('Turnstile token is required');
}

const validation = await this.turnstileService.validateTurnstileTokenWithRetry(
turnstileToken,
remoteIp
);

if (!validation.valid) {
this.logger.warn('Turnstile validation failed', {
reason: validation.reason,
remoteIp,
});

let errorMessage = 'Verification failed. Please try again.';

switch (validation.reason) {
case 'turnstile_disabled':
errorMessage = 'Verification service is not available';
break;
case 'invalid_token_format':
case 'token_too_long':
errorMessage = 'Invalid verification token';
break;
case 'turnstile_failed':
errorMessage = 'Verification failed. Please refresh and try again.';
break;
case 'api_error':
case 'internal_error':
case 'max_retries_exceeded':
errorMessage = 'Verification service temporarily unavailable. Please try again.';
break;
}

throw new BadRequestException(errorMessage);
}

this.logger.debug('Turnstile validation successful', {
hostname: validation.data?.hostname,
action: validation.data?.action,
});
}

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

// Validate Turnstile token if enabled
await this.validateTurnstileIfEnabled(turnstileToken, remoteIp);

await this.verifySignup(body);

const user = await this.userService.getUserByEmail(email);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
/* eslint-disable @typescript-eslint/naming-convention */
/* eslint-disable sonarjs/no-duplicate-string */
import type { TestingModule } from '@nestjs/testing';
import { Test } from '@nestjs/testing';
import type { Request } from 'express';
import { mockDeep, mockReset } from 'vitest-mock-extended';
import { CacheService } from '../../../cache/cache.service';
import { GlobalModule } from '../../../global/global.module';
Expand All @@ -14,6 +16,15 @@ describe('LocalStrategy', () => {
const cacheService = mockDeep<CacheService>();
const testEmail = '[email protected]';
const testPassword = '12345678a';
const mokeReq = {
ip: '127.0.0.1',
connection: {
remoteAddress: '127.0.0.1',
},
headers: {
'x-forwarded-for': '127.0.0.1',
},
} as unknown as Request;

beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
Expand Down Expand Up @@ -41,7 +52,7 @@ describe('LocalStrategy', () => {
maxLoginAttempts: 0,
accountLockoutMinutes: 0,
};
await expect(localStrategy.validate(testEmail, testPassword)).rejects.toThrow(
await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toThrow(
'Email or password is incorrect'
);
});
Expand All @@ -57,7 +68,7 @@ describe('LocalStrategy', () => {
return undefined;
});

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

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

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

await expect(localStrategy.validate(testEmail, testPassword)).rejects.toMatchObject({
await expect(localStrategy.validate(mokeReq, testEmail, testPassword)).rejects.toMatchObject({
response: 'Email or password is incorrect',
});
expect(cacheService.setDetail).toHaveBeenCalledWith(`signin:attempts:${testEmail}`, 1, 30);
Expand Down
14 changes: 12 additions & 2 deletions apps/nestjs-backend/src/features/auth/strategies/local.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { PassportStrategy } from '@nestjs/passport';
import { HttpErrorCode } from '@teable/core';
import type { Request } from 'express';
import { Strategy } from 'passport-local';
import { CacheService } from '../../../cache/cache.service';
import { AuthConfig, IAuthConfig } from '../../../configs/auth.config';
Expand All @@ -21,12 +22,21 @@ export class LocalStrategy extends PassportStrategy(Strategy) {
super({
usernameField: 'email',
passwordField: 'password',
passReqToCallback: true,
});
}

async validate(email: string, password: string) {
async validate(req: Request, email: string, password: string) {
try {
const user = await this.authService.validateUserByEmail(email, password);
const turnstileToken = req.body?.turnstileToken;
const remoteIp =
req.ip || req.connection.remoteAddress || (req.headers['x-forwarded-for'] as string);
const user = await this.authService.validateUserByEmailWithTurnstile(
email,
password,
turnstileToken,
remoteIp
);
if (!user) {
throw new CustomHttpException(
'Email or password is incorrect',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { TurnstileService } from './turnstile.service';

@Module({
providers: [TurnstileService],
exports: [TurnstileService],
})
export class TurnstileModule {}
Loading
Loading