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

View File

@@ -25,6 +25,7 @@ AFFiNE.ENV_MAP = {
OAUTH_OIDC_CLAIM_MAP_EMAIL: 'plugins.oauth.providers.oidc.args.claim_email',
OAUTH_OIDC_CLAIM_MAP_NAME: 'plugins.oauth.providers.oidc.args.claim_name',
METRICS_CUSTOMER_IO_TOKEN: ['metrics.customerIo.token', 'string'],
CAPTCHA_TURNSTILE_SECRET: ['plugins.captcha.turnstile.secret', 'string'],
COPILOT_OPENAI_API_KEY: 'plugins.copilot.openai.apiKey',
COPILOT_FAL_API_KEY: 'plugins.copilot.fal.apiKey',
COPILOT_UNSPLASH_API_KEY: 'plugins.copilot.unsplashKey',

View File

@@ -71,6 +71,14 @@ AFFiNE.use('payment', {
});
AFFiNE.use('oauth');
/* Captcha Plugin Default Config */
AFFiNE.use('captcha', {
turnstile: {},
challenge: {
bits: 20,
},
});
if (AFFiNE.deploy) {
AFFiNE.mailer = {
service: 'gmail',

View File

@@ -95,6 +95,15 @@ AFFiNE.server.port = 3010;
// });
//
//
// /* Captcha Plugin Default Config */
// AFFiNE.plugins.use('captcha', {
// turnstile: {},
// challenge: {
// bits: 20,
// },
// });
//
//
// /* Cloudflare R2 Plugin */
// /* Enable if you choose to store workspace blobs or user avatars in Cloudflare R2 Storage Service */
// AFFiNE.use('cloudflare-r2', {

View File

@@ -1,5 +1,3 @@
import { randomUUID } from 'node:crypto';
import {
Body,
Controller,
@@ -23,6 +21,7 @@ import {
SignUpForbidden,
Throttle,
URLHelper,
UseNamedGuard,
} from '../../fundamentals';
import { UserService } from '../user';
import { validators } from '../utils/validators';
@@ -86,6 +85,7 @@ export class AuthController {
}
@Public()
@UseNamedGuard('captcha')
@Post('/sign-in')
@Header('content-type', 'application/json')
async signIn(
@@ -237,14 +237,4 @@ export class AuthController {
users: await this.auth.getUserList(token),
};
}
@Public()
@Get('/challenge')
async challenge() {
// TODO(@darksky): impl in following PR
return {
challenge: randomUUID(),
resource: randomUUID(),
};
}
}

View File

@@ -20,7 +20,7 @@ import { TokenService, TokenType } from './token';
AuthGuard,
AuthWebsocketOptionsProvider,
],
exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider],
exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider, TokenService],
controllers: [AuthController],
})
export class AuthModule {}

View File

@@ -69,13 +69,9 @@ export class TokenService {
const valid =
!expired && (!record.credential || record.credential === credential);
if ((expired || valid) && !keep) {
const deleted = await this.db.verificationToken.deleteMany({
where: {
token,
type,
},
});
// always revoke expired token
if (expired || (valid && !keep)) {
const deleted = await this.revokeToken(type, token);
// already deleted, means token has been used
if (!deleted.count) {
@@ -86,6 +82,15 @@ export class TokenService {
return valid ? record : null;
}
async revokeToken(type: TokenType, token: string) {
return await this.db.verificationToken.deleteMany({
where: {
token,
type,
},
});
}
@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async cleanExpiredTokens() {
await this.db.verificationToken.deleteMany({

View File

@@ -3,6 +3,7 @@ import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { DeploymentType } from '../../fundamentals';
export enum ServerFeature {
Captcha = 'captcha',
Copilot = 'copilot',
Payment = 'payment',
OAuth = 'oauth',

View File

@@ -526,4 +526,10 @@ export const USER_FRIENDLY_ERRORS = {
type: 'action_forbidden',
message: 'Cannot delete own account.',
},
// captcha errors
captcha_verification_failed: {
type: 'bad_request',
message: 'Captcha verification failed.',
},
} satisfies Record<string, UserFriendlyErrorOptions>;

View File

@@ -533,6 +533,12 @@ export class CannotDeleteOwnAccount extends UserFriendlyError {
super('action_forbidden', 'cannot_delete_own_account', message);
}
}
export class CaptchaVerificationFailed extends UserFriendlyError {
constructor(message?: string) {
super('bad_request', 'captcha_verification_failed', message);
}
}
export enum ErrorNames {
INTERNAL_SERVER_ERROR,
TOO_MANY_REQUEST,
@@ -604,7 +610,8 @@ export enum ErrorNames {
INVALID_RUNTIME_CONFIG_TYPE,
MAILER_SERVICE_IS_NOT_CONFIGURED,
CANNOT_DELETE_ALL_ADMIN_ACCOUNT,
CANNOT_DELETE_OWN_ACCOUNT
CANNOT_DELETE_OWN_ACCOUNT,
CAPTCHA_VERIFICATION_FAILED
}
registerEnumType(ErrorNames, {
name: 'ErrorNames'

View File

@@ -0,0 +1,50 @@
import {
applyDecorators,
CanActivate,
ExecutionContext,
Injectable,
SetMetadata,
UseGuards,
} from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { GUARD_PROVIDER, NamedGuards } from './provider';
const BasicGuardSymbol = Symbol('BasicGuard');
@Injectable()
export class BasicGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
async canActivate(context: ExecutionContext) {
// get registered guard name
const providerName = this.reflector.get<string>(
BasicGuardSymbol,
context.getHandler()
);
const provider = GUARD_PROVIDER[providerName as NamedGuards];
if (provider) {
return await provider.canActivate(context);
}
return true;
}
}
/**
* This guard is used to protect routes/queries/mutations that use a registered guard
*
* @example
*
* ```typescript
* \@UseNamedGuard('captcha') // use captcha guard
* \@Auth()
* \@Query(() => UserType)
* user(@CurrentUser() user: CurrentUser) {
* return user;
* }
* ```
*/
export const UseNamedGuard = (name: NamedGuards) =>
applyDecorators(UseGuards(BasicGuard), SetMetadata(BasicGuardSymbol, name));

View File

@@ -0,0 +1,2 @@
export { UseNamedGuard } from './guard';
export { GuardProvider, type RegisterGuardName } from './provider';

View File

@@ -0,0 +1,26 @@
import {
CanActivate,
ExecutionContext,
Injectable,
Logger,
OnModuleInit,
} from '@nestjs/common';
export interface RegisterGuardName {}
export type NamedGuards = keyof RegisterGuardName;
export const GUARD_PROVIDER: Partial<Record<NamedGuards, GuardProvider>> = {};
@Injectable()
export abstract class GuardProvider implements OnModuleInit, CanActivate {
private readonly logger = new Logger(GuardProvider.name);
abstract name: NamedGuards;
onModuleInit() {
GUARD_PROVIDER[this.name] = this;
this.logger.log(`Guard provider [${this.name}] registered`);
}
abstract canActivate(context: ExecutionContext): boolean | Promise<boolean>;
}

View File

@@ -16,6 +16,7 @@ export {
export * from './error';
export { EventEmitter, type EventPayload, OnEvent } from './event';
export type { GraphqlContext } from './graphql';
export * from './guard';
export { CryptoHelper, URLHelper } from './helpers';
export { MailService } from './mailer';
export { CallCounter, CallTimer, metrics } from './metrics';

View File

@@ -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,
},
});

View File

@@ -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();
}
}

View File

@@ -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;
}
}

View File

@@ -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';

View File

@@ -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');
}
}
}
}

View File

@@ -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;
}

View File

@@ -1,3 +1,4 @@
import './captcha';
import './copilot';
import './gcloud';
import './oauth';

View File

@@ -218,6 +218,7 @@ enum ErrorNames {
CANNOT_DELETE_OWN_ACCOUNT
CANT_CHANGE_SPACE_OWNER
CANT_UPDATE_LIFETIME_SUBSCRIPTION
CAPTCHA_VERIFICATION_FAILED
COPILOT_ACTION_TAKEN
COPILOT_FAILED_TO_CREATE_MESSAGE
COPILOT_FAILED_TO_GENERATE_TEXT
@@ -675,6 +676,7 @@ enum ServerDeploymentType {
}
enum ServerFeature {
Captcha
Copilot
OAuth
Payment