mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-10-30 16:23:31 +00:00
Merge pull request #5261 from lyen1688/feat/tencent-captcha-gate
新增腾讯天御验证码认证门禁
This commit is contained in:
@@ -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({
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user