fmode-login-proxy.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. // fmode-login-proxy v0.1.0 — 登录接口代理(绕开平台网关的多值 CORS)
  2. //
  3. // ## 为什么需要它
  4. //
  5. // Android App 打包后 WebView 的 origin 是 `https://localhost`,**没有 nginx 同源代理**
  6. // (Web 端有 `/api-platform/**` 反代,反代会清空上游 ACAO 头,所以 Web 端从没暴露)。
  7. //
  8. // 实测 2026-11-15:平台网关 `/api/apig/message`(登录发验证码)会**随机返回两条**
  9. // `Access-Control-Allow-Origin`:
  10. //
  11. // 第 1、2 次: access-control-allow-origin: * ← 正常
  12. // 第 3 次: access-control-allow-origin: https://localhost ← 中间件 A 回显
  13. // access-control-allow-origin: * ← 中间件 B 固定
  14. //
  15. // 浏览器规定 ACAO 只能有一个值 → 直接判定非法 → 登录随机失败。
  16. // 根因是**平台网关两层各加了一次头**,代码不在我们能改的地方。
  17. //
  18. // ## 本函数怎么绕
  19. //
  20. // 服务端之间 HTTP 调用**不经过浏览器 CORS 检查**。
  21. // 浏览器 → /api/functions(实测稳定单值 ACAO)→ 本函数 → 服务端 fetch apig/message
  22. // App 侧只面对 `/api/functions` 这一个稳定接口。
  23. //
  24. // ## 调用
  25. //
  26. // POST /api/functions {id, params:{action:'sendCode', mobile}}
  27. // → {code:200} 发送成功
  28. // → {code:<平台码>, msg} 平台业务错误(手机号格式等),原样透传
  29. //
  30. // POST /api/functions {id, params:{action:'login', mobile, code}}
  31. // → {code:200, token, me:{...}} 登录成功
  32. // → {code:<平台码>, msg} 失败原因
  33. //
  34. // ## 安全约束
  35. //
  36. // ① **不接受任意 URL**:只允许调这两个固定路径,不做开放代理
  37. // ② **company 固定**:由服务端决定,不信任客户端传入
  38. // ③ **不记录 token**:日志里只打 code,绝不打印 token / 验证码
  39. // ④ **限流**:同手机号 60 秒内最多 1 次发码(防止被当成短信轰炸跳板)
  40. // ⑤ **超时**:10 秒,避免函数挂死
  41. var API_ORIGIN = 'https://server.fmode.cn';
  42. var SEND_CODE_PATH = '/api/apig/message';
  43. var LOGIN_PATH = '/api/fmode/mobile';
  44. /**
  45. * 🔴 `LOGIN_COMPANY` 必须是**真实 Company 的 objectId**,不是 appId。
  46. *
  47. * 实测 2026-11-15:
  48. * company = 'ncloudmaster'(Parse appId) → 接口挂起 20 秒后超时(HTTP 000)
  49. * company = 'E4KpGvTEto'(飞码 Company) → 0.59s 返回 {"code":1,"data":{"msg":"发送成功"}}
  50. *
  51. * 「超时」而不是「报错」很容易被误判成网络问题/接口挂了 —— 实际是参数不对。
  52. * 与前端 `parse-api.ts` 的 `LOGIN_COMPANY` 保持同值。
  53. */
  54. var LOGIN_COMPANY = 'E4KpGvTEto';
  55. var FETCH_TIMEOUT_MS = 10000;
  56. /** 同手机号发码冷却(毫秒) */
  57. var CODE_COOLDOWN_MS = 60000;
  58. function _ok(response, data) {
  59. var body = { code: 200 };
  60. if (data) for (var k in data) body[k] = data[k];
  61. response.status(200).json(body);
  62. }
  63. function _fail(response, code, msg) {
  64. response.status(200).json({ code: code, msg: msg });
  65. }
  66. function _str(v, max) {
  67. if (v === undefined || v === null) return '';
  68. var s = String(v).trim();
  69. return max && s.length > max ? s.slice(0, max) : s;
  70. }
  71. /** 手机号:只接受 11 位数字(大陆),防止被拿来扫号 */
  72. function _isMobile(s) {
  73. return /^1\d{10}$/.test(s);
  74. }
  75. /** 带超时的服务端 fetch(平台 Node 环境兼容 AbortController 不可用时退化) */
  76. function _fetchWithTimeout(url, opts) {
  77. return new Promise(function (resolve, reject) {
  78. var done = false;
  79. var timer = setTimeout(function () {
  80. if (done) return;
  81. done = true;
  82. reject(new Error('timeout'));
  83. }, FETCH_TIMEOUT_MS);
  84. fetch(url, opts).then(
  85. function (res) {
  86. if (done) return;
  87. done = true;
  88. clearTimeout(timer);
  89. resolve(res);
  90. },
  91. function (e) {
  92. if (done) return;
  93. done = true;
  94. clearTimeout(timer);
  95. reject(e);
  96. }
  97. );
  98. });
  99. }
  100. /** 读 JSON,失败时返回 null(平台可能回 HTML 错误页) */
  101. function _readJson(res) {
  102. return res.text().then(function (t) {
  103. try {
  104. return JSON.parse(t);
  105. } catch (e) {
  106. return null;
  107. }
  108. });
  109. }
  110. /**
  111. * 发码冷却 —— 用**进程内内存**实现(2026-11-15)
  112. *
  113. * 🔴 为什么不用 Parse 表记录:
  114. * 初版写 `Parse.Object('MetaloginCooldown').save({useMasterKey:true})`,
  115. * 实测报 `This user is not allowed to access non-existent class: MetaloginCooldown`
  116. * —— 云函数沙箱**不允许自动建类**,必须先由平台建好表才行。
  117. * 为一个 60 秒的冷却去申请建表不值得,改用内存。
  118. *
  119. * 局限(如实说明):云函数多实例时各自记各自的,冷却不完全生效;
  120. * 实例重启即清空。**真正的防刷在短信服务商侧**,这里只挡「用户手滑连点」。
  121. */
  122. var _codeStamps = {};
  123. function _checkCooldown(mobile) {
  124. var at = _codeStamps[mobile];
  125. if (!at) return false;
  126. if (Date.now() - at < CODE_COOLDOWN_MS) return true;
  127. delete _codeStamps[mobile];
  128. return false;
  129. }
  130. function _stampCooldown(mobile) {
  131. _codeStamps[mobile] = Date.now();
  132. /* 顺手清理过期条目,避免长期运行把内存撑大 */
  133. var now = Date.now();
  134. for (var k in _codeStamps) {
  135. if (now - _codeStamps[k] > CODE_COOLDOWN_MS * 10) delete _codeStamps[k];
  136. }
  137. }
  138. // ---------- action: 发验证码 ----------
  139. async function _sendCode(response, mobile) {
  140. if (!_isMobile(mobile)) return _fail(response, 400, '请输入正确的手机号');
  141. try {
  142. var cooling = _checkCooldown(mobile);
  143. if (cooling) return _fail(response, 429, '发送太频繁,请 1 分钟后再试');
  144. _stampCooldown(mobile);
  145. var res = await _fetchWithTimeout(API_ORIGIN + SEND_CODE_PATH, {
  146. method: 'POST',
  147. headers: { 'Content-Type': 'application/json' },
  148. body: JSON.stringify({ mobile: mobile, company: LOGIN_COMPANY }),
  149. });
  150. var d = await _readJson(res);
  151. if (!d) return _fail(response, 502, '登录服务返回异常,请稍后重试');
  152. // 平台约定:code === 1 才算发送成功
  153. if (d.code !== 1) {
  154. return _fail(response, Number(d.code) || 500, d.msg || d.mess || '发送失败');
  155. }
  156. return _ok(response);
  157. } catch (e) {
  158. return _fail(
  159. response,
  160. 502,
  161. e && e.message === 'timeout' ? '登录服务超时,请重试' : '网络异常,请重试'
  162. );
  163. }
  164. }
  165. // ---------- action: 登录兑换 ----------
  166. async function _login(response, mobile, code) {
  167. if (!_isMobile(mobile)) return _fail(response, 400, '请输入正确的手机号');
  168. if (!code) return _fail(response, 400, '请输入验证码');
  169. var url =
  170. API_ORIGIN +
  171. LOGIN_PATH +
  172. '?mobile=' + encodeURIComponent(mobile) +
  173. '&code=' + encodeURIComponent(code) +
  174. '&company=' + encodeURIComponent(LOGIN_COMPANY);
  175. try {
  176. var res = await _fetchWithTimeout(url, { method: 'GET' });
  177. var d = await _readJson(res);
  178. if (!d) return _fail(response, 502, '登录服务返回异常,请稍后重试');
  179. var token = d.data && d.data.token;
  180. // 平台约定:code === 200 && data.token
  181. if (d.code !== 200 || !token) {
  182. return _fail(response, Number(d.code) || 401, d.msg || d.mess || '登录失败');
  183. }
  184. // 🔴 不打印 token,只回传给客户端
  185. return _ok(response, { token: token });
  186. } catch (e) {
  187. return _fail(
  188. response,
  189. 502,
  190. e && e.message === 'timeout' ? '登录服务超时,请重试' : '网络异常,请重试'
  191. );
  192. }
  193. }
  194. // ==================== 入口 ====================
  195. //
  196. // 🔴 平台的执行器**不提供** `Parse.Cloud.define`(实测 2026-11-15:
  197. // 写成 define 会报 `Parse.Cloud.define is not a function`)。
  198. // 真实约定是**顶层定义 `handler(request, response)`**,
  199. // 入参由平台注入——`params` 是全局变量,`response` 必须显式 `.status().json()`。
  200. // 与 fmode-file-read / fmode-file-hub 同一写法。
  201. function handler(request, response) {
  202. (async function () {
  203. var p = params || {};
  204. var action = _str(p.action, 32);
  205. var mobile = _str(p.mobile, 20);
  206. if (action === 'sendCode') {
  207. await _sendCode(response, mobile);
  208. return;
  209. }
  210. if (action === 'login') {
  211. await _login(response, mobile, _str(p.code, 10));
  212. return;
  213. }
  214. return _fail(response, 400, '未知操作:' + action);
  215. })().catch(function (e) {
  216. _fail(response, 500, 'server error: ' + (e && e.message ? e.message : 'unknown'));
  217. });
  218. }