Files
sub2api/frontend/src/api/auth.ts
T
feeeei 26e0a89323 人机验证增加阿里云验证码 2.0
沿用腾讯天御验证码引入的多服务商模型:aliyun_captcha_enabled 作为独立
开关,与 Cloudflare Turnstile、腾讯天御三方互斥(保存校验 + 运行时
CAPTCHA_PROVIDER_CONFLICT)。后台「安全与认证」合并为单张人机验证卡片:
总开关 + 服务商单选(Turnstile / 腾讯天御 / 阿里云),选中即启用该家并
关闭其它,落库仍是三个独立开关键,由前端映射保证互斥。

阿里云侧同时支持 aliyun 中国站与国际站(alibabacloud.com):两站前端脚本、
region 取值与服务端 API 完全一致,仅账号与 AccessKey 相互独立,因此由
「服务地域」决定线路即可——中国内地走 captcha.cn-shanghai.aliyuncs.com,
非中国内地(新加坡)走 captcha.ap-southeast-1.aliyuncs.com,AccessKey
取自持有该实例的账号,无需在配置中区分站点。

- AliyunCaptchaService 对称 TencentCaptchaService:服务端校验走官方 SDK
  VerifyIntelligentCaptcha,调用异常按 fail-closed 拦截,与 Turnstile
  网络错误行为对称;保存设置时真实探测 AK/SK 有效性
- 保护面对齐腾讯扩展入口:VerifyTencentCaptchaIfEnabled 通用化为
  VerifyActionCaptchaIfEnabled,OAuth 登录启动、passkey 登录在阿里云
  启用时同样拦截;Turnstile 维持既有覆盖不扩大
- 前端 AliyunCaptchaWidget 为表单内预验证按钮(popup 模式),同时暴露
  verify() 供 OAuth 启动、passkey 等动作入口程序化弹窗;未预验证直接
  提交时弹窗兜底。SDK 按钮绑定异步完成,弹窗未出现前按 tick 重试触发,
  并轮询弹窗可见性识别用户关闭
- captchaVerifyParam 复用 turnstile_token 请求字段提交;公开设置下发
  aliyun_captcha_enabled / scene_id / prefix / region
- CSP 放行验证码 CDN:script-src/style-src 加 *.alicdn.com
2026-08-04 20:57:15 +08:00

720 lines
20 KiB
TypeScript

/**
* Authentication API endpoints
* Handles user login, registration, and logout operations
*/
import { apiClient } from './client'
import { refreshAuthTokens, type RefreshTokenResponse } from './tokenRefresh'
export type { RefreshTokenResponse } from './tokenRefresh'
import type {
LoginRequest,
RegisterRequest,
AuthResponse,
CurrentUserResponse,
SendVerifyCodeRequest,
SendVerifyCodeResponse,
PublicSettings,
ActionCaptchaRequestProof,
TotpLoginResponse,
TotpLogin2FARequest
} from '@/types'
/**
* Login response type - can be either full auth or 2FA required
*/
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: ActionCaptchaRequestProof
): 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
*/
export function isTotp2FARequired(response: LoginResponse): response is TotpLoginResponse {
return 'requires_2fa' in response && response.requires_2fa === true
}
/**
* Store authentication token in localStorage
*/
export function setAuthToken(token: string): void {
localStorage.setItem('auth_token', token)
}
/**
* Store refresh token in localStorage
*/
export function setRefreshToken(token: string): void {
localStorage.setItem('refresh_token', token)
}
/**
* Store token expiration timestamp in localStorage
* Converts expires_in (seconds) to absolute timestamp (milliseconds)
*/
export function setTokenExpiresAt(expiresIn: number): void {
const expiresAt = Date.now() + expiresIn * 1000
localStorage.setItem('token_expires_at', String(expiresAt))
}
/**
* Get authentication token from localStorage
*/
export function getAuthToken(): string | null {
return localStorage.getItem('auth_token')
}
/**
* Get refresh token from localStorage
*/
export function getRefreshToken(): string | null {
return localStorage.getItem('refresh_token')
}
/**
* Get token expiration timestamp from localStorage
*/
export function getTokenExpiresAt(): number | null {
const value = localStorage.getItem('token_expires_at')
return value ? parseInt(value, 10) : null
}
/**
* Clear authentication token from localStorage
*/
export function clearAuthToken(): void {
localStorage.removeItem('auth_token')
localStorage.removeItem('refresh_token')
localStorage.removeItem('auth_user')
localStorage.removeItem('token_expires_at')
}
/**
* User login
* @param credentials - Email and password
* @returns Authentication response with token and user data, or 2FA required response
*/
export async function login(credentials: LoginRequest): Promise<LoginResponse> {
const { data } = await apiClient.post<LoginResponse>('/auth/login', credentials)
// Only store token if 2FA is not required
if (!isTotp2FARequired(data)) {
setAuthToken(data.access_token)
if (data.refresh_token) {
setRefreshToken(data.refresh_token)
}
if (data.expires_in) {
setTokenExpiresAt(data.expires_in)
}
localStorage.setItem('auth_user', JSON.stringify(data.user))
}
return data
}
/**
* Complete login with 2FA code
* @param request - Temp token and TOTP code
* @returns Authentication response with token and user data
*/
export async function login2FA(request: TotpLogin2FARequest): Promise<AuthResponse> {
const { data } = await apiClient.post<AuthResponse>('/auth/login/2fa', request)
// Store token and user data
setAuthToken(data.access_token)
if (data.refresh_token) {
setRefreshToken(data.refresh_token)
}
if (data.expires_in) {
setTokenExpiresAt(data.expires_in)
}
localStorage.setItem('auth_user', JSON.stringify(data.user))
return data
}
/**
* User registration
* @param userData - Registration data (username, email, password)
* @returns Authentication response with token and user data
*/
export async function register(userData: RegisterRequest): Promise<AuthResponse> {
const { data } = await apiClient.post<AuthResponse>('/auth/register', userData)
// Store token and user data
setAuthToken(data.access_token)
if (data.refresh_token) {
setRefreshToken(data.refresh_token)
}
if (data.expires_in) {
setTokenExpiresAt(data.expires_in)
}
localStorage.setItem('auth_user', JSON.stringify(data.user))
return data
}
/**
* Get current authenticated user
* @returns User profile data
*/
export async function getCurrentUser() {
return apiClient.get<CurrentUserResponse>('/auth/me')
}
/**
* User logout
* Clears authentication token and user data from localStorage
* Optionally revokes the refresh token on the server
*/
export async function logout(): Promise<void> {
const refreshToken = getRefreshToken()
// Try to revoke the refresh token on the server
if (refreshToken) {
try {
await apiClient.post('/auth/logout', { refresh_token: refreshToken })
} catch {
// Ignore errors - we still want to clear local state
}
}
clearAuthToken()
}
/**
* Refresh token response
*/
export interface OAuthTokenResponse {
access_token: string
refresh_token?: string
expires_in?: number
token_type?: string
}
export interface PendingOAuthBindLoginResponse extends Partial<OAuthTokenResponse> {
auth_result?: string
redirect?: string
error?: string
requires_2fa?: boolean
temp_token?: string
user_email_masked?: string
adoption_required?: boolean
suggested_display_name?: string
suggested_avatar_url?: string
}
export type PendingOAuthExchangeResponse = PendingOAuthBindLoginResponse
export interface PendingOAuthCreateAccountResponse extends OAuthTokenResponse {
auth_result?: string
}
export interface PendingOAuthSendVerifyCodeResponse extends SendVerifyCodeResponse {
auth_result?: string
provider?: string
redirect?: string
}
export type OAuthCompletionKind = 'login' | 'bind'
export interface OAuthAdoptionDecision {
adoptDisplayName?: boolean
adoptAvatar?: boolean
}
function serializeOAuthAdoptionDecision(
decision?: OAuthAdoptionDecision
): Record<string, boolean> {
const payload: Record<string, boolean> = {}
if (typeof decision?.adoptDisplayName === 'boolean') {
payload.adopt_display_name = decision.adoptDisplayName
}
if (typeof decision?.adoptAvatar === 'boolean') {
payload.adopt_avatar = decision.adoptAvatar
}
return payload
}
export function isOAuthLoginCompletion(
completion: Partial<OAuthTokenResponse>
): completion is OAuthTokenResponse {
return typeof completion.access_token === 'string' && completion.access_token.trim().length > 0
}
export function getOAuthCompletionKind(
completion: Partial<OAuthTokenResponse>
): OAuthCompletionKind {
return isOAuthLoginCompletion(completion) ? 'login' : 'bind'
}
export function getPendingOAuthBindLoginKind(
completion: PendingOAuthBindLoginResponse
): OAuthCompletionKind {
return getOAuthCompletionKind(completion)
}
export function isPendingOAuthCreateAccountRequired(
completion: Pick<PendingOAuthBindLoginResponse, 'error'>
): boolean {
return completion.error === 'invitation_required'
}
export function hasPendingOAuthSuggestedProfile(
completion: Pick<
PendingOAuthBindLoginResponse,
'suggested_display_name' | 'suggested_avatar_url'
>
): boolean {
return Boolean(completion.suggested_display_name || completion.suggested_avatar_url)
}
export function persistOAuthTokenContext(tokens: Partial<OAuthTokenResponse>): void {
if (tokens.refresh_token) {
setRefreshToken(tokens.refresh_token)
}
if (tokens.expires_in) {
setTokenExpiresAt(tokens.expires_in)
}
}
export async function prepareOAuthBindAccessTokenCookie(): Promise<void> {
if (!getAuthToken()) {
return
}
await apiClient.post('/auth/oauth/bind-token')
}
/**
* Refresh the access token using the refresh token
* @returns New token pair
*/
export async function refreshToken(): Promise<RefreshTokenResponse> {
return refreshAuthTokens()
}
/**
* Revoke all sessions for the current user
* @returns Response with message
*/
export async function revokeAllSessions(): Promise<{ message: string }> {
const { data } = await apiClient.post<{ message: string }>('/auth/revoke-all-sessions')
return data
}
/**
* Check if user is authenticated
* @returns True if user has valid token
*/
export function isAuthenticated(): boolean {
return getAuthToken() !== null
}
/**
* Get public settings (no auth required)
* @returns Public settings including registration and Turnstile config
*/
export async function getPublicSettings(): Promise<PublicSettings> {
const { data } = await apiClient.get<PublicSettings>('/settings/public')
return data
}
export type WeChatOAuthMode = 'open' | 'mp'
export type WeChatOAuthUnavailableReason =
| 'not_configured'
| 'capability_unknown'
| 'external_browser_required'
| 'wechat_browser_required'
| 'native_app_required'
export interface ResolvedWeChatOAuthStart {
mode: WeChatOAuthMode | null
openEnabled: boolean
mpEnabled: boolean
mobileEnabled: boolean
isWeChatBrowser: boolean
unavailableReason: WeChatOAuthUnavailableReason | null
}
export type WeChatOAuthPublicSettings = {
wechat_oauth_enabled?: boolean
wechat_oauth_open_enabled?: boolean
wechat_oauth_mp_enabled?: boolean
wechat_oauth_mobile_enabled?: boolean
}
export function isWeChatWebOAuthEnabled(
settings: WeChatOAuthPublicSettings | null | undefined,
): boolean {
const legacyEnabled = settings?.wechat_oauth_enabled ?? false
const hasExplicitCapabilities =
typeof settings?.wechat_oauth_open_enabled === 'boolean' ||
typeof settings?.wechat_oauth_mp_enabled === 'boolean'
if (!hasExplicitCapabilities) {
return legacyEnabled
}
return settings?.wechat_oauth_open_enabled === true || settings?.wechat_oauth_mp_enabled === true
}
export function hasExplicitWeChatOAuthCapabilities(
settings: WeChatOAuthPublicSettings | null | undefined,
): settings is WeChatOAuthPublicSettings & {
wechat_oauth_open_enabled: boolean
wechat_oauth_mp_enabled: boolean
} {
return typeof settings?.wechat_oauth_open_enabled === 'boolean'
&& typeof settings?.wechat_oauth_mp_enabled === 'boolean'
}
export function resolveWeChatOAuthStart(
settings: WeChatOAuthPublicSettings | null | undefined,
userAgent?: string
): ResolvedWeChatOAuthStart {
const normalizedUserAgent = (userAgent
?? (typeof navigator !== 'undefined' ? navigator.userAgent : '')
?? '').trim()
const isWeChatBrowser = /MicroMessenger/i.test(normalizedUserAgent)
const legacyEnabled = settings?.wechat_oauth_enabled ?? false
const openEnabled = typeof settings?.wechat_oauth_open_enabled === 'boolean'
? settings.wechat_oauth_open_enabled
: legacyEnabled
const mpEnabled = typeof settings?.wechat_oauth_mp_enabled === 'boolean'
? settings.wechat_oauth_mp_enabled
: legacyEnabled
const mobileEnabled = typeof settings?.wechat_oauth_mobile_enabled === 'boolean'
? settings.wechat_oauth_mobile_enabled
: false
if (isWeChatBrowser) {
if (mpEnabled) {
return { mode: 'mp', openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: null }
}
if (openEnabled) {
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'external_browser_required' }
}
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'not_configured' }
}
if (openEnabled) {
return { mode: 'open', openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: null }
}
if (mpEnabled) {
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'wechat_browser_required' }
}
return { mode: null, openEnabled, mpEnabled, mobileEnabled, isWeChatBrowser, unavailableReason: 'not_configured' }
}
export function resolveWeChatOAuthStartStrict(
settings: WeChatOAuthPublicSettings | null | undefined,
userAgent?: string,
): ResolvedWeChatOAuthStart {
const normalizedUserAgent = (userAgent
?? (typeof navigator !== 'undefined' ? navigator.userAgent : '')
?? '').trim()
const isWeChatBrowser = /MicroMessenger/i.test(normalizedUserAgent)
if (!hasExplicitWeChatOAuthCapabilities(settings)) {
return {
mode: null,
openEnabled: false,
mpEnabled: false,
mobileEnabled: false,
isWeChatBrowser,
unavailableReason: 'capability_unknown',
}
}
return resolveWeChatOAuthStart(settings, normalizedUserAgent)
}
/**
* Send verification code to email
* @param request - Email and optional Turnstile token
* @returns Response with countdown seconds
*/
export async function sendVerifyCode(
request: SendVerifyCodeRequest
): Promise<SendVerifyCodeResponse> {
const { data } = await apiClient.post<SendVerifyCodeResponse>('/auth/send-verify-code', request)
return data
}
export async function sendPendingOAuthVerifyCode(
request: SendVerifyCodeRequest
): Promise<PendingOAuthSendVerifyCodeResponse> {
const { data } = await apiClient.post<PendingOAuthSendVerifyCodeResponse>(
'/auth/oauth/pending/send-verify-code',
request
)
return data
}
/**
* Validate promo code response
*/
export interface ValidatePromoCodeResponse {
valid: boolean
bonus_amount?: number
error_code?: string
message?: string
}
/**
* Validate promo code (public endpoint, no auth required)
* @param code - Promo code to validate
* @returns Validation result with bonus amount if valid
*/
export async function validatePromoCode(code: string): Promise<ValidatePromoCodeResponse> {
const { data } = await apiClient.post<ValidatePromoCodeResponse>('/auth/validate-promo-code', { code })
return data
}
/**
* Validate invitation code response
*/
export interface ValidateInvitationCodeResponse {
valid: boolean
error_code?: string
}
/**
* Validate invitation code (public endpoint, no auth required)
* @param code - Invitation code to validate
* @returns Validation result
*/
export async function validateInvitationCode(code: string): Promise<ValidateInvitationCodeResponse> {
const { data } = await apiClient.post<ValidateInvitationCodeResponse>('/auth/validate-invitation-code', { code })
return data
}
/**
* Forgot password request
*/
export interface ForgotPasswordRequest {
email: string
turnstile_token?: string
tencent_captcha_ticket?: string
tencent_captcha_randstr?: string
}
/**
* Forgot password response
*/
export interface ForgotPasswordResponse {
message: string
}
/**
* Request password reset link
* @param request - Email and optional Turnstile token
* @returns Response with message
*/
export async function forgotPassword(request: ForgotPasswordRequest): Promise<ForgotPasswordResponse> {
const { data } = await apiClient.post<ForgotPasswordResponse>('/auth/forgot-password', request)
return data
}
/**
* Reset password request
*/
export interface ResetPasswordRequest {
email: string
token: string
new_password: string
}
/**
* Reset password response
*/
export interface ResetPasswordResponse {
message: string
}
/**
* Reset password with token
* @param request - Email, token, and new password
* @returns Response with message
*/
export async function resetPassword(request: ResetPasswordRequest): Promise<ResetPasswordResponse> {
const { data } = await apiClient.post<ResetPasswordResponse>('/auth/reset-password', request)
return data
}
/**
* Complete LinuxDo OAuth registration by supplying an invitation code
* @param invitationCode - Invitation code entered by the user
* @returns Token pair on success
*/
export async function completeLinuxDoOAuthRegistration(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> {
return createPendingLinuxDoOAuthAccount(invitationCode, decision, affiliateCode)
}
/**
* Complete OIDC OAuth registration by supplying an invitation code
* @param invitationCode - Invitation code entered by the user
* @returns Token pair on success
*/
export async function completeOIDCOAuthRegistration(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> {
return createPendingOIDCOAuthAccount(invitationCode, decision, affiliateCode)
}
export async function completeWeChatOAuthRegistration(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<OAuthTokenResponse> {
return createPendingWeChatOAuthAccount(invitationCode, decision, affiliateCode)
}
async function createPendingOAuthAccount(
provider: 'linuxdo' | 'oidc' | 'wechat' | 'dingtalk',
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
const normalizedAffiliateCode = affiliateCode?.trim()
const { data } = await apiClient.post<PendingOAuthCreateAccountResponse>(
`/auth/oauth/${provider}/complete-registration`,
{
invitation_code: invitationCode,
...(normalizedAffiliateCode ? { aff_code: normalizedAffiliateCode } : {}),
...serializeOAuthAdoptionDecision(decision)
}
)
return data
}
export async function createPendingLinuxDoOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('linuxdo', invitationCode, decision, affiliateCode)
}
export async function createPendingOIDCOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('oidc', invitationCode, decision, affiliateCode)
}
export async function createPendingWeChatOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('wechat', invitationCode, decision, affiliateCode)
}
export async function createPendingDingTalkOAuthAccount(
invitationCode: string,
decision?: OAuthAdoptionDecision,
affiliateCode?: string
): Promise<PendingOAuthCreateAccountResponse> {
return createPendingOAuthAccount('dingtalk', invitationCode, decision, affiliateCode)
}
export async function completePendingOAuthBindLogin(
decision?: OAuthAdoptionDecision
): Promise<PendingOAuthBindLoginResponse> {
const { data } = await apiClient.post<PendingOAuthBindLoginResponse>(
'/auth/oauth/pending/exchange',
serializeOAuthAdoptionDecision(decision)
)
return data
}
export async function exchangePendingOAuthCompletion(
decision?: OAuthAdoptionDecision
): Promise<PendingOAuthExchangeResponse> {
return completePendingOAuthBindLogin(decision)
}
export const authAPI = {
login,
login2FA,
isTotp2FARequired,
register,
getCurrentUser,
logout,
isAuthenticated,
setAuthToken,
setRefreshToken,
setTokenExpiresAt,
getAuthToken,
getRefreshToken,
getTokenExpiresAt,
clearAuthToken,
getPublicSettings,
sendVerifyCode,
sendPendingOAuthVerifyCode,
validatePromoCode,
validateInvitationCode,
forgotPassword,
resetPassword,
refreshToken,
revokeAllSessions,
getPendingOAuthBindLoginKind,
isPendingOAuthCreateAccountRequired,
hasPendingOAuthSuggestedProfile,
completePendingOAuthBindLogin,
createPendingLinuxDoOAuthAccount,
createPendingOIDCOAuthAccount,
createPendingWeChatOAuthAccount,
exchangePendingOAuthCompletion,
completeLinuxDoOAuthRegistration,
completeOIDCOAuthRegistration,
completeWeChatOAuthRegistration,
createPendingDingTalkOAuthAccount
}
export default authAPI