mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-11 23:26:30 +08:00
feat(server): make captcha modular (#5961)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user