mirror of
https://github.com/toeverything/AFFiNE.git
synced 2024-12-23 18:42:58 +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 }}"
|
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
|
||||||
|
@ -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',
|
||||||
|
@ -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',
|
||||||
|
@ -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', {
|
||||||
|
@ -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(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@ -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 {}
|
||||||
|
@ -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({
|
||||||
|
@ -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',
|
||||||
|
@ -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>;
|
||||||
|
@ -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'
|
||||||
|
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 * 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';
|
||||||
|
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 './copilot';
|
||||||
import './gcloud';
|
import './gcloud';
|
||||||
import './oauth';
|
import './oauth';
|
||||||
|
@ -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
|
||||||
|
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(),
|
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(),
|
||||||
|
@ -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;
|
||||||
|
@ -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'];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -82,15 +82,7 @@ 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) {
|
|
||||||
searchParams.set('challenge', challenge);
|
|
||||||
}
|
|
||||||
searchParams.set('token', verifyToken);
|
|
||||||
|
|
||||||
const res = await this.fetchService.fetch(
|
|
||||||
'/api/auth/sign-in?' + searchParams.toString(),
|
|
||||||
{
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
email,
|
email,
|
||||||
@ -100,9 +92,9 @@ export class AuthService extends Service {
|
|||||||
}),
|
}),
|
||||||
headers: {
|
headers: {
|
||||||
'content-type': 'application/json',
|
'content-type': 'application/json',
|
||||||
|
...this.captchaHeaders(verifyToken, challenge),
|
||||||
},
|
},
|
||||||
}
|
});
|
||||||
);
|
|
||||||
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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',
|
||||||
|
@ -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,
|
||||||
|
Loading…
Reference in New Issue
Block a user