-
Notifications
You must be signed in to change notification settings - Fork 92
feat(captcha): add Cloudflare Turnstile verification for protected endpoints #2892
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,9 @@ | ||
| import { Module } from '@nestjs/common'; | ||
| import { CaptchaService } from '@/routes/captcha/captcha.service'; | ||
| import { CaptchaGuard } from '@/routes/captcha/guards/captcha.guard'; | ||
|
|
||
| @Module({ | ||
| providers: [CaptchaService, CaptchaGuard], | ||
| exports: [CaptchaGuard, CaptchaService], | ||
| }) | ||
| export class CaptchaModule {} |
| Original file line number | Diff line number | Diff line change | ||
|---|---|---|---|---|
| @@ -0,0 +1,80 @@ | ||||
| import { Inject, Injectable } from '@nestjs/common'; | ||||
| import { IConfigurationService } from '@/config/configuration.service.interface'; | ||||
| import { | ||||
| NetworkService, | ||||
| type INetworkService, | ||||
| } from '@/datasources/network/network.service.interface'; | ||||
| import { ILoggingService, LoggingService } from '@/logging/logging.interface'; | ||||
|
|
||||
| interface TurnstileVerifyResponse { | ||||
| success: boolean; | ||||
| 'error-codes'?: Array<string>; | ||||
| challenge_ts?: string; | ||||
| hostname?: string; | ||||
| } | ||||
|
|
||||
| @Injectable() | ||||
| export class CaptchaService { | ||||
| private readonly verifyUrl = | ||||
| 'https://challenges.cloudflare.com/turnstile/v0/siteverify'; | ||||
|
|
||||
| constructor( | ||||
| @Inject(IConfigurationService) | ||||
| private readonly configurationService: IConfigurationService, | ||||
| @Inject(NetworkService) | ||||
| private readonly networkService: INetworkService, | ||||
| @Inject(LoggingService) | ||||
| private readonly loggingService: ILoggingService, | ||||
| ) {} | ||||
|
|
||||
| async verifyToken(token: string, remoteip?: string): Promise<boolean> { | ||||
| const isEnabled = this.configurationService.get<boolean>('captcha.enabled'); | ||||
| if (!isEnabled) { | ||||
| return true; | ||||
| } | ||||
|
|
||||
| const secretKey = | ||||
| this.configurationService.get<string>('captcha.secretKey'); | ||||
| if (!secretKey) { | ||||
| this.loggingService.warn( | ||||
| 'CAPTCHA is enabled but secret key is not configured', | ||||
| ); | ||||
| return false; | ||||
| } | ||||
|
|
||||
| if (!token) { | ||||
| return false; | ||||
| } | ||||
|
|
||||
| try { | ||||
| const response = await this.networkService.post<TurnstileVerifyResponse>({ | ||||
| url: this.verifyUrl, | ||||
| data: { | ||||
| secret: secretKey, | ||||
| response: token, | ||||
| ...(remoteip && { remoteip }), | ||||
| }, | ||||
| }); | ||||
|
|
||||
| // response.data is Raw<TurnstileVerifyResponse>, cast to actual type | ||||
| const verifyResponse = | ||||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. We usually validate the responses coming from the
|
||||
| response.data as unknown as TurnstileVerifyResponse; | ||||
| const isValid = verifyResponse?.success === true; | ||||
|
|
||||
| if (!isValid) { | ||||
| this.loggingService.debug({ | ||||
| type: 'captcha_verification_failed', | ||||
| errorCodes: verifyResponse?.['error-codes'] || [], | ||||
| }); | ||||
| } | ||||
|
|
||||
| return isValid; | ||||
| } catch (error) { | ||||
| this.loggingService.error({ | ||||
| type: 'captcha_verification_error', | ||||
| error: error instanceof Error ? error.message : String(error), | ||||
| }); | ||||
| return false; | ||||
| } | ||||
| } | ||||
| } | ||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,46 @@ | ||
| import { | ||
| CanActivate, | ||
| ExecutionContext, | ||
| Inject, | ||
| Injectable, | ||
| UnauthorizedException, | ||
| } from '@nestjs/common'; | ||
| import { Request } from 'express'; | ||
| import { CaptchaService } from '@/routes/captcha/captcha.service'; | ||
| import { IConfigurationService } from '@/config/configuration.service.interface'; | ||
|
|
||
| @Injectable() | ||
| export class CaptchaGuard implements CanActivate { | ||
| constructor( | ||
| private readonly captchaService: CaptchaService, | ||
| @Inject(IConfigurationService) | ||
| private readonly configurationService: IConfigurationService, | ||
| ) {} | ||
|
|
||
| async canActivate(context: ExecutionContext): Promise<boolean> { | ||
| const isEnabled = this.configurationService.get<boolean>('captcha.enabled'); | ||
| if (!isEnabled) { | ||
| return true; | ||
| } | ||
|
|
||
| const request: Request = context.switchToHttp().getRequest(); | ||
| const token = request.headers['x-captcha-token'] as string | undefined; | ||
|
|
||
| if (!token) { | ||
| throw new UnauthorizedException('CAPTCHA token is required'); | ||
| } | ||
|
|
||
| const remoteip = | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Nit(recommendation): We could extract this to a separate helper for reusability as we are fetching the client IP in different places. |
||
| (request.headers['x-forwarded-for'] as string)?.split(',')[0]?.trim() || | ||
| request.ip || | ||
| request.socket.remoteAddress; | ||
|
|
||
| const isValid = await this.captchaService.verifyToken(token, remoteip); | ||
|
|
||
| if (!isValid) { | ||
| throw new UnauthorizedException('Invalid CAPTCHA token'); | ||
| } | ||
|
|
||
| return true; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We should extract it to a separate interface/entity file.