/** * Fmode 用户鉴权 * * 通过调用 Fmode 网关的 doApi 接口校验 Bearer token 是否有效。 * 默认使用 /user/getProfile,不携带 guid 时上游会返回参数错误, * 但只要 token 有效就不会返回“认证失败”,从而在无副作用的情况下完成校验。 */ import { getFmodeGatewayUrl, getFmodeTokenValidateMethod } from './config.js'; import { sha256Hash } from './crypto.js'; export interface FmodeUser { userId: string; nickname?: string; mobile?: string; avatarUrl?: string; } const VALIDATE_TIMEOUT_MS = 15000; const MAX_RETRIES = 3; const RETRY_DELAY_MS = 800; function isAuthFailure(resp: Response, json: unknown): boolean { if (resp.status === 401) return true; if (!json || typeof json !== 'object') return false; const payload = json as Record; const message = String(payload.mess || payload.msg || payload.message || payload.error || ''); return /认证失败|unauthorized|invalid token|token.*无效|token.*失效|未授权/i.test(message); } function sleep(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } async function tryValidateToken( token: string, deviceGuid?: string, ): Promise<{ resp: Response; json: unknown }> { const url = `${getFmodeGatewayUrl()}/doApi`; const method = getFmodeTokenValidateMethod(); const body = { uid: `relay-${Date.now()}`, method, params: deviceGuid ? { guid: deviceGuid } : {}, }; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), VALIDATE_TIMEOUT_MS); try { const resp = await fetch(url, { method: 'POST', headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', Accept: 'application/json', }, body: JSON.stringify(body), signal: controller.signal, }); let json: unknown; try { json = await resp.json(); } catch { json = undefined; } return { resp, json }; } finally { clearTimeout(timer); } } export async function verifyFmodeToken( token: string, deviceGuid?: string, ): Promise { if (!token) { throw new Error('Missing Fmode token'); } let lastError: Error | undefined; for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) { try { const { resp, json } = await tryValidateToken(token, deviceGuid); if (isAuthFailure(resp, json)) { throw new Error('Fmode token 无效'); } const payload = json && typeof json === 'object' ? (json as Record) : {}; const data = payload.data && typeof payload.data === 'object' ? (payload.data as Record) : {}; let userId = (data.userId as string) || (data.acctid as string) || (data.user_id as string) || (data.mobile as string); // 如果提供了 guid 仍拿不到 userId,降级为 token 哈希(保证一人一租户的风控) if (!userId) { userId = `token_${sha256Hash(token).slice(0, 24)}`; if (deviceGuid) { console.warn('[FmodeAuth] 已提供 guid 但未返回 userId,使用 token 哈希作为用户标识'); } } return { userId, nickname: data.nickname as string | undefined, mobile: data.mobile as string | undefined, avatarUrl: data.avatarUrl as string | undefined, }; } catch (err) { const message = err instanceof Error ? err.message : String(err); // 认证错误不重试 if (/Fmode token 无效|Missing Fmode token/i.test(message)) { throw err; } lastError = err instanceof Error ? err : new Error(message); if (attempt < MAX_RETRIES) { console.warn(`[FmodeAuth] 校验失败,第 ${attempt} 次重试: ${message}`); await sleep(RETRY_DELAY_MS * attempt); } } } throw new Error(`Fmode 网关无法访问: ${lastError?.message || '未知错误'}`); }