feat(server): support refresh token (#15218)

This commit is contained in:
DarkSky
2026-07-12 02:39:42 +08:00
committed by GitHub
parent aea128f0b9
commit 02b25e05d8
80 changed files with 5866 additions and 745 deletions
@@ -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';