mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-20 03:26:47 +08:00
feat(server): support refresh token (#15218)
This commit is contained in:
@@ -1,3 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { defineModuleConfig } from '../../base';
|
||||
import { CaptchaConfig } from './types';
|
||||
|
||||
@@ -26,10 +28,26 @@ defineModuleConfig('captcha', {
|
||||
default: {
|
||||
turnstile: {
|
||||
secret: '',
|
||||
siteKey: '',
|
||||
action: 'auth-sign-in',
|
||||
},
|
||||
challenge: {
|
||||
bits: 20,
|
||||
},
|
||||
},
|
||||
shape: z
|
||||
.object({
|
||||
turnstile: z
|
||||
.object({
|
||||
secret: z.string().max(4096),
|
||||
siteKey: z.string().max(256),
|
||||
action: z.string().regex(/^[A-Za-z0-9_-]{1,32}$/),
|
||||
})
|
||||
.strict(),
|
||||
challenge: z
|
||||
.object({ bits: z.number().int().min(16).max(30) })
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { Controller, Get, Header, Req } from '@nestjs/common';
|
||||
import type { Request } from 'express';
|
||||
|
||||
import { Throttle } from '../../base';
|
||||
import { Public } from '../../core/auth';
|
||||
@@ -10,8 +11,11 @@ export class CaptchaController {
|
||||
constructor(private readonly captcha: CaptchaService) {}
|
||||
|
||||
@Public()
|
||||
@Get('/challenge')
|
||||
async getChallenge() {
|
||||
return this.captcha.getChallengeToken();
|
||||
@Get('/captcha')
|
||||
@Header('Cache-Control', 'no-store')
|
||||
async getChallenge(@Req() req: Request) {
|
||||
return this.captcha.getClientConfig(
|
||||
req.get('x-affine-client-kind') === 'native'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,14 +33,15 @@ export class CaptchaGuardProvider
|
||||
|
||||
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 token = req.headers['x-captcha-token'];
|
||||
const challenge = req.headers['x-captcha-challenge'];
|
||||
const provider = req.headers['x-captcha-provider'];
|
||||
|
||||
const credential = this.captcha.assertValidCredential({ token, challenge });
|
||||
const credential = this.captcha.assertValidCredential({
|
||||
token,
|
||||
challenge,
|
||||
provider,
|
||||
});
|
||||
await this.captcha.verifyRequest(credential, req);
|
||||
|
||||
return true;
|
||||
|
||||
@@ -2,13 +2,14 @@ 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,
|
||||
getRequestClientIp,
|
||||
metrics,
|
||||
NetworkError,
|
||||
OnEvent,
|
||||
} from '../../base';
|
||||
import { ServerFeature, ServerService } from '../../core';
|
||||
@@ -17,21 +18,31 @@ import { verifyChallengeResponse } from '../../native';
|
||||
import { CaptchaConfig } from './types';
|
||||
|
||||
const validator = z
|
||||
.object({ token: z.string(), challenge: z.string().optional() })
|
||||
.object({
|
||||
token: z.string().min(1).max(2048),
|
||||
challenge: z.string().min(1).max(128).optional(),
|
||||
provider: z.enum(['hashcash', 'turnstile']),
|
||||
})
|
||||
.strict();
|
||||
type Credential = z.infer<typeof validator>;
|
||||
const turnstileResponse = z.object({
|
||||
success: z.boolean(),
|
||||
hostname: z.string().optional(),
|
||||
action: z.string().optional(),
|
||||
'error-codes': z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
@Injectable()
|
||||
export class CaptchaService {
|
||||
private readonly logger = new Logger(CaptchaService.name);
|
||||
private readonly captcha: CaptchaConfig;
|
||||
|
||||
constructor(
|
||||
private readonly config: Config,
|
||||
private readonly challenges: AuthChallengeStore,
|
||||
private readonly server: ServerService
|
||||
) {
|
||||
this.captcha = config.captcha.config;
|
||||
) {}
|
||||
|
||||
private get captcha(): CaptchaConfig {
|
||||
return this.config.captcha.config;
|
||||
}
|
||||
|
||||
@OnEvent('config.init')
|
||||
@@ -46,36 +57,71 @@ export class CaptchaService {
|
||||
}
|
||||
}
|
||||
|
||||
private async verifyCaptchaToken(token: any, ip: string) {
|
||||
if (typeof token !== 'string' || !token) return false;
|
||||
|
||||
private async verifyCaptchaToken(token: string, ip: string) {
|
||||
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());
|
||||
formData.append('idempotency_key', randomUUID());
|
||||
|
||||
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;
|
||||
};
|
||||
let result: Response;
|
||||
try {
|
||||
result = await fetch(url, {
|
||||
body: formData,
|
||||
method: 'POST',
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
} catch {
|
||||
metrics.auth.counter('captcha_verification').add(1, {
|
||||
provider: 'turnstile',
|
||||
result: 'unavailable',
|
||||
});
|
||||
throw new NetworkError('Captcha verification temporarily unavailable');
|
||||
}
|
||||
if (!result.ok) {
|
||||
metrics.auth.counter('captcha_verification').add(1, {
|
||||
provider: 'turnstile',
|
||||
result: 'unavailable',
|
||||
});
|
||||
throw new NetworkError('Captcha verification temporarily unavailable');
|
||||
}
|
||||
let parsed: z.SafeParseReturnType<
|
||||
unknown,
|
||||
z.infer<typeof turnstileResponse>
|
||||
>;
|
||||
try {
|
||||
parsed = turnstileResponse.safeParse(await result.json());
|
||||
} catch {
|
||||
parsed = turnstileResponse.safeParse(null);
|
||||
}
|
||||
if (!parsed.success) {
|
||||
metrics.auth.counter('captcha_verification').add(1, {
|
||||
provider: 'turnstile',
|
||||
result: 'unavailable',
|
||||
});
|
||||
throw new NetworkError('Captcha verification temporarily unavailable');
|
||||
}
|
||||
const outcome = parsed.data;
|
||||
|
||||
if (!outcome.success) return false;
|
||||
if (outcome.action !== this.captcha.turnstile.action) 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;
|
||||
if (
|
||||
outcome.hostname &&
|
||||
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;
|
||||
if (outcome.hostname && this.config.server.host === outcome.hostname) {
|
||||
return true;
|
||||
}
|
||||
|
||||
this.logger.warn(
|
||||
`Captcha verification failed for hostname: ${outcome.hostname}`
|
||||
@@ -83,7 +129,7 @@ export class CaptchaService {
|
||||
return false;
|
||||
}
|
||||
|
||||
private async verifyChallengeResponse(response: any, resource: string) {
|
||||
private async verifyChallengeResponse(response: string, resource: string) {
|
||||
return verifyChallengeResponse(
|
||||
response,
|
||||
this.captcha.challenge.bits,
|
||||
@@ -91,7 +137,17 @@ export class CaptchaService {
|
||||
);
|
||||
}
|
||||
|
||||
async getChallengeToken() {
|
||||
async getClientConfig(nativeClient: boolean) {
|
||||
const provider = nativeClient
|
||||
? ('hashcash' as const)
|
||||
: ('turnstile' as const);
|
||||
if (provider === 'turnstile') {
|
||||
return {
|
||||
provider,
|
||||
siteKey: this.captcha.turnstile.siteKey,
|
||||
action: this.captcha.turnstile.action,
|
||||
};
|
||||
}
|
||||
const resource = randomUUID();
|
||||
const challenge = await this.challenges.create(
|
||||
'captcha',
|
||||
@@ -100,6 +156,7 @@ export class CaptchaService {
|
||||
);
|
||||
|
||||
return {
|
||||
provider,
|
||||
challenge,
|
||||
resource,
|
||||
};
|
||||
@@ -109,44 +166,83 @@ export class CaptchaService {
|
||||
try {
|
||||
return validator.parse(credential);
|
||||
} catch {
|
||||
metrics.auth.counter('captcha_verification').add(1, {
|
||||
provider:
|
||||
credential?.provider === 'hashcash' ||
|
||||
credential?.provider === 'turnstile'
|
||||
? credential.provider
|
||||
: 'unknown',
|
||||
result: 'invalid_credential',
|
||||
});
|
||||
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.challenges.consume<string>('captcha', challenge);
|
||||
}
|
||||
|
||||
if (resource) {
|
||||
if (credential.provider === 'hashcash') {
|
||||
if (!credential.challenge) {
|
||||
metrics.auth.counter('captcha_verification').add(1, {
|
||||
provider: 'hashcash',
|
||||
result: 'missing_challenge',
|
||||
});
|
||||
throw new CaptchaVerificationFailed('Missing Challenge');
|
||||
}
|
||||
const resource = await this.challenges.consume<string>(
|
||||
'captcha',
|
||||
credential.challenge
|
||||
);
|
||||
if (!resource) {
|
||||
metrics.auth.counter('captcha_verification').add(1, {
|
||||
provider: 'hashcash',
|
||||
result: 'expired_or_replayed',
|
||||
});
|
||||
throw new CaptchaVerificationFailed('Invalid Challenge Response');
|
||||
}
|
||||
const isChallengeVerified = await this.verifyChallengeResponse(
|
||||
credential.token,
|
||||
resource
|
||||
);
|
||||
|
||||
this.logger.debug(
|
||||
`Challenge: ${challenge}, Resource: ${resource}, Response: ${credential.token}, isChallengeVerified: ${isChallengeVerified}`
|
||||
);
|
||||
|
||||
if (!isChallengeVerified) {
|
||||
metrics.auth.counter('captcha_verification').add(1, {
|
||||
provider: 'hashcash',
|
||||
result: 'invalid_proof',
|
||||
});
|
||||
throw new CaptchaVerificationFailed('Invalid Challenge Response');
|
||||
}
|
||||
metrics.auth.counter('captcha_verification').add(1, {
|
||||
provider: 'hashcash',
|
||||
result: 'success',
|
||||
});
|
||||
} else {
|
||||
if (credential.challenge) {
|
||||
throw new CaptchaVerificationFailed('Unexpected Challenge');
|
||||
}
|
||||
const isTokenVerified = await this.verifyCaptchaToken(
|
||||
credential.token,
|
||||
getRequestClientIp(req)
|
||||
);
|
||||
|
||||
if (!isTokenVerified) {
|
||||
metrics.auth.counter('captcha_verification').add(1, {
|
||||
provider: 'turnstile',
|
||||
result: 'failed',
|
||||
});
|
||||
throw new CaptchaVerificationFailed('Invalid Captcha Response');
|
||||
}
|
||||
metrics.auth.counter('captcha_verification').add(1, {
|
||||
provider: 'turnstile',
|
||||
result: 'success',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
private setup() {
|
||||
if (this.config.captcha.enabled) {
|
||||
if (!this.captcha.turnstile.secret || !this.captcha.turnstile.siteKey) {
|
||||
throw new Error(
|
||||
'Enabled captcha requires Turnstile secret and site key.'
|
||||
);
|
||||
}
|
||||
this.server.enableFeature(ServerFeature.Captcha);
|
||||
} else {
|
||||
this.server.disableFeature(ServerFeature.Captcha);
|
||||
|
||||
@@ -4,9 +4,12 @@ export interface CaptchaConfig {
|
||||
turnstile: {
|
||||
/**
|
||||
* Cloudflare Turnstile CAPTCHA secret
|
||||
* default value is demo api key, witch always return success
|
||||
*/
|
||||
secret: string;
|
||||
/** Public Turnstile widget site key. */
|
||||
siteKey: string;
|
||||
/** Expected action bound to the authentication widget and Siteverify response. */
|
||||
action: string;
|
||||
};
|
||||
challenge: {
|
||||
/**
|
||||
|
||||
@@ -18,14 +18,10 @@ import {
|
||||
URLHelper,
|
||||
UseNamedGuard,
|
||||
} from '../../base';
|
||||
import {
|
||||
OAuthCallbackBodySchema,
|
||||
OAuthPreflightBodySchema,
|
||||
Public,
|
||||
SessionIssuer,
|
||||
} from '../../core/auth';
|
||||
import { Public, SessionIssuer } from '../../core/auth';
|
||||
import { OAuthProviderName } from './config';
|
||||
import { OAuthProviderFactory } from './factory';
|
||||
import { OAuthCallbackBodySchema, OAuthPreflightBodySchema } from './input';
|
||||
import { OAuthService } from './service';
|
||||
|
||||
@Controller('/api/oauth')
|
||||
@@ -48,6 +44,16 @@ export class OAuthController {
|
||||
if (fields.has('client_nonce')) {
|
||||
throw new MissingOauthQueryParameter({ name: 'client_nonce' });
|
||||
}
|
||||
if (fields.has('client')) {
|
||||
throw new ActionForbidden();
|
||||
}
|
||||
if (fields.has('provider')) {
|
||||
const provider =
|
||||
body && typeof body === 'object' && 'provider' in body
|
||||
? String(body.provider)
|
||||
: '';
|
||||
throw new UnknownOauthProvider({ name: provider });
|
||||
}
|
||||
throw new MissingOauthQueryParameter({ name: 'provider' });
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const OAuthProviderSchema = z.enum([
|
||||
'Google',
|
||||
'GitHub',
|
||||
'Apple',
|
||||
'OIDC',
|
||||
]);
|
||||
|
||||
export const OAuthClientSchema = z.enum([
|
||||
'web',
|
||||
'affine',
|
||||
'affine-canary',
|
||||
'affine-beta',
|
||||
'affine-dev',
|
||||
]);
|
||||
|
||||
export const OAuthPreflightBodySchema = z
|
||||
.object({
|
||||
provider: OAuthProviderSchema,
|
||||
redirect_uri: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(2048)
|
||||
.nullish()
|
||||
.transform(value => value ?? undefined),
|
||||
client: OAuthClientSchema,
|
||||
client_nonce: z.string().min(1).max(512),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const OAuthCallbackBodySchema = z
|
||||
.object({
|
||||
code: z.string().min(1).max(4096),
|
||||
state: z.string().min(1).max(16_384),
|
||||
client_nonce: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(512)
|
||||
.nullish()
|
||||
.transform(value => value ?? undefined),
|
||||
// Apple includes this JSON field on the first form_post callback.
|
||||
user: z.string().max(16_384).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const OAuthStateEnvelopeSchema = z
|
||||
.object({
|
||||
state: z.string().uuid(),
|
||||
provider: OAuthProviderSchema,
|
||||
client: OAuthClientSchema,
|
||||
flow: z.enum(['popup', 'redirect']).optional(),
|
||||
pkce: z
|
||||
.object({
|
||||
codeChallenge: z.string().min(1).max(512),
|
||||
codeChallengeMethod: z.literal('S256'),
|
||||
})
|
||||
.strict()
|
||||
.optional(),
|
||||
})
|
||||
.strict();
|
||||
@@ -15,12 +15,12 @@ import {
|
||||
import {
|
||||
AuthChallengeStore,
|
||||
AuthService,
|
||||
OAuthStateEnvelopeSchema,
|
||||
type VerifiedIdentity,
|
||||
} from '../../core/auth';
|
||||
import { Models } from '../../models';
|
||||
import { OAuthProviderName } from './config';
|
||||
import { OAuthProviderFactory } from './factory';
|
||||
import { OAuthStateEnvelopeSchema } from './input';
|
||||
import { OAuthAccount, Tokens } from './providers/def';
|
||||
import { OAuthPkceChallenge, OAuthState } from './types';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user