feat(server): make captcha modular (#5961)

This commit is contained in:
darkskygit 2024-09-03 09:03:51 +00:00
parent 52c9da67f0
commit 935771c8a8
No known key found for this signature in database
GPG Key ID: 97B7D036B1566E9D
28 changed files with 432 additions and 58 deletions

View File

@ -77,8 +77,6 @@ spec:
value: "{{ .Values.app.https }}" value: "{{ .Values.app.https }}"
- name: ENABLE_R2_OBJECT_STORAGE - name: ENABLE_R2_OBJECT_STORAGE
value: "{{ .Values.app.objectStorage.r2.enabled }}" value: "{{ .Values.app.objectStorage.r2.enabled }}"
- name: ENABLE_CAPTCHA
value: "{{ .Values.app.captcha.enabled }}"
- name: FEATURES_EARLY_ACCESS_PREVIEW - name: FEATURES_EARLY_ACCESS_PREVIEW
value: "{{ .Values.app.features.earlyAccessPreview }}" value: "{{ .Values.app.features.earlyAccessPreview }}"
- name: FEATURES_SYNC_CLIENT_VERSION_CHECK - name: FEATURES_SYNC_CLIENT_VERSION_CHECK

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_EMAIL: 'plugins.oauth.providers.oidc.args.claim_email',
OAUTH_OIDC_CLAIM_MAP_NAME: 'plugins.oauth.providers.oidc.args.claim_name', OAUTH_OIDC_CLAIM_MAP_NAME: 'plugins.oauth.providers.oidc.args.claim_name',
METRICS_CUSTOMER_IO_TOKEN: ['metrics.customerIo.token', 'string'], 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_OPENAI_API_KEY: 'plugins.copilot.openai.apiKey',
COPILOT_FAL_API_KEY: 'plugins.copilot.fal.apiKey', COPILOT_FAL_API_KEY: 'plugins.copilot.fal.apiKey',
COPILOT_UNSPLASH_API_KEY: 'plugins.copilot.unsplashKey', COPILOT_UNSPLASH_API_KEY: 'plugins.copilot.unsplashKey',

View File

@ -71,6 +71,14 @@ AFFiNE.use('payment', {
}); });
AFFiNE.use('oauth'); AFFiNE.use('oauth');
/* Captcha Plugin Default Config */
AFFiNE.use('captcha', {
turnstile: {},
challenge: {
bits: 20,
},
});
if (AFFiNE.deploy) { if (AFFiNE.deploy) {
AFFiNE.mailer = { AFFiNE.mailer = {
service: 'gmail', 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 */ // /* Cloudflare R2 Plugin */
// /* Enable if you choose to store workspace blobs or user avatars in Cloudflare R2 Storage Service */ // /* Enable if you choose to store workspace blobs or user avatars in Cloudflare R2 Storage Service */
// AFFiNE.use('cloudflare-r2', { // AFFiNE.use('cloudflare-r2', {

View File

@ -1,5 +1,3 @@
import { randomUUID } from 'node:crypto';
import { import {
Body, Body,
Controller, Controller,
@ -23,6 +21,7 @@ import {
SignUpForbidden, SignUpForbidden,
Throttle, Throttle,
URLHelper, URLHelper,
UseNamedGuard,
} from '../../fundamentals'; } from '../../fundamentals';
import { UserService } from '../user'; import { UserService } from '../user';
import { validators } from '../utils/validators'; import { validators } from '../utils/validators';
@ -86,6 +85,7 @@ export class AuthController {
} }
@Public() @Public()
@UseNamedGuard('captcha')
@Post('/sign-in') @Post('/sign-in')
@Header('content-type', 'application/json') @Header('content-type', 'application/json')
async signIn( async signIn(
@ -237,14 +237,4 @@ export class AuthController {
users: await this.auth.getUserList(token), 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, AuthGuard,
AuthWebsocketOptionsProvider, AuthWebsocketOptionsProvider,
], ],
exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider], exports: [AuthService, AuthGuard, AuthWebsocketOptionsProvider, TokenService],
controllers: [AuthController], controllers: [AuthController],
}) })
export class AuthModule {} export class AuthModule {}

View File

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

View File

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

View File

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

View File

@ -533,6 +533,12 @@ export class CannotDeleteOwnAccount extends UserFriendlyError {
super('action_forbidden', 'cannot_delete_own_account', message); 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 { export enum ErrorNames {
INTERNAL_SERVER_ERROR, INTERNAL_SERVER_ERROR,
TOO_MANY_REQUEST, TOO_MANY_REQUEST,
@ -604,7 +610,8 @@ export enum ErrorNames {
INVALID_RUNTIME_CONFIG_TYPE, INVALID_RUNTIME_CONFIG_TYPE,
MAILER_SERVICE_IS_NOT_CONFIGURED, MAILER_SERVICE_IS_NOT_CONFIGURED,
CANNOT_DELETE_ALL_ADMIN_ACCOUNT, CANNOT_DELETE_ALL_ADMIN_ACCOUNT,
CANNOT_DELETE_OWN_ACCOUNT CANNOT_DELETE_OWN_ACCOUNT,
CAPTCHA_VERIFICATION_FAILED
} }
registerEnumType(ErrorNames, { registerEnumType(ErrorNames, {
name: '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 * from './error';
export { EventEmitter, type EventPayload, OnEvent } from './event'; export { EventEmitter, type EventPayload, OnEvent } from './event';
export type { GraphqlContext } from './graphql'; export type { GraphqlContext } from './graphql';
export * from './guard';
export { CryptoHelper, URLHelper } from './helpers'; export { CryptoHelper, URLHelper } from './helpers';
export { MailService } from './mailer'; export { MailService } from './mailer';
export { CallCounter, CallTimer, metrics } from './metrics'; 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 './copilot';
import './gcloud'; import './gcloud';
import './oauth'; import './oauth';

View File

@ -218,6 +218,7 @@ 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
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
@ -675,6 +676,7 @@ enum ServerDeploymentType {
} }
enum ServerFeature { enum ServerFeature {
Captcha
Copilot Copilot
OAuth OAuth
Payment Payment

View File

@ -26,7 +26,6 @@ export const runtimeFlagsSchema = z.object({
allowLocalWorkspace: z.boolean(), allowLocalWorkspace: z.boolean(),
enablePreloading: z.boolean(), enablePreloading: z.boolean(),
enableNewSettingUnstableApi: z.boolean(), enableNewSettingUnstableApi: z.boolean(),
enableCaptcha: z.boolean(),
enableEnhanceShareMode: z.boolean(), enableEnhanceShareMode: z.boolean(),
enableExperimentalFeature: z.boolean(), enableExperimentalFeature: z.boolean(),
enableInfoModal: z.boolean(), enableInfoModal: z.boolean(),

View File

@ -30,13 +30,15 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
const [sendingEmail, setSendingEmail] = useState(false); const [sendingEmail, setSendingEmail] = useState(false);
const onSignIn = useAsyncCallback(async () => { const onSignIn = useAsyncCallback(async () => {
if (isLoading) return; if (isLoading || !verifyToken) return;
setIsLoading(true); setIsLoading(true);
try { try {
await authService.signInPassword({ await authService.signInPassword({
email, email,
password, password,
verifyToken,
challenge,
}); });
} catch (err) { } catch (err) {
console.error(err); console.error(err);
@ -44,7 +46,7 @@ export const SignInWithPassword: FC<AuthPanelProps<'signInWithPassword'>> = ({
} finally { } finally {
setIsLoading(false); setIsLoading(false);
} }
}, [isLoading, authService, email, password]); }, [isLoading, authService, email, password, verifyToken, challenge]);
const sendMagicLink = useAsyncCallback(async () => { const sendMagicLink = useAsyncCallback(async () => {
if (sendingEmail) return; if (sendingEmail) return;

View File

@ -1,9 +1,11 @@
import { apis } from '@affine/electron-api'; import { apis } from '@affine/electron-api';
import { Turnstile } from '@marsidev/react-turnstile'; import { Turnstile } from '@marsidev/react-turnstile';
import { useLiveData, useService } from '@toeverything/infra';
import { atom, useAtom, useSetAtom } from 'jotai'; import { atom, useAtom, useSetAtom } from 'jotai';
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import useSWR from 'swr'; import useSWR from 'swr';
import { ServerConfigService } from '../../../modules/cloud';
import * as style from './style.css'; import * as style from './style.css';
type Challenge = { type Challenge = {
@ -27,6 +29,7 @@ const challengeFetcher = async (url: string) => {
return challenge; return challenge;
}; };
const generateChallengeResponse = async (challenge: string) => { const generateChallengeResponse = async (challenge: string) => {
if (!environment.isDesktop) { if (!environment.isDesktop) {
return undefined; return undefined;
@ -38,11 +41,18 @@ const generateChallengeResponse = async (challenge: string) => {
const captchaAtom = atom<string | undefined>(undefined); const captchaAtom = atom<string | undefined>(undefined);
const responseAtom = 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 = () => { export const Captcha = () => {
const setCaptcha = useSetAtom(captchaAtom); const setCaptcha = useSetAtom(captchaAtom);
const [response] = useAtom(responseAtom); const [response] = useAtom(responseAtom);
const hasCaptchaFeature = useHasCaptcha();
if (!runtimeConfig.enableCaptcha) { if (!hasCaptchaFeature) {
return null; return null;
} }
@ -66,6 +76,7 @@ export const Captcha = () => {
export const useCaptcha = (): [string | undefined, string?] => { export const useCaptcha = (): [string | undefined, string?] => {
const [verifyToken] = useAtom(captchaAtom); const [verifyToken] = useAtom(captchaAtom);
const [response, setResponse] = useAtom(responseAtom); const [response, setResponse] = useAtom(responseAtom);
const hasCaptchaFeature = useHasCaptcha();
const { data: challenge } = useSWR('/api/auth/challenge', challengeFetcher, { const { data: challenge } = useSWR('/api/auth/challenge', challengeFetcher, {
suspense: false, suspense: false,
@ -75,7 +86,7 @@ export const useCaptcha = (): [string | undefined, string?] => {
useEffect(() => { useEffect(() => {
if ( if (
runtimeConfig.enableCaptcha && hasCaptchaFeature &&
environment.isDesktop && environment.isDesktop &&
challenge?.challenge && challenge?.challenge &&
prevChallenge.current !== challenge.challenge prevChallenge.current !== challenge.challenge
@ -87,9 +98,9 @@ export const useCaptcha = (): [string | undefined, string?] => {
console.error('Error getting challenge response:', err); console.error('Error getting challenge response:', err);
}); });
} }
}, [challenge, setResponse]); }, [challenge, hasCaptchaFeature, setResponse]);
if (!runtimeConfig.enableCaptcha) { if (!hasCaptchaFeature) {
return ['XXXX.DUMMY.TOKEN.XXXX']; return ['XXXX.DUMMY.TOKEN.XXXX'];
} }

View File

@ -82,27 +82,19 @@ export class AuthService extends Service {
verifyToken: string, verifyToken: string,
challenge?: string challenge?: string
) { ) {
const searchParams = new URLSearchParams(); const res = await this.fetchService.fetch('/api/auth/sign-in', {
if (challenge) { method: 'POST',
searchParams.set('challenge', challenge); body: JSON.stringify({
} email,
searchParams.set('token', verifyToken); // 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
const res = await this.fetchService.fetch( callbackUrl: `/magic-link?client=${environment.isDesktop ? appInfo?.schema : 'web'}`,
'/api/auth/sign-in?' + searchParams.toString(), }),
{ headers: {
method: 'POST', 'content-type': 'application/json',
body: JSON.stringify({ ...this.captchaHeaders(verifyToken, challenge),
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',
},
}
);
if (!res.ok) { if (!res.ok) {
throw new Error('Failed to send email'); throw new Error('Failed to send email');
} }
@ -159,12 +151,18 @@ export class AuthService extends Service {
return await res.json(); 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', { const res = await this.fetchService.fetch('/api/auth/sign-in', {
method: 'POST', method: 'POST',
body: JSON.stringify(credential), body: JSON.stringify(credential),
headers: { headers: {
'content-type': 'application/json', 'content-type': 'application/json',
...this.captchaHeaders(credential.verifyToken, credential.challenge),
}, },
}); });
if (!res.ok) { if (!res.ok) {
@ -182,4 +180,16 @@ export class AuthService extends Service {
checkUserByEmail(email: string) { checkUserByEmail(email: string) {
return this.store.checkUserByEmail(email); 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;
}
} }

View File

@ -292,6 +292,7 @@ export enum ErrorNames {
CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT', CANNOT_DELETE_OWN_ACCOUNT = 'CANNOT_DELETE_OWN_ACCOUNT',
CANT_CHANGE_SPACE_OWNER = 'CANT_CHANGE_SPACE_OWNER', CANT_CHANGE_SPACE_OWNER = 'CANT_CHANGE_SPACE_OWNER',
CANT_UPDATE_LIFETIME_SUBSCRIPTION = 'CANT_UPDATE_LIFETIME_SUBSCRIPTION', CANT_UPDATE_LIFETIME_SUBSCRIPTION = 'CANT_UPDATE_LIFETIME_SUBSCRIPTION',
CAPTCHA_VERIFICATION_FAILED = 'CAPTCHA_VERIFICATION_FAILED',
COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN', COPILOT_ACTION_TAKEN = 'COPILOT_ACTION_TAKEN',
COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE', COPILOT_FAILED_TO_CREATE_MESSAGE = 'COPILOT_FAILED_TO_CREATE_MESSAGE',
COPILOT_FAILED_TO_GENERATE_TEXT = 'COPILOT_FAILED_TO_GENERATE_TEXT', COPILOT_FAILED_TO_GENERATE_TEXT = 'COPILOT_FAILED_TO_GENERATE_TEXT',
@ -965,6 +966,7 @@ export enum ServerDeploymentType {
} }
export enum ServerFeature { export enum ServerFeature {
Captcha = 'Captcha',
Copilot = 'Copilot', Copilot = 'Copilot',
OAuth = 'OAuth', OAuth = 'OAuth',
Payment = 'Payment', Payment = 'Payment',

View File

@ -17,7 +17,6 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
imageProxyUrl: '/api/worker/image-proxy', imageProxyUrl: '/api/worker/image-proxy',
linkPreviewUrl: '/api/worker/link-preview', linkPreviewUrl: '/api/worker/link-preview',
enablePreloading: true, enablePreloading: true,
enableCaptcha: true,
enableExperimentalFeature: true, enableExperimentalFeature: true,
allowLocalWorkspace: allowLocalWorkspace:
buildFlags.distribution === 'desktop' ? true : false, buildFlags.distribution === 'desktop' ? true : false,
@ -76,11 +75,6 @@ export function getRuntimeConfig(buildFlags: BuildFlags): RuntimeConfig {
enableNewSettingUnstableApi: process.env.ENABLE_NEW_SETTING_UNSTABLE_API enableNewSettingUnstableApi: process.env.ENABLE_NEW_SETTING_UNSTABLE_API
? process.env.ENABLE_NEW_SETTING_UNSTABLE_API === 'true' ? process.env.ENABLE_NEW_SETTING_UNSTABLE_API === 'true'
: currentBuildPreset.enableNewSettingUnstableApi, : 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 enableEnhanceShareMode: process.env.ENABLE_ENHANCE_SHARE_MODE
? process.env.ENABLE_ENHANCE_SHARE_MODE === 'true' ? process.env.ENABLE_ENHANCE_SHARE_MODE === 'true'
: currentBuildPreset.enableEnhanceShareMode, : currentBuildPreset.enableEnhanceShareMode,