Skip to content
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

feat: make captcha modular #5961

Open
wants to merge 23 commits into
base: canary
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 0 additions & 2 deletions .github/helm/affine/charts/graphql/templates/deployment.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -77,8 +77,6 @@ spec:
value: "{{ .Values.app.https }}"
- name: ENABLE_R2_OBJECT_STORAGE
value: "{{ .Values.app.objectStorage.r2.enabled }}"
- name: ENABLE_CAPTCHA
value: "{{ .Values.app.captcha.enabled }}"
- name: FEATURES_EARLY_ACCESS_PREVIEW
value: "{{ .Values.app.features.earlyAccessPreview }}"
- name: FEATURES_SYNC_CLIENT_VERSION_CHECK
Expand Down
1 change: 1 addition & 0 deletions packages/backend/server/src/config/affine.env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,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',
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/server/src/config/affine.self.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
9 changes: 9 additions & 0 deletions packages/backend/server/src/config/affine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,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', {
Expand Down
16 changes: 3 additions & 13 deletions packages/backend/server/src/core/auth/controller.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { randomUUID } from 'node:crypto';

import {
BadRequestException,
Body,
Expand All @@ -15,6 +13,7 @@ import {
import type { Request, Response } from 'express';

import { Config, Throttle, URLHelper } from '../../fundamentals';
import { Captcha } from '../../plugins/captcha/guard';
import { UserService } from '../user';
import { validators } from '../utils/validators';
import { CurrentUser } from './current-user';
Expand Down Expand Up @@ -44,6 +43,7 @@ export class AuthController {
) {}

@Public()
@Captcha()
@Post('/sign-in')
@Header('content-type', 'application/json')
async signIn(
Expand Down Expand Up @@ -93,7 +93,7 @@ export class AuthController {
}
}

async sendSignInEmail(
private async sendSignInEmail(
{ email, signUp }: { email: string; signUp: boolean },
redirectUri: string
) {
Expand Down Expand Up @@ -192,14 +192,4 @@ export class AuthController {
users: await this.auth.getUserList(token),
};
}

@Public()
@Get('/challenge')
async challenge() {
// TODO: impl in following PR
return {
challenge: randomUUID(),
resource: randomUUID(),
};
}
}
2 changes: 1 addition & 1 deletion packages/backend/server/src/core/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import { TokenService, TokenType } from './token';
@Module({
imports: [FeatureModule, UserModule, QuotaModule],
providers: [AuthService, AuthResolver, TokenService, AuthGuard],
exports: [AuthService, AuthGuard],
exports: [AuthService, AuthGuard, TokenService],
controllers: [AuthController],
})
export class AuthModule {}
Expand Down
22 changes: 15 additions & 7 deletions packages/backend/server/src/core/auth/token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { PrismaClient } from '@prisma/client';

import { CryptoHelper } from '../../fundamentals/helpers';

type Transaction = Parameters<Parameters<PrismaClient['$transaction']>[0]>[0];

export enum TokenType {
SignIn,
VerifyEmail,
Expand Down Expand Up @@ -69,13 +71,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, this.db);

// already deleted, means token has been used
if (!deleted.count) {
Expand All @@ -86,6 +84,16 @@ export class TokenService {
return valid ? record : null;
}

async revokeToken(type: TokenType, token: string, tx?: Transaction) {
const client = tx || this.db;
return await client.verificationToken.deleteMany({
where: {
token,
type,
},
});
}

@Cron(CronExpression.EVERY_DAY_AT_MIDNIGHT)
async cleanExpiredTokens() {
await this.db.verificationToken.deleteMany({
Expand Down
1 change: 1 addition & 0 deletions packages/backend/server/src/core/config/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export enum ServerFeature {
Captcha = 'captcha',
Copilot = 'copilot',
Payment = 'payment',
OAuth = 'oauth',
Expand Down
61 changes: 61 additions & 0 deletions packages/backend/server/src/fundamentals/guard/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import {
CanActivate,
ExecutionContext,
Injectable,
OnModuleInit,
SetMetadata,
UseGuards,
} from '@nestjs/common';
import { ModuleRef, Reflector } from '@nestjs/core';

import { GUARD_PROVIDER } from './provider';

const BasicGuardSymbol = Symbol('BasicGuard');

@Injectable()
export class BasicGuard implements CanActivate, OnModuleInit {
constructor(
private readonly ref: ModuleRef,

Check failure on line 18 in packages/backend/server/src/fundamentals/guard/guard.ts

View workflow job for this annotation

GitHub Actions / Lint

Property 'ref' is declared but its value is never read.
private readonly reflector: Reflector
) {}

onModuleInit() {}

async canActivate(context: ExecutionContext) {
// api is public
const providerName = this.reflector.get<string>(
BasicGuardSymbol,
context.getHandler()
);

const provider = GUARD_PROVIDER[providerName];
if (provider) {
return await provider.canActivate(context);
}

return true;
}
}

/**
* This guard is used to protect routes/queries/mutations that require a user to be logged in.
*
* The `@CurrentUser()` parameter decorator used in a `Auth` guarded queries would always give us the user because the `Auth` guard will
* fast throw if user is not logged in.
*
* @example
*
* ```typescript
* \@Auth()
* \@Query(() => UserType)
* user(@CurrentUser() user: CurrentUser) {
* return user;
* }
* ```
*/
export const UseBasicGuard = () => {
return UseGuards(BasicGuard);
};

// api is public accessible
export const Guard = (name: string) => SetMetadata(BasicGuardSymbol, name);
19 changes: 19 additions & 0 deletions packages/backend/server/src/fundamentals/guard/provider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import {
CanActivate,
ExecutionContext,
Injectable,
OnModuleInit,
} from '@nestjs/common';

export const GUARD_PROVIDER: Record<string, GuardProvider> = {};

@Injectable()
export abstract class GuardProvider implements OnModuleInit, CanActivate {
onModuleInit() {
GUARD_PROVIDER[this.name] = this;
}

abstract get name(): string;

abstract canActivate(context: ExecutionContext): boolean | Promise<boolean>;
}
17 changes: 17 additions & 0 deletions packages/backend/server/src/plugins/captcha/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config';
import { CaptchaConfig } from './types';

declare module '../config' {
interface PluginsConfig {
captcha: ModuleConfig<CaptchaConfig>;
}
}

defineStartupConfig('plugins.captcha', {
turnstile: {
secret: '',
},
challenge: {
bits: 20,
},
});
56 changes: 56 additions & 0 deletions packages/backend/server/src/plugins/captcha/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import type {
CanActivate,
ExecutionContext,
OnModuleInit,
} from '@nestjs/common';
import { Injectable, UseGuards } from '@nestjs/common';
import { ModuleRef } from '@nestjs/core';

import { getRequestResponseFromContext } from '../../fundamentals';
import { CaptchaService } from './service';

@Injectable()
export class CaptchaGuard implements CanActivate, OnModuleInit {
private captcha?: CaptchaService;

constructor(private readonly ref: ModuleRef) {}

onModuleInit() {
try {
this.captcha = this.ref.get(CaptchaService, { strict: false });
} catch {
// ignore
}
}

async canActivate(context: ExecutionContext) {
const { req } = getRequestResponseFromContext(context);

const captcha = this.captcha;
if (captcha) {
const { token, challenge } = req.query;
const credential = captcha.assertValidCredential({ token, challenge });
await captcha.verifyRequest(credential, req);
}

return true;
}
}

/**
* This guard is used to protect routes/queries/mutations that require a user to be check browser env.
* It will check if the user has passed the captcha challenge.
*
* @example
*
* ```typescript
* \@Captcha()
* \@Query(() => UserType)
* protected() {
* return true;
* }
* ```
*/
export const Captcha = () => {
return UseGuards(CaptchaGuard);
};
17 changes: 17 additions & 0 deletions packages/backend/server/src/plugins/captcha/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { AuthModule } from '../../core/auth';
import { ServerFeature } from '../../core/config';
import { Plugin } from '../registry';
import { CaptchaController } from './resolver';
import { CaptchaService } from './service';

@Plugin({
name: 'captcha',
imports: [AuthModule],
providers: [CaptchaService],
controllers: [CaptchaController],
contributesTo: ServerFeature.Captcha,
requires: ['plugins.captcha.turnstile.secret'],
})
export class CaptchaModule {}

export type { CaptchaConfig } from './types';
17 changes: 17 additions & 0 deletions packages/backend/server/src/plugins/captcha/resolver.ts
Original file line number Diff line number Diff line change
@@ -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();
}
}