Files
ark-stronghold-protocol/server/skland/auth.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

188 lines
11 KiB
JavaScript

// Persistent account authentication. Provider credentials stay in SQLite; the browser receives only a signed JWT.
import { DatabaseSync } from 'node:sqlite';
import { createHmac, createHash, randomBytes, randomUUID, timingSafeEqual } from 'node:crypto';
import { TokenBucket, isLocalIp, sanitizeName } from '../net.js';
import { roleKey, hasRoleIdentity } from '../../shared/accountIdentity.js';
export const ACCOUNT_PREFIX = '@account/';
export const ACCOUNT_COOKIE = 'sp_account';
export const LOGIN_TTL = 30 * 24 * 60 * 60;
const ISSUER = 'stronghold-account', AUDIENCE = 'stronghold-game';
const json64 = (value) => Buffer.from(JSON.stringify(value)).toString('base64url');
const equal = (a, b) => a.length === b.length && timingSafeEqual(a, b);
// 128 digest bits in base64url keep `n:<playerId>` within g.watch's existing 32-character field limit.
export const accountPlayerId = (role) => 'a_' + createHash('sha256').update(roleKey(role)).digest().subarray(0, 16).toString('base64url');
export function createAccountAuth({ file, service, registry, secret, publicOrigin, diagnosticKey, trustProxy = 'auto', now = Date.now, onExpire }) {
const db = new DatabaseSync(file);
db.exec(`PRAGMA journal_mode=WAL;
CREATE TABLE IF NOT EXISTS account_meta (key TEXT PRIMARY KEY, value TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS account_logins (
id TEXT PRIMARY KEY, subject TEXT NOT NULL, role_key TEXT, expires INTEGER NOT NULL, revoked INTEGER NOT NULL DEFAULT 0
);`);
if (secret && Buffer.byteLength(secret) < 32) throw new Error('SP_AUTH_SECRET must contain at least 32 bytes');
db.prepare('INSERT OR IGNORE INTO account_meta(key,value) VALUES (?,?)').run('signing_key', randomBytes(48).toString('base64url'));
const key = Buffer.from(secret || db.prepare('SELECT value FROM account_meta WHERE key=?').get('signing_key').value);
const configuredOrigin = publicOrigin ? new URL(publicOrigin).origin : null;
const signature = (value) => createHmac('sha256', key).update(value).digest();
const playable = (role) => hasRoleIdentity(role) && !!sanitizeName(role.nickName);
const roleOf = (login) => service.status(ACCOUNT_PREFIX + login.subject).roles.find((role) => playable(role) && roleKey(role) === login.role_key) || null;
const active = (login) => login && !login.revoked && login.expires > Math.floor(now() / 1000)
&& service.status(ACCOUNT_PREFIX + login.subject).bound;
const buckets = new Map();
function limited(req) {
const id = req.socket?.remoteAddress || '?';
if (!buckets.has(id)) {
if (buckets.size >= 4096) buckets.delete(buckets.keys().next().value);
buckets.set(id, new TokenBucket(3, 120, now()));
}
return !buckets.get(id).take(now());
}
function originAllowed(req) {
if (req.headers['sec-fetch-site'] === 'cross-site') return false;
if (!req.headers.origin) return true; // Non-browser clients still need both credentials.
const secure = req.socket?.encrypted || (trustProxy !== false && (trustProxy === true || isLocalIp(req.socket?.remoteAddress))
&& req.headers['x-forwarded-proto'] === 'https');
return req.headers.origin === (configuredOrigin || `${secure ? 'https' : 'http'}://${req.headers.host}`);
}
function cookieValue(req) {
const entries = (req.headers.cookie || '').split(';').map((part) => part.trim()).filter((part) => part.startsWith(ACCOUNT_COOKIE + '='));
if (entries.length > 1) throw new Error('invalid account cookie');
return entries.length ? entries[0].slice(ACCOUNT_COOKIE.length + 1) : null;
}
function verify(token) {
try {
if (typeof token !== 'string' || token.length > 4096) return null;
const parts = token.split('.');
if (parts.length !== 3 || parts.some((part) => !/^[A-Za-z0-9_-]+$/.test(part))) return null;
const header = JSON.parse(Buffer.from(parts[0], 'base64url').toString());
if (header.alg !== 'HS256' || header.typ !== 'JWT' || Object.keys(header).length !== 2) return null;
if (!equal(signature(parts[0] + '.' + parts[1]), Buffer.from(parts[2], 'base64url'))) return null;
const claims = JSON.parse(Buffer.from(parts[1], 'base64url').toString());
const at = Math.floor(now() / 1000);
if (claims.iss !== ISSUER || claims.aud !== AUDIENCE || !Number.isInteger(claims.iat) || claims.iat > at
|| !Number.isInteger(claims.exp) || claims.exp <= at || claims.exp <= claims.iat || claims.exp - claims.iat > LOGIN_TTL
|| typeof claims.sub !== 'string' || typeof claims.jti !== 'string') return null;
const login = db.prepare('SELECT * FROM account_logins WHERE id=?').get(claims.jti);
return active(login) && login.subject === claims.sub && login.expires === claims.exp && login.role_key === claims.role ? login : null;
} catch { return null; }
}
function authenticate(req) {
const token = cookieValue(req);
if (!token) return null;
const login = verify(token);
if (!login) throw new Error('invalid account cookie');
return login;
}
function setCookie(req, res, login) {
const secure = req.socket?.encrypted || configuredOrigin?.startsWith('https:') || req.headers.origin?.startsWith('https:')
|| (trustProxy !== false && (trustProxy === true || isLocalIp(req.socket?.remoteAddress)) && req.headers['x-forwarded-proto'] === 'https');
let value = '', maxAge = 0;
if (login) {
const at = Math.floor(now() / 1000);
const head = json64({ alg: 'HS256', typ: 'JWT' });
const body = json64({ iss: ISSUER, aud: AUDIENCE, sub: login.subject, jti: login.id,
iat: at, exp: login.expires, role: login.role_key });
value = head + '.' + body + '.' + signature(head + '.' + body).toString('base64url');
maxAge = Math.max(0, login.expires - at);
}
res.setHeader('Set-Cookie', `${ACCOUNT_COOKIE}=${value}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${maxAge}${secure ? '; Secure' : ''}`);
}
function publish(player) {
const row = service._store.get(player);
if (!row || row.status !== 'bound') return null;
const roles = service.status(player).roles.filter(playable);
const selected = roles.find((role) => role.isDefault) || roles[0];
const login = { id: randomUUID(), subject: randomUUID(), role_key: selected ? roleKey(selected) : null,
expires: Math.floor(now() / 1000) + LOGIN_TTL, revoked: 0 };
const { player_id: _player, created_at: _created, updated_at: _updated, ...credentials } = row;
service._store.update(ACCOUNT_PREFIX + login.subject, credentials);
db.prepare('INSERT INTO account_logins(id,subject,role_key,expires) VALUES (?,?,?,?)')
.run(login.id, login.subject, login.role_key, login.expires);
service.unbind(player); // A replay of the old transport token cannot issue a fresh login after logout.
return login;
}
function select(login, selectedKey) {
const role = service.status(ACCOUNT_PREFIX + login.subject).roles.find((role) => playable(role) && roleKey(role) === selectedKey);
if (!role) return null;
const old = roleOf(login), session = old && registry.byId(accountPlayerId(old));
if (session?.roomCode && selectedKey !== login.role_key) return null;
db.prepare('UPDATE account_logins SET role_key=? WHERE id=?').run(selectedKey, login.id);
return { ...login, role_key: selectedKey };
}
function ensureSelection(login) {
if (roleOf(login)) return login;
const roles = service.status(ACCOUNT_PREFIX + login.subject).roles.filter(playable);
const role = roles.find((item) => item.isDefault) || roles[0];
const selectedKey = role ? roleKey(role) : null;
if (selectedKey === login.role_key) return login;
db.prepare('UPDATE account_logins SET role_key=? WHERE id=?').run(selectedKey, login.id);
return { ...login, role_key: selectedKey };
}
function state(login) {
const status = service.status(ACCOUNT_PREFIX + login.subject), role = roleOf(login);
return { ...status, loginId: login.id, selectedRoleKey: login.role_key,
gamePlayerId: role ? accountPlayerId(role) : null };
}
function roleSession(role) {
const id = accountPlayerId(role);
let session = registry.byId(id);
if (session && registry.isExpired(session, registry.now())) {
registry.remove(session);
onExpire?.(session); // Remove the old seat before reusing its stable account playerId.
session = null;
}
if (!session) {
session = registry.create(sanitizeName(role.nickName));
if (!session) throw new Error('server full');
registry.byPlayerId.delete(session.playerId); session.playerId = id; registry.byPlayerId.set(id, session);
}
return session;
}
function revoke(login) {
db.prepare('UPDATE account_logins SET revoked=1 WHERE subject=?').run(login.subject);
service.unbind(ACCOUNT_PREFIX + login.subject);
}
const gamePolicy = {
connection(req) {
let login = null;
try { login = authenticate(req); } catch { /* Expired cookies can still establish a login-only transport. */ }
return { login, allowed: originAllowed(req) };
},
hello(conn, message) {
if (!conn.account.allowed) return null;
const login = conn.account.login;
if (login) {
const current = db.prepare('SELECT * FROM account_logins WHERE id=?').get(login.id);
const role = active(current) && current.role_key === login.role_key && roleOf(current);
const target = conn.session || (message.token && registry.byToken(message.token));
// Merely restoring the cookie or viewing the title must not take over another device. A current role token
// resumes normally; the authenticated enter/resume POST supplies it after an explicit user action.
if (role && target?.playerId === accountPlayerId(role)) return { ...message, name: sanitizeName(role.nickName), token: target.token };
}
// Anonymous hellos may establish transport for login, but never resume an authenticated game identity.
if (conn.session?.playerId.startsWith('a_')) return null;
const target = message.token && registry.byToken(message.token);
return { ...message, token: target?.playerId.startsWith('a_') ? undefined : message.token };
},
attached(conn, { repeat }) {
if (!repeat && conn.session?.playerId.startsWith('a_')) registry.rotateToken(conn.session);
},
authorized(conn) {
const captured = conn.account?.login;
if (!captured) return false;
const current = db.prepare('SELECT * FROM account_logins WHERE id=?').get(captured.id);
const role = active(current) && current.role_key === captured.role_key && roleOf(current);
return !!role && conn.session?.playerId === accountPlayerId(role);
},
};
return { authenticate, setCookie, publish, select, ensureSelection, state, roleOf, revoke, gamePolicy, originAllowed, limited,
gameSession(login) { const role = roleOf(login); return role ? roleSession(role) : null; },
diagnosticAllowed(req) {
const supplied = req.headers['x-sp-diagnostic-key'];
return !!diagnosticKey && typeof supplied === 'string' && equal(Buffer.from(supplied), Buffer.from(diagnosticKey));
},
close() { db.close(); },
};
}