From 935771c8a8939a95c82c8702ced76a4ea7f660cc Mon Sep 17 00:00:00 2001 From: darkskygit Date: Tue, 3 Sep 2024 09:03:51 +0000 Subject: [PATCH] feat(server): make captcha modular (#5961) --- .../charts/graphql/templates/deployment.yaml | 2 - .../backend/server/src/config/affine.env.ts | 1 + .../backend/server/src/config/affine.self.ts | 8 ++ packages/backend/server/src/config/affine.ts | 9 ++ .../server/src/core/auth/controller.ts | 14 +- .../backend/server/src/core/auth/index.ts | 2 +- .../backend/server/src/core/auth/token.ts | 19 ++- .../backend/server/src/core/config/types.ts | 1 + .../server/src/fundamentals/error/def.ts | 6 + .../src/fundamentals/error/errors.gen.ts | 9 +- .../server/src/fundamentals/guard/guard.ts | 50 +++++++ .../server/src/fundamentals/guard/index.ts | 2 + .../server/src/fundamentals/guard/provider.ts | 26 ++++ .../backend/server/src/fundamentals/index.ts | 1 + .../server/src/plugins/captcha/config.ts | 23 ++++ .../server/src/plugins/captcha/controller.ts | 17 +++ .../server/src/plugins/captcha/guard.ts | 40 ++++++ .../server/src/plugins/captcha/index.ts | 18 +++ .../server/src/plugins/captcha/service.ts | 123 ++++++++++++++++++ .../server/src/plugins/captcha/types.ts | 28 ++++ packages/backend/server/src/plugins/index.ts | 1 + packages/backend/server/src/schema.gql | 2 + packages/common/env/src/global.ts | 1 - .../affine/auth/sign-in-with-password.tsx | 6 +- .../components/affine/auth/use-captcha.tsx | 19 ++- .../core/src/modules/cloud/services/auth.ts | 54 ++++---- packages/frontend/graphql/src/schema.ts | 2 + tools/cli/src/webpack/runtime-config.ts | 6 - 28 files changed, 432 insertions(+), 58 deletions(-) create mode 100644 packages/backend/server/src/fundamentals/guard/guard.ts create mode 100644 packages/backend/server/src/fundamentals/guard/index.ts create mode 100644 packages/backend/server/src/fundamentals/guard/provider.ts create mode 100644 packages/backend/server/src/plugins/captcha/config.ts create mode 100644 packages/backend/server/src/plugins/captcha/controller.ts create mode 100644 packages/backend/server/src/plugins/captcha/guard.ts create mode 100644 packages/backend/server/src/plugins/captcha/index.ts create mode 100644 packages/backend/server/src/plugins/captcha/service.ts create mode 100644 packages/backend/server/src/plugins/captcha/types.ts diff --git a/.github/helm/affine/charts/graphql/templates/deployment.yaml b/.github/helm/affine/charts/graphql/templates/deployment.yaml index 679bea605..2f74b5cde 100644 --- a/.github/helm/affine/charts/graphql/templates/deployment.yaml +++ b/.github/helm/affine/charts/graphql/templates/deployment.yaml @@ -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 diff --git a/packages/backend/server/src/config/affine.env.ts b/packages/backend/server/src/config/affine.env.ts index 14fdb185b..8fc277c12 100644 --- a/packages/backend/server/src/config/affine.env.ts +++ b/packages/backend/server/src/config/affine.env.ts @@ -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', diff --git a/packages/backend/server/src/config/affine.self.ts b/packages/backend/server/src/config/affine.self.ts index 1e191cea4..7d211cbad 100644 --- a/packages/backend/server/src/config/affine.self.ts +++ b/packages/backend/server/src/config/affine.self.ts @@ -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', diff --git a/packages/backend/server/src/config/affine.ts b/packages/backend/server/src/config/affine.ts index 470fb8afb..7cff0cfbb 100644 --- a/packages/backend/server/src/config/affine.ts +++ b/packages/backend/server/src/config/affine.ts @@ -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', { diff --git a/packages/backend/server/src/core/auth/controller.ts b/packages/backend/server/src/core/auth/controller.ts index 07b2a6d34..6aaef286e 100644 --- a/packages/backend/server/src/core/auth/controller.ts +++ b/packages/backend/server/src/core/auth/controller.ts @@ -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(), - }; - } } diff --git a/packages/backend/server/src/core/auth/index.ts b/packages/backend/server/src/core/auth/index.ts index 4b754b43b..f698c57fb 100644 --- a/packages/backend/server/src/core/auth/index.ts +++ b/packages/backend/server/src/core/auth/index.ts @@ -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 {} diff --git a/packages/backend/server/src/core/auth/token.ts b/packages/backend/server/src/core/auth/token.ts index 042262176..5eed3435f 100644 --- a/packages/backend/server/src/core/auth/token.ts +++ b/packages/backend/server/src/core/auth/token.ts @@ -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({ diff --git a/packages/backend/server/src/core/config/types.ts b/packages/backend/server/src/core/config/types.ts index 169239ce1..5af8bfef1 100644 --- a/packages/backend/server/src/core/config/types.ts +++ b/packages/backend/server/src/core/config/types.ts @@ -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', diff --git a/packages/backend/server/src/fundamentals/error/def.ts b/packages/backend/server/src/fundamentals/error/def.ts index 2b4b4c964..0a972c424 100644 --- a/packages/backend/server/src/fundamentals/error/def.ts +++ b/packages/backend/server/src/fundamentals/error/def.ts @@ -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; diff --git a/packages/backend/server/src/fundamentals/error/errors.gen.ts b/packages/backend/server/src/fundamentals/error/errors.gen.ts index b63a85506..156cf8892 100644 --- a/packages/backend/server/src/fundamentals/error/errors.gen.ts +++ b/packages/backend/server/src/fundamentals/error/errors.gen.ts @@ -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' diff --git a/packages/backend/server/src/fundamentals/guard/guard.ts b/packages/backend/server/src/fundamentals/guard/guard.ts new file mode 100644 index 000000000..e747ee6f0 --- /dev/null +++ b/packages/backend/server/src/fundamentals/guard/guard.ts @@ -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( + 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)); diff --git a/packages/backend/server/src/fundamentals/guard/index.ts b/packages/backend/server/src/fundamentals/guard/index.ts new file mode 100644 index 000000000..f0deb1985 --- /dev/null +++ b/packages/backend/server/src/fundamentals/guard/index.ts @@ -0,0 +1,2 @@ +export { UseNamedGuard } from './guard'; +export { GuardProvider, type RegisterGuardName } from './provider'; diff --git a/packages/backend/server/src/fundamentals/guard/provider.ts b/packages/backend/server/src/fundamentals/guard/provider.ts new file mode 100644 index 000000000..45501f633 --- /dev/null +++ b/packages/backend/server/src/fundamentals/guard/provider.ts @@ -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> = {}; + +@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; +} diff --git a/packages/backend/server/src/fundamentals/index.ts b/packages/backend/server/src/fundamentals/index.ts index ec3f90736..469530c3f 100644 --- a/packages/backend/server/src/fundamentals/index.ts +++ b/packages/backend/server/src/fundamentals/index.ts @@ -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'; diff --git a/packages/backend/server/src/plugins/captcha/config.ts b/packages/backend/server/src/plugins/captcha/config.ts new file mode 100644 index 000000000..1624002ff --- /dev/null +++ b/packages/backend/server/src/plugins/captcha/config.ts @@ -0,0 +1,23 @@ +import { defineStartupConfig, ModuleConfig } from '../../fundamentals/config'; +import { CaptchaConfig } from './types'; + +declare module '../config' { + interface PluginsConfig { + captcha: ModuleConfig; + } +} + +declare module '../../fundamentals/guard' { + interface RegisterGuardName { + captcha: 'captcha'; + } +} + +defineStartupConfig('plugins.captcha', { + turnstile: { + secret: '', + }, + challenge: { + bits: 20, + }, +}); diff --git a/packages/backend/server/src/plugins/captcha/controller.ts b/packages/backend/server/src/plugins/captcha/controller.ts new file mode 100644 index 000000000..58f6f09b7 --- /dev/null +++ b/packages/backend/server/src/plugins/captcha/controller.ts @@ -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(); + } +} diff --git a/packages/backend/server/src/plugins/captcha/guard.ts b/packages/backend/server/src/plugins/captcha/guard.ts new file mode 100644 index 000000000..b04bf73ca --- /dev/null +++ b/packages/backend/server/src/plugins/captcha/guard.ts @@ -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; + } +} diff --git a/packages/backend/server/src/plugins/captcha/index.ts b/packages/backend/server/src/plugins/captcha/index.ts new file mode 100644 index 000000000..ce24e0916 --- /dev/null +++ b/packages/backend/server/src/plugins/captcha/index.ts @@ -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'; diff --git a/packages/backend/server/src/plugins/captcha/service.ts b/packages/backend/server/src/plugins/captcha/service.ts new file mode 100644 index 000000000..e3f35974f --- /dev/null +++ b/packages/backend/server/src/plugins/captcha/service.ts @@ -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; + +@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'); + } + } + } +} diff --git a/packages/backend/server/src/plugins/captcha/types.ts b/packages/backend/server/src/plugins/captcha/types.ts new file mode 100644 index 000000000..7935914bc --- /dev/null +++ b/packages/backend/server/src/plugins/captcha/types.ts @@ -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; +} diff --git a/packages/backend/server/src/plugins/index.ts b/packages/backend/server/src/plugins/index.ts index 1eeb08f2b..2c8928008 100644 --- a/packages/backend/server/src/plugins/index.ts +++ b/packages/backend/server/src/plugins/index.ts @@ -1,3 +1,4 @@ +import './captcha'; import './copilot'; import './gcloud'; import './oauth'; diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index c3b0305ae..32db5c43a 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -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 diff --git a/packages/common/env/src/global.ts b/packages/common/env/src/global.ts index 725f89909..d54362efc 100644 --- a/packages/common/env/src/global.ts +++ b/packages/common/env/src/global.ts @@ -26,7 +26,6 @@ export const runtimeFlagsSchema = z.object({ allowLocalWorkspace: z.boolean(), enablePreloading: z.boolean(), enableNewSettingUnstableApi: z.boolean(), - enableCaptcha: z.boolean(), enableEnhanceShareMode: z.boolean(), enableExperimentalFeature: z.boolean(), enableInfoModal: z.boolean(), diff --git a/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx b/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx index 96306b9ee..b205c1672 100644 --- a/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx +++ b/packages/frontend/core/src/components/affine/auth/sign-in-with-password.tsx @@ -30,13 +30,15 @@ export const SignInWithPassword: FC> = ({ const [sendingEmail, setSendingEmail] = useState(false); const onSignIn = useAsyncCallback(async () => { - if (isLoading) return; + if (isLoading || !verifyToken) return; setIsLoading(true); try { await authService.signInPassword({ email, password, + verifyToken, + challenge, }); } catch (err) { console.error(err); @@ -44,7 +46,7 @@ export const SignInWithPassword: FC> = ({ } finally { setIsLoading(false); } - }, [isLoading, authService, email, password]); + }, [isLoading, authService, email, password, verifyToken, challenge]); const sendMagicLink = useAsyncCallback(async () => { if (sendingEmail) return; diff --git a/packages/frontend/core/src/components/affine/auth/use-captcha.tsx b/packages/frontend/core/src/components/affine/auth/use-captcha.tsx index a00b8923b..b887e32ae 100644 --- a/packages/frontend/core/src/components/affine/auth/use-captcha.tsx +++ b/packages/frontend/core/src/components/affine/auth/use-captcha.tsx @@ -1,9 +1,11 @@ import { apis } from '@affine/electron-api'; import { Turnstile } from '@marsidev/react-turnstile'; +import { useLiveData, useService } from '@toeverything/infra'; import { atom, useAtom, useSetAtom } from 'jotai'; import { useEffect, useRef } from 'react'; import useSWR from 'swr'; +import { ServerConfigService } from '../../../modules/cloud'; import * as style from './style.css'; type Challenge = { @@ -27,6 +29,7 @@ const challengeFetcher = async (url: string) => { return challenge; }; + const generateChallengeResponse = async (challenge: string) => { if (!environment.isDesktop) { return undefined; @@ -38,11 +41,18 @@ const generateChallengeResponse = async (challenge: string) => { const captchaAtom = atom(undefined); const responseAtom = atom(undefined); +const useHasCaptcha = () => { + const serverConfig = useService(ServerConfigService).serverConfig; + const hasCaptcha = useLiveData(serverConfig.features$.map(r => r?.captcha)); + return hasCaptcha || false; +}; + export const Captcha = () => { const setCaptcha = useSetAtom(captchaAtom); const [response] = useAtom(responseAtom); + const hasCaptchaFeature = useHasCaptcha(); - if (!runtimeConfig.enableCaptcha) { + if (!hasCaptchaFeature) { return null; } @@ -66,6 +76,7 @@ export const Captcha = () => { export const useCaptcha = (): [string | undefined, string?] => { const [verifyToken] = useAtom(captchaAtom); const [response, setResponse] = useAtom(responseAtom); + const hasCaptchaFeature = useHasCaptcha(); const { data: challenge } = useSWR('/api/auth/challenge', challengeFetcher, { suspense: false, @@ -75,7 +86,7 @@ export const useCaptcha = (): [string | undefined, string?] => { useEffect(() => { if ( - runtimeConfig.enableCaptcha && + hasCaptchaFeature && environment.isDesktop && challenge?.challenge && prevChallenge.current !== challenge.challenge @@ -87,9 +98,9 @@ export const useCaptcha = (): [string | undefined, string?] => { console.error('Error getting challenge response:', err); }); } - }, [challenge, setResponse]); + }, [challenge, hasCaptchaFeature, setResponse]); - if (!runtimeConfig.enableCaptcha) { + if (!hasCaptchaFeature) { return ['XXXX.DUMMY.TOKEN.XXXX']; } diff --git a/packages/frontend/core/src/modules/cloud/services/auth.ts b/packages/frontend/core/src/modules/cloud/services/auth.ts index 3fed83627..ceb3c8a4a 100644 --- a/packages/frontend/core/src/modules/cloud/services/auth.ts +++ b/packages/frontend/core/src/modules/cloud/services/auth.ts @@ -82,27 +82,19 @@ export class AuthService extends Service { verifyToken: string, challenge?: string ) { - const searchParams = new URLSearchParams(); - if (challenge) { - searchParams.set('challenge', challenge); - } - searchParams.set('token', verifyToken); - - const res = await this.fetchService.fetch( - '/api/auth/sign-in?' + searchParams.toString(), - { - method: 'POST', - body: JSON.stringify({ - email, - // we call it [callbackUrl] instead of [redirect_uri] - // to make it clear the url is used to finish the sign-in process instead of redirect after signed-in - callbackUrl: `/magic-link?client=${environment.isDesktop ? appInfo?.schema : 'web'}`, - }), - headers: { - 'content-type': 'application/json', - }, - } - ); + const res = await this.fetchService.fetch('/api/auth/sign-in', { + method: 'POST', + body: JSON.stringify({ + email, + // we call it [callbackUrl] instead of [redirect_uri] + // to make it clear the url is used to finish the sign-in process instead of redirect after signed-in + callbackUrl: `/magic-link?client=${environment.isDesktop ? appInfo?.schema : 'web'}`, + }), + headers: { + 'content-type': 'application/json', + ...this.captchaHeaders(verifyToken, challenge), + }, + }); if (!res.ok) { throw new Error('Failed to send email'); } @@ -159,12 +151,18 @@ export class AuthService extends Service { return await res.json(); } - async signInPassword(credential: { email: string; password: string }) { + async signInPassword(credential: { + email: string; + password: string; + verifyToken: string; + challenge?: string; + }) { const res = await this.fetchService.fetch('/api/auth/sign-in', { method: 'POST', body: JSON.stringify(credential), headers: { 'content-type': 'application/json', + ...this.captchaHeaders(credential.verifyToken, credential.challenge), }, }); if (!res.ok) { @@ -182,4 +180,16 @@ export class AuthService extends Service { checkUserByEmail(email: string) { return this.store.checkUserByEmail(email); } + + captchaHeaders(token: string, challenge?: string) { + const headers: Record = { + 'x-captcha-token': token, + }; + + if (challenge) { + headers['x-captcha-challenge'] = challenge; + } + + return headers; + } } diff --git a/packages/frontend/graphql/src/schema.ts b/packages/frontend/graphql/src/schema.ts index b51f010fd..d0dea28ef 100644 --- a/packages/frontend/graphql/src/schema.ts +++ b/packages/frontend/graphql/src/schema.ts @@ -292,6 +292,7 @@ export enum ErrorNames { CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT', CANT_CHANGE_SPACE_OWNER = 'CANT_CHANGE_SPACE_OWNER', CANT_UPDATE_LIFETIME_SUBSCRIPTION = 'CANT_UPDATE_LIFETIME_SUBSCRIPTION', + CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED', COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN', COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE', COPILOT_FAILED_TO_GENERATE_TEXT = 'COPILOT_FAILED_TO_GENERATE_TEXT', @@ -965,6 +966,7 @@ export enum ServerDeploymentType { } export enum ServerFeature { + Captcha = 'Captcha', Copilot = 'Copilot', OAuth = 'OAuth', Payment = 'Payment', diff --git a/tools/cli/src/webpack/runtime-config.ts b/tools/cli/src/webpack/runtime-config.ts index 8ae58c1ba..03db03843 100644 --- a/tools/cli/src/webpack/runtime-config.ts +++ b/tools/cli/src/webpack/runtime-config.ts @@ -17,7 +17,6 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { imageProxyUrl: '/api/worker/image-proxy', linkPreviewUrl: '/api/worker/link-preview', enablePreloading: true, - enableCaptcha: true, enableExperimentalFeature: true, allowLocalWorkspace: buildFlags.distribution === 'desktop' ? true : false, @@ -76,11 +75,6 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig { enableNewSettingUnstableApi: process.env.ENABLE_NEW_SETTING_UNSTABLE_API ? process.env.ENABLE_NEW_SETTING_UNSTABLE_API === 'true' : currentBuildPreset.enableNewSettingUnstableApi, - enableCaptcha: process.env.ENABLE_CAPTCHA - ? process.env.ENABLE_CAPTCHA === 'true' - : buildFlags.mode === 'development' - ? false - : currentBuildPreset.enableCaptcha, enableEnhanceShareMode: process.env.ENABLE_ENHANCE_SHARE_MODE ? process.env.ENABLE_ENHANCE_SHARE_MODE === 'true' : currentBuildPreset.enableEnhanceShareMode,