feat(server): make captcha modular (#5961)

This commit is contained in:
darkskygit
2024-09-03 09:03:51 +00:00
parent 52c9da67f0
commit 935771c8a8
28 changed files with 432 additions and 58 deletions
@@ -0,0 +1,23 @@
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
import { CaptchaConfig } from './types';
declare module '../config' {
interface PluginsConfig {
captcha: ModuleConfig<CaptchaConfig>;
}
}
declare module '../../fundamentals/guard' {
interface RegisterGuardName {
captcha: 'captcha';
}
}
defineStartupConfig('plugins.captcha', {
turnstile: {
secret: '',
},
challenge: {
bits: 20,
},
});
@@ -0,0 +1,17 @@
import { Controller, Get } from '@nestjs/common';
import { Public } from '../../core/auth';
import { Throttle } from '../../fundamentals';
import { CaptchaService } from './service';
@Throttle('strict')
@Controller('/api/auth')
export class CaptchaController {
constructor(private readonly captcha: CaptchaService) {}
@Public()
@Get('/challenge')
async getChallenge() {
return this.captcha.getChallengeToken();
}
}
@@ -0,0 +1,40 @@
import type {
CanActivate,
ExecutionContext,
OnModuleInit,
} from '@nestjs/common';
import { Injectable } from '@nestjs/common';
import {
getRequestResponseFromContext,
GuardProvider,
} from '../../fundamentals';
import { CaptchaService } from './service';
@Injectable()
export class CaptchaGuardProvider
extends GuardProvider
implements CanActivate, OnModuleInit
{
name = 'captcha' as const;
constructor(private readonly captcha: CaptchaService) {
super();
}
async canActivate(context: ExecutionContext) {
const { req } = getRequestResponseFromContext(context);
// require headers, old client send through query string
// x-captcha-token
// x-captcha-challenge
const token = req.headers['x-captcha-token'] ?? req.query['token'];
const challenge =
req.headers['x-captcha-challenge'] ?? req.query['challenge'];
const credential = this.captcha.assertValidCredential({ token, challenge });
await this.captcha.verifyRequest(credential, req);
return true;
}
}
@@ -0,0 +1,18 @@
import { AuthModule } from '../../core/auth';
import { ServerFeature } from '../../core/config';
import { Plugin } from '../registry';
import { CaptchaController } from './controller';
import { CaptchaGuardProvider } from './guard';
import { CaptchaService } from './service';
@Plugin({
name: 'captcha',
imports: [AuthModule],
providers: [CaptchaService, CaptchaGuardProvider],
controllers: [CaptchaController],
contributesTo: ServerFeature.Captcha,
requires: ['plugins.captcha.turnstile.secret'],
})
export class CaptchaModule {}
export type { CaptchaConfig } from './types';
@@ -0,0 +1,123 @@
import assert from 'node:assert';
import { randomUUID } from 'node:crypto';
import { Injectable, Logger } from '@nestjs/common';
import type { Request } from 'express';
import { nanoid } from 'nanoid';
import { z } from 'zod';
import { TokenService, TokenType } from '../../core/auth/token';
import {
CaptchaVerificationFailed,
Config,
verifyChallengeResponse,
} from '../../fundamentals';
import { CaptchaConfig } from './types';
const validator = z
.object({ token: z.string(), challenge: z.string().optional() })
.strict();
type Credential = z.infer<typeof validator>;
@Injectable()
export class CaptchaService {
private readonly logger = new Logger(CaptchaService.name);
private readonly captcha: CaptchaConfig;
constructor(
private readonly config: Config,
private readonly token: TokenService
) {
assert(config.plugins.captcha);
this.captcha = config.plugins.captcha;
}
private async verifyCaptchaToken(token: any, ip: string) {
if (typeof token !== 'string' || !token) return false;
const formData = new FormData();
formData.append('secret', this.captcha.turnstile.secret);
formData.append('response', token);
formData.append('remoteip', ip);
// prevent replay attack
formData.append('idempotency_key', nanoid());
const url = 'https://challenges.cloudflare.com/turnstile/v0/siteverify';
const result = await fetch(url, {
body: formData,
method: 'POST',
});
const outcome = await result.json();
return (
!!outcome.success &&
// skip hostname check in dev mode
(this.config.node.dev || outcome.hostname === this.config.server.host)
);
}
private async verifyChallengeResponse(response: any, resource: string) {
return verifyChallengeResponse(
response,
this.captcha.challenge.bits,
resource
);
}
async getChallengeToken() {
const resource = randomUUID();
const challenge = await this.token.createToken(
TokenType.Challenge,
resource,
5 * 60
);
return {
challenge,
resource,
};
}
assertValidCredential(credential: any): Credential {
try {
return validator.parse(credential);
} catch {
throw new CaptchaVerificationFailed('Invalid Credential');
}
}
async verifyRequest(credential: Credential, req: Request) {
const challenge = credential.challenge;
if (typeof challenge === 'string' && challenge) {
const resource = await this.token
.verifyToken(TokenType.Challenge, challenge)
.then(token => token?.credential);
if (!resource) {
throw new CaptchaVerificationFailed('Invalid Challenge');
}
const isChallengeVerified = await this.verifyChallengeResponse(
credential.token,
resource
);
this.logger.debug(
`Challenge: ${challenge}, Resource: ${resource}, Response: ${credential.token}, isChallengeVerified: ${isChallengeVerified}`
);
if (!isChallengeVerified) {
throw new CaptchaVerificationFailed('Invalid Challenge Response');
}
} else {
const isTokenVerified = await this.verifyCaptchaToken(
credential.token,
req.headers['CF-Connecting-IP'] as string
);
if (!isTokenVerified) {
throw new CaptchaVerificationFailed('Invalid Captcha Response');
}
}
}
}
@@ -0,0 +1,28 @@
import { Field, ObjectType } from '@nestjs/graphql';
export interface CaptchaConfig {
turnstile: {
/**
* Cloudflare Turnstile CAPTCHA secret
* default value is demo api key, witch always return success
*/
secret: string;
};
challenge: {
/**
* challenge bits length
* default value is 20, which can resolve in 0.5-3 second in M2 MacBook Air in single thread
* @default 20
*/
bits: number;
};
}
@ObjectType()
export class ChallengeResponse {
@Field()
challenge!: string;
@Field()
resource!: string;
}