mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 10:21:35 +03:00
feat(server): make captcha modular (#5961)
This commit is contained in:
parent
52c9da67f0
commit
935771c8a8
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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',
|
||||
|
@ -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', {
|
||||
|
@ -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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
@ -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 {}
|
||||
|
@ -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({
|
||||
|
@ -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',
|
||||
|
@ -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>;
|
||||
|
@ -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'
|
||||
|
50
packages/backend/server/src/fundamentals/guard/guard.ts
Normal file
50
packages/backend/server/src/fundamentals/guard/guard.ts
Normal 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));
|
2
packages/backend/server/src/fundamentals/guard/index.ts
Normal file
2
packages/backend/server/src/fundamentals/guard/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export { UseNamedGuard } from './guard';
|
||||
export { GuardProvider, type RegisterGuardName } from './provider';
|
26
packages/backend/server/src/fundamentals/guard/provider.ts
Normal file
26
packages/backend/server/src/fundamentals/guard/provider.ts
Normal 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>;
|
||||
}
|
@ -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';
|
||||
|
23
packages/backend/server/src/plugins/captcha/config.ts
Normal file
23
packages/backend/server/src/plugins/captcha/config.ts
Normal 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,
|
||||
},
|
||||
});
|
17
packages/backend/server/src/plugins/captcha/controller.ts
Normal file
17
packages/backend/server/src/plugins/captcha/controller.ts
Normal 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();
|
||||
}
|
||||
}
|
40
packages/backend/server/src/plugins/captcha/guard.ts
Normal file
40
packages/backend/server/src/plugins/captcha/guard.ts
Normal 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;
|
||||
}
|
||||
}
|
18
packages/backend/server/src/plugins/captcha/index.ts
Normal file
18
packages/backend/server/src/plugins/captcha/index.ts
Normal 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';
|
123
packages/backend/server/src/plugins/captcha/service.ts
Normal file
123
packages/backend/server/src/plugins/captcha/service.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
28
packages/backend/server/src/plugins/captcha/types.ts
Normal file
28
packages/backend/server/src/plugins/captcha/types.ts
Normal 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;
|
||||
}
|
@ -1,3 +1,4 @@
|
||||
import './captcha';
|
||||
import './copilot';
|
||||
import './gcloud';
|
||||
import './oauth';
|
||||
|
@ -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
|
||||
|
1
packages/common/env/src/global.ts
vendored
1
packages/common/env/src/global.ts
vendored
@ -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(),
|
||||
|
@ -30,13 +30,15 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
|
||||
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<AuthPanelProps<'signInWithPassword'>> = ({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [isLoading, authService, email, password]);
|
||||
}, [isLoading, authService, email, password, verifyToken, challenge]);
|
||||
|
||||
const sendMagicLink = useAsyncCallback(async () => {
|
||||
if (sendingEmail) return;
|
||||
|
@ -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<string | undefined>(undefined);
|
||||
const responseAtom = atom<string | undefined>(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'];
|
||||
}
|
||||
|
||||
|
@ -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<string, string> = {
|
||||
'x-captcha-token': token,
|
||||
};
|
||||
|
||||
if (challenge) {
|
||||
headers['x-captcha-challenge'] = challenge;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
}
|
||||
|
@ -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',
|
||||
|
@ -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,
|
||||
|
Loading…
Reference in New Issue
Block a user