Files
ark-stronghold-protocol/server/skland/accountHttp.js
T
herryyun 7c25c89992
CI / test (ubuntu-latest, node 22) (push) Failing after 33m10s
CI / test (ubuntu-latest, node 24) (push) Failing after 34m41s
CI / tooling (lint, imports, typecheck) (push) Failing after 36m11s
CI / test (windows-latest, node 22) (push) Canceled after 0s
CI / test (windows-latest, node 24) (push) Canceled after 0s
实现账号绑定、持久登录与跨设备对局恢复
2026-10-14 23:22:50 +08:00

109 lines
6.8 KiB
JavaScript

// i18n-ignore-file: API errors are diagnostics; the account UI supplies translated labels.
import { sendJson } from '../http/common.js';
import { readJsonBody } from './http.js';
import { ACCOUNT_PREFIX } from './auth.js';
import { SESSION_BINDING_PREFIX } from './session.js';
export function createAccountHttp({ service, auth, registry, recovery, diagnostic }) {
const endpoints = { status: 'GET', roles: 'GET', recovery: 'GET', enter: 'POST', resume: 'POST', 'scan/start': 'POST', 'scan/poll': 'POST',
'phone/send': 'POST', 'phone/login': 'POST', 'password/login': 'POST', 'role/select': 'POST', unbind: 'POST' };
return async (req, res, parts) => {
const fail = (status, error, code) => { sendJson(req, res, status, { ok: false, error, ...(code ? { code } : {}) }); return true; };
if (!auth.originAllowed(req)) return fail(403, '跨站请求已拒绝 · Cross-origin request denied');
if (auth.limited(req)) return fail(429, '请求过于频繁 · Too many requests');
if (!parts.rawPath.startsWith('/skland/session/')) {
if (!auth.diagnosticAllowed(req)) return fail(403, '诊断接口需要管理员密钥 · Diagnostic API requires an admin key');
return diagnostic(req, res, parts);
}
const endpoint = parts.rawPath.slice('/skland/session/'.length);
if (!Object.hasOwn(endpoints, endpoint)) return fail(404, '未知接口 · Unknown endpoint');
if (req.method !== endpoints[endpoint]) return fail(405, '不支持的请求方法 · Method not allowed');
const match = /^Bearer ([a-f0-9]{32})$/i.exec(req.headers.authorization || '');
const session = match && registry.byToken(match[1]);
if (!session || registry.isExpired(session, registry.now())) return fail(401, '游戏会话已失效 · Game session expired');
const query = new URLSearchParams(parts.query);
let body = {};
if (req.method === 'POST') {
try { body = await readJsonBody(req); } catch { return fail(400, '请求体无效 · Bad request'); }
if (!body || typeof body !== 'object' || Array.isArray(body)) return fail(400, '请求体无效 · Bad request');
}
// Reading a POST body yields: logout, expiry or a device takeover may invalidate either credential meanwhile.
if (registry.byToken(match[1]) !== session) return fail(401, '游戏会话已失效 · Game session expired');
let login;
try { login = auth.authenticate(req); } catch { auth.setCookie(req, res, null); return fail(401, '登录已过期,请重新绑定 · Login expired'); }
if (session.playerId.startsWith('a_') && (!login || auth.state(login).gamePlayerId !== session.playerId)) {
return fail(403, '身份不匹配 · Identity mismatch');
}
// Claimed identity fields never choose the target row; all ownership comes from authenticated credentials.
const pendingPlayer = SESSION_BINDING_PREFIX + session.playerId;
const issue = () => {
login = auth.publish(pendingPlayer);
if (login) auth.setCookie(req, res, login);
};
if (!login && service.status(pendingPlayer).bound) issue(); // Migrate an existing binding owned by this transport.
const player = login ? ACCOUNT_PREFIX + login.subject : pendingPlayer;
const syncSelection = () => {
if (!login) return;
const updated = auth.ensureSelection(login);
if (updated !== login) { login = updated; auth.setCookie(req, res, login); }
};
try {
let answer;
if (endpoint === 'status') { syncSelection(); answer = login ? auth.state(login) : service.status(player); }
else if (endpoint === 'recovery' || endpoint === 'enter' || endpoint === 'resume') {
if (!login) return fail(401, '请先绑定账号 · Account binding required');
syncSelection();
const playerId = auth.state(login).gamePlayerId;
if (!playerId) return fail(409, '请选择有效角色 · A playable role is required');
const item = recovery.describe(playerId);
if (endpoint === 'recovery') answer = { ok: true, recovery: item };
else {
if (endpoint === 'resume' && (!item || typeof body.id !== 'string' || body.id !== item.id)) {
return fail(409, '此对局已结束或过期,请刷新后重试 · Recovery is no longer available', 'resume_expired');
}
const target = auth.gameSession(login);
answer = { ok: true, gamePlayerId: target.playerId, token: target.token, name: target.name };
}
} else if (endpoint === 'roles') {
if (!login) return fail(401, '请先绑定账号 · Account binding required');
answer = { ok: true, roles: await service.roles(player, query.get('refresh') === '1') };
syncSelection();
} else if (endpoint === 'role/select') {
if (!login) return fail(401, '请先绑定账号 · Account binding required');
const selected = auth.select(login, body.roleKey);
if (!selected) return fail(403, '无法选择该角色 · Role not owned or currently in a room');
login = selected; auth.setCookie(req, res, login); answer = { ok: true, ...auth.state(login) };
} else if (endpoint === 'unbind') {
if (login) {
const role = auth.roleOf(login), current = role && registry.byId(auth.state(login).gamePlayerId);
if (current?.roomCode) return fail(409, '请先离开同盟 · Leave your room first');
auth.revoke(login);
}
service.unbind(pendingPlayer); auth.setCookie(req, res, null); answer = { ok: true };
} else {
if (login) return fail(409, '请先解绑当前账号 · Unbind the current account first');
if (endpoint === 'scan/start') answer = { ok: true, ...await service.startScan(player) };
else if (endpoint === 'scan/poll') {
answer = { ok: true, ...await service.pollScan(player) };
if (answer.state === 'confirmed' && !answer.error) issue();
} else if (endpoint === 'phone/send') {
if (!body.phone) return fail(400, '缺少手机号 · Missing phone');
answer = await service.sendPhoneCode(player, String(body.phone), body.captcha);
} else if (endpoint === 'phone/login') {
if (!body.phone || !body.code) return fail(400, '缺少登录信息 · Missing login fields');
answer = await service.loginByPhoneCode(player, String(body.phone), String(body.code));
if (answer.ok) issue();
} else if (endpoint === 'password/login') {
if (!body.phone || !body.password) return fail(400, '缺少登录信息 · Missing login fields');
answer = await service.loginByPhonePassword(player, String(body.phone), String(body.password), body.captcha, body.captchaStep);
if (answer.ok) issue();
}
}
sendJson(req, res, 200, answer);
} catch (error) {
sendJson(req, res, 200, { ok: false, ...(endpoint === 'scan/poll' ? { state: 'error' } : {}), error: error.message });
}
return true;
};
}