mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-02-12 04:18:54 +00:00
close CLOUD-233 #### PR Dependency Tree * **PR #12950** 👈 This tree was auto-generated by [Charcoal](https://github.com/danerwilliams/charcoal) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Added support for configuring multiple server hosts across backend and frontend settings. * Enhanced deployment and Helm chart configuration to allow specifying multiple ingress hosts. * Updated admin and configuration interfaces to display and manage multiple server hosts. * **Improvements** * Improved URL generation, OAuth, and worker service logic to dynamically handle requests from multiple hosts. * Enhanced captcha verification to support multiple allowed hostnames. * Updated frontend logic for platform-specific server base URLs and allowed origins, including Apple app domains. * Expanded test coverage for multi-host scenarios. * **Bug Fixes** * Corrected backend logic to consistently use dynamic base URLs and origins based on request host context. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
153 lines
4.1 KiB
TypeScript
153 lines
4.1 KiB
TypeScript
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 { CaptchaVerificationFailed, Config, OnEvent } from '../../base';
|
|
import { ServerFeature, ServerService } from '../../core';
|
|
import { Models, TokenType } from '../../models';
|
|
import { verifyChallengeResponse } from '../../native';
|
|
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 models: Models,
|
|
private readonly server: ServerService
|
|
) {
|
|
this.captcha = config.captcha.config;
|
|
}
|
|
|
|
@OnEvent('config.init')
|
|
onConfigInit() {
|
|
this.setup();
|
|
}
|
|
|
|
@OnEvent('config.changed')
|
|
onConfigChanged(event: Events['config.changed']) {
|
|
if ('captcha' in event.updates) {
|
|
this.setup();
|
|
}
|
|
}
|
|
|
|
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()) as {
|
|
success: boolean;
|
|
hostname: string;
|
|
};
|
|
|
|
if (!outcome.success) return false;
|
|
|
|
// skip hostname check in dev mode
|
|
if (env.dev) return true;
|
|
|
|
// check if the hostname is in the hosts
|
|
if (this.config.server.hosts.includes(outcome.hostname)) return true;
|
|
|
|
// check if the hostname is in the host
|
|
if (this.config.server.host === outcome.hostname) return true;
|
|
|
|
this.logger.warn(
|
|
`Captcha verification failed for hostname: ${outcome.hostname}`
|
|
);
|
|
return false;
|
|
}
|
|
|
|
private async verifyChallengeResponse(response: any, resource: string) {
|
|
return verifyChallengeResponse(
|
|
response,
|
|
this.captcha.challenge.bits,
|
|
resource
|
|
);
|
|
}
|
|
|
|
async getChallengeToken() {
|
|
const resource = randomUUID();
|
|
const challenge = await this.models.verificationToken.create(
|
|
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;
|
|
let resource: string | null = null;
|
|
if (typeof challenge === 'string' && challenge) {
|
|
resource = await this.models.verificationToken
|
|
.get(TokenType.Challenge, challenge)
|
|
.then(token => token?.credential || null);
|
|
}
|
|
|
|
if (resource) {
|
|
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');
|
|
}
|
|
}
|
|
}
|
|
|
|
private setup() {
|
|
if (this.config.captcha.enabled) {
|
|
this.server.enableFeature(ServerFeature.Captcha);
|
|
} else {
|
|
this.server.disableFeature(ServerFeature.Captcha);
|
|
}
|
|
}
|
|
}
|