|
|
@@ -0,0 +1,241 @@
|
|
|
+// fmode-login-proxy v0.1.0 — 登录接口代理(绕开平台网关的多值 CORS)
|
|
|
+//
|
|
|
+// ## 为什么需要它
|
|
|
+//
|
|
|
+// Android App 打包后 WebView 的 origin 是 `https://localhost`,**没有 nginx 同源代理**
|
|
|
+// (Web 端有 `/api-platform/**` 反代,反代会清空上游 ACAO 头,所以 Web 端从没暴露)。
|
|
|
+//
|
|
|
+// 实测 2026-11-15:平台网关 `/api/apig/message`(登录发验证码)会**随机返回两条**
|
|
|
+// `Access-Control-Allow-Origin`:
|
|
|
+//
|
|
|
+// 第 1、2 次: access-control-allow-origin: * ← 正常
|
|
|
+// 第 3 次: access-control-allow-origin: https://localhost ← 中间件 A 回显
|
|
|
+// access-control-allow-origin: * ← 中间件 B 固定
|
|
|
+//
|
|
|
+// 浏览器规定 ACAO 只能有一个值 → 直接判定非法 → 登录随机失败。
|
|
|
+// 根因是**平台网关两层各加了一次头**,代码不在我们能改的地方。
|
|
|
+//
|
|
|
+// ## 本函数怎么绕
|
|
|
+//
|
|
|
+// 服务端之间 HTTP 调用**不经过浏览器 CORS 检查**。
|
|
|
+// 浏览器 → /api/functions(实测稳定单值 ACAO)→ 本函数 → 服务端 fetch apig/message
|
|
|
+// App 侧只面对 `/api/functions` 这一个稳定接口。
|
|
|
+//
|
|
|
+// ## 调用
|
|
|
+//
|
|
|
+// POST /api/functions {id, params:{action:'sendCode', mobile}}
|
|
|
+// → {code:200} 发送成功
|
|
|
+// → {code:<平台码>, msg} 平台业务错误(手机号格式等),原样透传
|
|
|
+//
|
|
|
+// POST /api/functions {id, params:{action:'login', mobile, code}}
|
|
|
+// → {code:200, token, me:{...}} 登录成功
|
|
|
+// → {code:<平台码>, msg} 失败原因
|
|
|
+//
|
|
|
+// ## 安全约束
|
|
|
+//
|
|
|
+// ① **不接受任意 URL**:只允许调这两个固定路径,不做开放代理
|
|
|
+// ② **company 固定**:由服务端决定,不信任客户端传入
|
|
|
+// ③ **不记录 token**:日志里只打 code,绝不打印 token / 验证码
|
|
|
+// ④ **限流**:同手机号 60 秒内最多 1 次发码(防止被当成短信轰炸跳板)
|
|
|
+// ⑤ **超时**:10 秒,避免函数挂死
|
|
|
+
|
|
|
+var API_ORIGIN = 'https://server.fmode.cn';
|
|
|
+var SEND_CODE_PATH = '/api/apig/message';
|
|
|
+var LOGIN_PATH = '/api/fmode/mobile';
|
|
|
+/**
|
|
|
+ * 🔴 `LOGIN_COMPANY` 必须是**真实 Company 的 objectId**,不是 appId。
|
|
|
+ *
|
|
|
+ * 实测 2026-11-15:
|
|
|
+ * company = 'ncloudmaster'(Parse appId) → 接口挂起 20 秒后超时(HTTP 000)
|
|
|
+ * company = 'E4KpGvTEto'(飞码 Company) → 0.59s 返回 {"code":1,"data":{"msg":"发送成功"}}
|
|
|
+ *
|
|
|
+ * 「超时」而不是「报错」很容易被误判成网络问题/接口挂了 —— 实际是参数不对。
|
|
|
+ * 与前端 `parse-api.ts` 的 `LOGIN_COMPANY` 保持同值。
|
|
|
+ */
|
|
|
+var LOGIN_COMPANY = 'E4KpGvTEto';
|
|
|
+var FETCH_TIMEOUT_MS = 10000;
|
|
|
+/** 同手机号发码冷却(毫秒) */
|
|
|
+var CODE_COOLDOWN_MS = 60000;
|
|
|
+
|
|
|
+function _ok(response, data) {
|
|
|
+ var body = { code: 200 };
|
|
|
+ if (data) for (var k in data) body[k] = data[k];
|
|
|
+ response.status(200).json(body);
|
|
|
+}
|
|
|
+
|
|
|
+function _fail(response, code, msg) {
|
|
|
+ response.status(200).json({ code: code, msg: msg });
|
|
|
+}
|
|
|
+
|
|
|
+function _str(v, max) {
|
|
|
+ if (v === undefined || v === null) return '';
|
|
|
+ var s = String(v).trim();
|
|
|
+ return max && s.length > max ? s.slice(0, max) : s;
|
|
|
+}
|
|
|
+
|
|
|
+/** 手机号:只接受 11 位数字(大陆),防止被拿来扫号 */
|
|
|
+function _isMobile(s) {
|
|
|
+ return /^1\d{10}$/.test(s);
|
|
|
+}
|
|
|
+
|
|
|
+/** 带超时的服务端 fetch(平台 Node 环境兼容 AbortController 不可用时退化) */
|
|
|
+function _fetchWithTimeout(url, opts) {
|
|
|
+ return new Promise(function (resolve, reject) {
|
|
|
+ var done = false;
|
|
|
+ var timer = setTimeout(function () {
|
|
|
+ if (done) return;
|
|
|
+ done = true;
|
|
|
+ reject(new Error('timeout'));
|
|
|
+ }, FETCH_TIMEOUT_MS);
|
|
|
+
|
|
|
+ fetch(url, opts).then(
|
|
|
+ function (res) {
|
|
|
+ if (done) return;
|
|
|
+ done = true;
|
|
|
+ clearTimeout(timer);
|
|
|
+ resolve(res);
|
|
|
+ },
|
|
|
+ function (e) {
|
|
|
+ if (done) return;
|
|
|
+ done = true;
|
|
|
+ clearTimeout(timer);
|
|
|
+ reject(e);
|
|
|
+ }
|
|
|
+ );
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/** 读 JSON,失败时返回 null(平台可能回 HTML 错误页) */
|
|
|
+function _readJson(res) {
|
|
|
+ return res.text().then(function (t) {
|
|
|
+ try {
|
|
|
+ return JSON.parse(t);
|
|
|
+ } catch (e) {
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ });
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * 发码冷却 —— 用**进程内内存**实现(2026-11-15)
|
|
|
+ *
|
|
|
+ * 🔴 为什么不用 Parse 表记录:
|
|
|
+ * 初版写 `Parse.Object('MetaloginCooldown').save({useMasterKey:true})`,
|
|
|
+ * 实测报 `This user is not allowed to access non-existent class: MetaloginCooldown`
|
|
|
+ * —— 云函数沙箱**不允许自动建类**,必须先由平台建好表才行。
|
|
|
+ * 为一个 60 秒的冷却去申请建表不值得,改用内存。
|
|
|
+ *
|
|
|
+ * 局限(如实说明):云函数多实例时各自记各自的,冷却不完全生效;
|
|
|
+ * 实例重启即清空。**真正的防刷在短信服务商侧**,这里只挡「用户手滑连点」。
|
|
|
+ */
|
|
|
+var _codeStamps = {};
|
|
|
+
|
|
|
+function _checkCooldown(mobile) {
|
|
|
+ var at = _codeStamps[mobile];
|
|
|
+ if (!at) return false;
|
|
|
+ if (Date.now() - at < CODE_COOLDOWN_MS) return true;
|
|
|
+ delete _codeStamps[mobile];
|
|
|
+ return false;
|
|
|
+}
|
|
|
+
|
|
|
+function _stampCooldown(mobile) {
|
|
|
+ _codeStamps[mobile] = Date.now();
|
|
|
+ /* 顺手清理过期条目,避免长期运行把内存撑大 */
|
|
|
+ var now = Date.now();
|
|
|
+ for (var k in _codeStamps) {
|
|
|
+ if (now - _codeStamps[k] > CODE_COOLDOWN_MS * 10) delete _codeStamps[k];
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- action: 发验证码 ----------
|
|
|
+
|
|
|
+async function _sendCode(response, mobile) {
|
|
|
+ if (!_isMobile(mobile)) return _fail(response, 400, '请输入正确的手机号');
|
|
|
+
|
|
|
+ try {
|
|
|
+ var cooling = _checkCooldown(mobile);
|
|
|
+ if (cooling) return _fail(response, 429, '发送太频繁,请 1 分钟后再试');
|
|
|
+
|
|
|
+ _stampCooldown(mobile);
|
|
|
+
|
|
|
+ var res = await _fetchWithTimeout(API_ORIGIN + SEND_CODE_PATH, {
|
|
|
+ method: 'POST',
|
|
|
+ headers: { 'Content-Type': 'application/json' },
|
|
|
+ body: JSON.stringify({ mobile: mobile, company: LOGIN_COMPANY }),
|
|
|
+ });
|
|
|
+ var d = await _readJson(res);
|
|
|
+ if (!d) return _fail(response, 502, '登录服务返回异常,请稍后重试');
|
|
|
+ // 平台约定:code === 1 才算发送成功
|
|
|
+ if (d.code !== 1) {
|
|
|
+ return _fail(response, Number(d.code) || 500, d.msg || d.mess || '发送失败');
|
|
|
+ }
|
|
|
+ return _ok(response);
|
|
|
+ } catch (e) {
|
|
|
+ return _fail(
|
|
|
+ response,
|
|
|
+ 502,
|
|
|
+ e && e.message === 'timeout' ? '登录服务超时,请重试' : '网络异常,请重试'
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ---------- action: 登录兑换 ----------
|
|
|
+
|
|
|
+async function _login(response, mobile, code) {
|
|
|
+ if (!_isMobile(mobile)) return _fail(response, 400, '请输入正确的手机号');
|
|
|
+ if (!code) return _fail(response, 400, '请输入验证码');
|
|
|
+
|
|
|
+ var url =
|
|
|
+ API_ORIGIN +
|
|
|
+ LOGIN_PATH +
|
|
|
+ '?mobile=' + encodeURIComponent(mobile) +
|
|
|
+ '&code=' + encodeURIComponent(code) +
|
|
|
+ '&company=' + encodeURIComponent(LOGIN_COMPANY);
|
|
|
+
|
|
|
+ try {
|
|
|
+ var res = await _fetchWithTimeout(url, { method: 'GET' });
|
|
|
+ var d = await _readJson(res);
|
|
|
+ if (!d) return _fail(response, 502, '登录服务返回异常,请稍后重试');
|
|
|
+ var token = d.data && d.data.token;
|
|
|
+ // 平台约定:code === 200 && data.token
|
|
|
+ if (d.code !== 200 || !token) {
|
|
|
+ return _fail(response, Number(d.code) || 401, d.msg || d.mess || '登录失败');
|
|
|
+ }
|
|
|
+ // 🔴 不打印 token,只回传给客户端
|
|
|
+ return _ok(response, { token: token });
|
|
|
+ } catch (e) {
|
|
|
+ return _fail(
|
|
|
+ response,
|
|
|
+ 502,
|
|
|
+ e && e.message === 'timeout' ? '登录服务超时,请重试' : '网络异常,请重试'
|
|
|
+ );
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+// ==================== 入口 ====================
|
|
|
+//
|
|
|
+// 🔴 平台的执行器**不提供** `Parse.Cloud.define`(实测 2026-11-15:
|
|
|
+// 写成 define 会报 `Parse.Cloud.define is not a function`)。
|
|
|
+// 真实约定是**顶层定义 `handler(request, response)`**,
|
|
|
+// 入参由平台注入——`params` 是全局变量,`response` 必须显式 `.status().json()`。
|
|
|
+// 与 fmode-file-read / fmode-file-hub 同一写法。
|
|
|
+
|
|
|
+function handler(request, response) {
|
|
|
+ (async function () {
|
|
|
+ var p = params || {};
|
|
|
+ var action = _str(p.action, 32);
|
|
|
+ var mobile = _str(p.mobile, 20);
|
|
|
+
|
|
|
+ if (action === 'sendCode') {
|
|
|
+ await _sendCode(response, mobile);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (action === 'login') {
|
|
|
+ await _login(response, mobile, _str(p.code, 10));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ return _fail(response, 400, '未知操作:' + action);
|
|
|
+ })().catch(function (e) {
|
|
|
+ _fail(response, 500, 'server error: ' + (e && e.message ? e.message : 'unknown'));
|
|
|
+ });
|
|
|
+}
|