fmode-auth.ts 4.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. /**
  2. * Fmode 用户鉴权
  3. *
  4. * 通过调用 Fmode 网关的 doApi 接口校验 Bearer token 是否有效。
  5. * 默认使用 /user/getProfile,不携带 guid 时上游会返回参数错误,
  6. * 但只要 token 有效就不会返回“认证失败”,从而在无副作用的情况下完成校验。
  7. */
  8. import { getFmodeGatewayUrl, getFmodeTokenValidateMethod } from './config.js';
  9. import { sha256Hash } from './crypto.js';
  10. export interface FmodeUser {
  11. userId: string;
  12. nickname?: string;
  13. mobile?: string;
  14. avatarUrl?: string;
  15. }
  16. const VALIDATE_TIMEOUT_MS = 15000;
  17. const MAX_RETRIES = 3;
  18. const RETRY_DELAY_MS = 800;
  19. function isAuthFailure(resp: Response, json: unknown): boolean {
  20. if (resp.status === 401) return true;
  21. if (!json || typeof json !== 'object') return false;
  22. const payload = json as Record<string, unknown>;
  23. const message = String(payload.mess || payload.msg || payload.message || payload.error || '');
  24. return /认证失败|unauthorized|invalid token|token.*无效|token.*失效|未授权/i.test(message);
  25. }
  26. function sleep(ms: number): Promise<void> {
  27. return new Promise((resolve) => setTimeout(resolve, ms));
  28. }
  29. async function tryValidateToken(
  30. token: string,
  31. deviceGuid?: string,
  32. ): Promise<{ resp: Response; json: unknown }> {
  33. const url = `${getFmodeGatewayUrl()}/doApi`;
  34. const method = getFmodeTokenValidateMethod();
  35. const body = {
  36. uid: `relay-${Date.now()}`,
  37. method,
  38. params: deviceGuid ? { guid: deviceGuid } : {},
  39. };
  40. const controller = new AbortController();
  41. const timer = setTimeout(() => controller.abort(), VALIDATE_TIMEOUT_MS);
  42. try {
  43. const resp = await fetch(url, {
  44. method: 'POST',
  45. headers: {
  46. Authorization: `Bearer ${token}`,
  47. 'Content-Type': 'application/json',
  48. Accept: 'application/json',
  49. },
  50. body: JSON.stringify(body),
  51. signal: controller.signal,
  52. });
  53. let json: unknown;
  54. try {
  55. json = await resp.json();
  56. } catch {
  57. json = undefined;
  58. }
  59. return { resp, json };
  60. } finally {
  61. clearTimeout(timer);
  62. }
  63. }
  64. export async function verifyFmodeToken(
  65. token: string,
  66. deviceGuid?: string,
  67. ): Promise<FmodeUser> {
  68. if (!token) {
  69. throw new Error('Missing Fmode token');
  70. }
  71. let lastError: Error | undefined;
  72. for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
  73. try {
  74. const { resp, json } = await tryValidateToken(token, deviceGuid);
  75. if (isAuthFailure(resp, json)) {
  76. throw new Error('Fmode token 无效');
  77. }
  78. const payload = json && typeof json === 'object' ? (json as Record<string, unknown>) : {};
  79. const data =
  80. payload.data && typeof payload.data === 'object'
  81. ? (payload.data as Record<string, unknown>)
  82. : {};
  83. let userId =
  84. (data.userId as string) ||
  85. (data.acctid as string) ||
  86. (data.user_id as string) ||
  87. (data.mobile as string);
  88. // 如果提供了 guid 仍拿不到 userId,降级为 token 哈希(保证一人一租户的风控)
  89. if (!userId) {
  90. userId = `token_${sha256Hash(token).slice(0, 24)}`;
  91. if (deviceGuid) {
  92. console.warn('[FmodeAuth] 已提供 guid 但未返回 userId,使用 token 哈希作为用户标识');
  93. }
  94. }
  95. return {
  96. userId,
  97. nickname: data.nickname as string | undefined,
  98. mobile: data.mobile as string | undefined,
  99. avatarUrl: data.avatarUrl as string | undefined,
  100. };
  101. } catch (err) {
  102. const message = err instanceof Error ? err.message : String(err);
  103. // 认证错误不重试
  104. if (/Fmode token 无效|Missing Fmode token/i.test(message)) {
  105. throw err;
  106. }
  107. lastError = err instanceof Error ? err : new Error(message);
  108. if (attempt < MAX_RETRIES) {
  109. console.warn(`[FmodeAuth] 校验失败,第 ${attempt} 次重试: ${message}`);
  110. await sleep(RETRY_DELAY_MS * attempt);
  111. }
  112. }
  113. }
  114. throw new Error(`Fmode 网关无法访问: ${lastError?.message || '未知错误'}`);
  115. }