Merge pull request #5261 from lyen1688/feat/tencent-captcha-gate

新增腾讯天御验证码认证门禁
This commit is contained in:
Wesley Liddick
2026-08-04 16:39:55 +08:00
committed by GitHub
86 changed files with 3683 additions and 262 deletions
@@ -0,0 +1,45 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
const post = vi.hoisted(() => vi.fn())
vi.mock('@/api/client', () => ({
apiClient: { post }
}))
import {
buildOAuthLoginStartURL,
startOAuthLogin,
type OAuthLoginStart
} from '@/api/auth'
describe('OAuth captcha start API', () => {
beforeEach(() => {
post.mockReset()
})
it('posts Tencent captcha proof and preserves OAuth query parameters', async () => {
const request: OAuthLoginStart = {
provider: 'github',
params: { redirect: '/dashboard', aff_code: 'AFF123' }
}
const proof = {
tencent_captcha_ticket: 'ticket',
tencent_captcha_randstr: '@rand'
}
post.mockResolvedValue({ data: { authorize_url: 'https://github.com/login/oauth/authorize' } })
await expect(startOAuthLogin(request, proof)).resolves.toEqual({
authorize_url: 'https://github.com/login/oauth/authorize'
})
expect(post).toHaveBeenCalledWith('/auth/oauth/github/start', proof, {
params: request.params
})
})
it('builds the legacy GET start URL when Tencent captcha is disabled', () => {
expect(buildOAuthLoginStartURL({
provider: 'wechat',
params: { mode: 'open', redirect: '/billing?plan=pro' }
})).toBe('/api/v1/auth/oauth/wechat/start?mode=open&redirect=%2Fbilling%3Fplan%3Dpro')
})
})
@@ -97,6 +97,7 @@ describe('passkey api', () => {
await passkeyAPI.login()
expect(post).toHaveBeenNthCalledWith(1, '/auth/passkey/login/begin')
const request = credentialGet.mock.calls[0][0] as CredentialRequestOptions
expect(Array.from(new Uint8Array(request.publicKey!.challenge))).toEqual([1, 2, 3])
expect(request.publicKey!.userVerification).toBe('required')
@@ -119,6 +120,38 @@ describe('passkey api', () => {
})
})
it('sends Tencent captcha proof only with the passkey begin request', async () => {
post
.mockResolvedValueOnce({
data: {
session_token: 'one-time-session',
options: {
publicKey: {
challenge: 'AQID',
rpId: 'sub2api.example.com',
userVerification: 'required'
}
}
}
})
.mockResolvedValueOnce({ data: { access_token: 'access', token_type: 'Bearer', user: { id: 1 } } })
credentialGet.mockResolvedValue(new FakePublicKeyCredential())
await passkeyAPI.login({
tencent_captcha_ticket: 'ticket-value',
tencent_captcha_randstr: '@rand-value'
})
expect(post).toHaveBeenNthCalledWith(1, '/auth/passkey/login/begin', {
tencent_captcha_ticket: 'ticket-value',
tencent_captcha_randstr: '@rand-value'
})
expect(post.mock.calls[1][1]).not.toEqual(expect.objectContaining({
tencent_captcha_ticket: expect.anything(),
tencent_captcha_randstr: expect.anything()
}))
})
it('sends the account password when beginning registration', async () => {
post
.mockResolvedValueOnce({
+10
View File
@@ -459,6 +459,11 @@ export interface SystemSettings {
turnstile_enabled: boolean;
turnstile_site_key: string;
turnstile_secret_key_configured: boolean;
tencent_captcha_enabled: boolean;
tencent_captcha_app_id: string;
tencent_captcha_app_secret_key_configured: boolean;
tencent_captcha_cloud_secret_id_configured: boolean;
tencent_captcha_cloud_secret_key_configured: boolean;
api_key_acl_trust_forwarded_ip: boolean;
forwarded_client_ip_headers: string[];
@@ -773,6 +778,11 @@ export interface UpdateSettingsRequest {
turnstile_enabled?: boolean;
turnstile_site_key?: string;
turnstile_secret_key?: string;
tencent_captcha_enabled?: boolean;
tencent_captcha_app_id?: string;
tencent_captcha_app_secret_key?: string;
tencent_captcha_cloud_secret_id?: string;
tencent_captcha_cloud_secret_key?: string;
api_key_acl_trust_forwarded_ip?: boolean;
forwarded_client_ip_headers?: string[];
linuxdo_connect_enabled?: boolean;
+40
View File
@@ -14,6 +14,7 @@ import type {
SendVerifyCodeRequest,
SendVerifyCodeResponse,
PublicSettings,
TencentCaptchaRequestProof,
TotpLoginResponse,
TotpLogin2FARequest
} from '@/types'
@@ -23,6 +24,43 @@ import type {
*/
export type LoginResponse = AuthResponse | TotpLoginResponse
export type OAuthLoginProvider =
| 'github'
| 'google'
| 'linuxdo'
| 'dingtalk'
| 'wechat'
| 'oidc'
export interface OAuthLoginStart {
provider: OAuthLoginProvider
params: Record<string, string>
}
export interface OAuthLoginStartResponse {
authorize_url: string
}
export function buildOAuthLoginStartURL(request: OAuthLoginStart): string {
const apiBase = (import.meta.env.VITE_API_BASE_URL as string | undefined) || '/api/v1'
const normalized = apiBase.replace(/\/$/, '')
const query = new URLSearchParams(request.params).toString()
const path = `${normalized}/auth/oauth/${request.provider}/start`
return query ? `${path}?${query}` : path
}
export async function startOAuthLogin(
request: OAuthLoginStart,
proof: TencentCaptchaRequestProof
): Promise<OAuthLoginStartResponse> {
const { data } = await apiClient.post<OAuthLoginStartResponse>(
`/auth/oauth/${request.provider}/start`,
proof,
{ params: request.params }
)
return data
}
/**
* Type guard to check if login response requires 2FA
*/
@@ -493,6 +531,8 @@ export async function validateInvitationCode(code: string): Promise<ValidateInvi
export interface ForgotPasswordRequest {
email: string
turnstile_token?: string
tencent_captcha_ticket?: string
tencent_captcha_randstr?: string
}
/**
+5 -5
View File
@@ -1,5 +1,5 @@
import { apiClient } from './client'
import type { AuthResponse } from '@/types'
import type { AuthResponse, TencentCaptchaRequestProof } from '@/types'
export interface PasskeyCredentialSummary {
id: number
@@ -104,11 +104,11 @@ function serializeAssertionCredential(credential: PublicKeyCredential): Record<s
}
}
async function login(): Promise<AuthResponse> {
async function login(proof?: TencentCaptchaRequestProof): Promise<AuthResponse> {
requirePasskeySupport()
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
'/auth/passkey/login/begin'
)
const { data: begin } = proof
? await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin', proof)
: await apiClient.post<CeremonyOptionsResponse>('/auth/passkey/login/begin')
const credential = await navigator.credentials.get({
publicKey: requestOptionsFromJSON(begin.options.publicKey)
})