问题:2 小时与 30 天不是两个孤立的数字
《潮汐消消》案例设定中访问令牌有效期 2 小时、刷新令牌 30 天;《潮汐方舟》用的是同一组数字。两个产品共用一个账号中台,所以令牌时长相同,但多端策略不同:消消是「后登录顶掉先登录」,方舟是「同账号最多 2 端并存」。
把这两个数字直接抄进代码,只能得到「2 小时后过期、30 天后重登」。真正决定体验的是中间那条刷新链:什么时候允许换新令牌、旧令牌还能不能用、同一家族里出现复用怎么办、第二台设备登录时该踢谁。这些规则不写清楚,2 小时和 30 天就只是两个会定时制造客诉的闹钟。
登录态的目标不是「让令牌永不过期」,而是「让被盗令牌的可用时间足够短,同时让正常玩家的重登次数足够少」。访问令牌短、刷新令牌长,是为了把高频鉴权与低频续期分开;刷新轮换与复用检测,是为了让偷到旧刷新令牌的人不能无限续期。
登录态的三张表:会话、访问令牌、刷新令牌
| 表 | 键 | 作用 | 失效方式 |
|---|---|---|---|
| 会话 | sessionId | 记录玩家、设备、家族与状态 | 主动吊销、被踢、复用检测 |
| 访问令牌索引 | accessToken → sessionId | 每次请求鉴权 | 2 小时到期或会话吊销 |
| 刷新令牌索引 | refreshToken → sessionId | 换取新一对令牌 | 30 天到期、轮换或会话吊销 |
三张表里,会话是事实,两个令牌索引只是查找入口。很多实现只存令牌不存会话,结果无法回答「这个玩家现在有几台设备在线」「踢掉的是哪一台」「同一个家族里哪些令牌已经轮换过」。会话记录必须保留 playerId、deviceId、familyId、签发时间、两种到期时间和状态。
familyId 是刷新轮换的关键。一次登录产生一个家族,后续每次刷新都在同一个家族里生成新会话,旧会话标记为 rotated。这样当旧刷新令牌再次出现时,系统能沿着家族找到当前活跃会话并整体吊销,而不是只拒绝这一次请求。
刷新不是续期,而是轮换
「刷新」最容易被实现成「把同一个刷新令牌的过期时间往后推」。这种做法一旦刷新令牌泄露,攻击者可以无限续期,防守方没有任何窗口。正确做法是每次刷新都签发新的访问令牌和新的刷新令牌,旧的立即标记为 rotated。
轮换之后必须做复用检测:如果某个已经 rotated 的刷新令牌再次被使用,说明至少有两方持有它——正常客户端已经换过一轮,攻击者拿旧令牌来换。此时不能只返回错误,而要把整个家族当前活跃的会话全部吊销,让正常玩家重新登录,把攻击者一起挤出去。
这条规则有代价:网络重试、客户端并发刷新、用户快速切换网络,都可能让旧令牌被第二次提交,从而误伤正常会话。控制方法通常有三种:客户端保证同一时刻只有一个刷新请求;服务端对同一令牌的短时间重复请求做幂等返回;或者把复用检测限定在同一家族仍有活跃会话时。无论选哪种,都必须明确写进协议,不能靠「应该不会发生」。
多端互踢:单端与双端两种策略
| 策略 | 适用案例 | 新设备登录时 | 被踢方的表现 |
|---|---|---|---|
single | 《潮汐消消》 | 吊销该玩家其他所有活跃会话 | 下一次请求返回会话已吊销,回登录页 |
max_two | 《潮汐方舟》 | 保留最近 2 个活跃会话,吊销最旧的 | 最旧设备回登录页,另外两台不受影响 |
策略没有绝对优劣,取决于单局长度与多端需求。消消单局短、弱联网,玩家换机或换端后继续玩的成本低,单端策略能把账号共享和代练的空间压到最小;方舟单局长、强联网,玩家可能在手机与平板之间切换,最多 2 端并存能减少「一登录就把另一台踢掉」的挫败感。
案例设定中消消多端登录用户占 18%,方舟占 34%。方舟的多端比例更高,正好对应它更宽松的并存策略;但「最多 2 端」不是无限并存,第 3 台设备登录时仍然要按签发时间踢掉最旧的一台,否则会话表会无限增长。
同设备重登不是互踢
同一台设备再次登录时,旧会话应该被替换,而不是被当成「另一台设备」参与踢人计数。否则单端策略下,玩家在同一台设备重登会先踢掉自己,再签发新会话,日志里出现一次没有意义的互踢记录;双端策略下,重登还可能把另一台正常设备挤掉。
替换的语义是:同一 playerId、同一 deviceId 的活跃会话标记为 revoked,原因写 replaced;新会话正常签发,不计入 kicked 列表。这样客服看到「设备 A 被设备 B 顶掉」时,不会把同设备重登误判成异地登录。
设备标识本身也要稳定。客户端可以用安装实例 id,但不能用可能变化的广告 id 或系统账号 id;否则同一台设备每次启动都像新设备,互踢策略会频繁误触发。
边界时间:过期用 >=,时钟以服务端为准
过期判断必须有唯一口径:now >= expiresAt 即过期,签发时 expiresAt = now + ttl。这样 2 小时的令牌在恰好 2 小时那一刻失效,不会出现「客户端算出还有 1 毫秒、服务端已经拒绝」的边界分歧。
所有时间都应以服务端时钟为准,客户端只负责在本地做提前刷新,不能把客户端时间当作鉴权依据。客户端时间可以被修改,时区也可能变化;服务端只接受自己的 nowMs,并且要拒绝负数、非有限值这类非法输入。
提前刷新是体验优化,不是安全机制。客户端可以在访问令牌剩余一小段时间时主动刷新,减少请求失败;但服务端仍然要在每次请求上做完整校验,不能因为「客户端说还没过期」就跳过。
可运行模型:login-session.ts
模型用一份不可变的会话账本记录所有状态,登录、鉴权、刷新都返回新的账本,不修改入参。这样测试可以精确比较每一步的会话数量、状态与踢出列表。
login按策略签发会话,处理同设备替换与跨设备踢人,返回被踢列表。authenticate按访问令牌找到会话,区分无效、已吊销与已过期。refresh执行轮换;旧刷新令牌再次出现时吊销整个家族并返回REFRESH_REUSE。activeSessionsFor按签发时间排序,保证「踢最旧」有确定结果。formatLoginResult输出当前会话与被踢设备,便于日志与测试断言。
// file: src/biz/login-session.ts
// tier: L1
// verify: node --test test/biz/login-session.test.ts
export type DevicePolicy = "single" | "max_two";
export type SessionStatus = "active" | "revoked" | "rotated";
export const ACCESS_TTL_MS = 2 * 60 * 60 * 1000;
export const REFRESH_TTL_MS = 30 * 24 * 60 * 60 * 1000;
export interface SessionRecord {
readonly sessionId: string;
readonly familyId: string;
readonly playerId: string;
readonly deviceId: string;
readonly accessToken: string;
readonly refreshToken: string;
readonly issuedAt: number;
readonly accessExpiresAt: number;
readonly refreshExpiresAt: number;
readonly status: SessionStatus;
readonly revokedReason?: string;
}
export interface SessionLedger {
readonly sequence: number;
readonly sessions: Readonly<Record<string, SessionRecord>>;
readonly byAccess: Readonly<Record<string, string>>;
readonly byRefresh: Readonly<Record<string, string>>;
}
export interface LoginRequest {
readonly playerId: string;
readonly deviceId: string;
readonly nowMs: number;
}
export interface LoginResult {
readonly ledger: SessionLedger;
readonly session: SessionRecord;
readonly kicked: readonly SessionRecord[];
}
export type AuthFailure = "INVALID_ACCESS" | "ACCESS_EXPIRED" | "SESSION_REVOKED";
export type AuthResult =
| { readonly ok: true; readonly session: SessionRecord }
| { readonly ok: false; readonly code: AuthFailure };
export type RefreshFailure =
| "INVALID_REFRESH"
| "REFRESH_EXPIRED"
| "SESSION_REVOKED"
| "REFRESH_REUSE";
export type RefreshResult =
| { readonly ok: true; readonly ledger: SessionLedger; readonly session: SessionRecord }
| { readonly ok: false; readonly code: RefreshFailure; readonly ledger: SessionLedger };
export class SessionError extends Error {
readonly code: string;
constructor(code: string, message: string) {
super(message);
this.name = "SessionError";
this.code = code;
}
}
function fail(code: string, message: string): never {
throw new SessionError(code, message);
}
function requireText(value: string, field: string): string {
const trimmed = value.trim();
if (trimmed.length === 0) {
fail("INVALID_REQUEST", `${field} must not be empty`);
}
return trimmed;
}
function requireTime(nowMs: number): number {
if (!Number.isFinite(nowMs) || nowMs < 0) {
fail("INVALID_TIME", "nowMs must be a non-negative finite number");
}
return nowMs;
}
function setSession(ledger: SessionLedger, record: SessionRecord): SessionLedger {
return { ...ledger, sessions: { ...ledger.sessions, [record.sessionId]: record } };
}
function appendSession(ledger: SessionLedger, record: SessionRecord): SessionLedger {
return {
sequence: ledger.sequence + 1,
sessions: { ...ledger.sessions, [record.sessionId]: record },
byAccess: { ...ledger.byAccess, [record.accessToken]: record.sessionId },
byRefresh: { ...ledger.byRefresh, [record.refreshToken]: record.sessionId },
};
}
function revoke(record: SessionRecord, reason: string): SessionRecord {
return { ...record, status: "revoked", revokedReason: reason };
}
export function emptyLedger(): SessionLedger {
return { sequence: 0, sessions: {}, byAccess: {}, byRefresh: {} };
}
export function activeSessionsFor(ledger: SessionLedger, playerId: string): SessionRecord[] {
return Object.values(ledger.sessions)
.filter((session) => session.playerId === playerId && session.status === "active")
.sort((a, b) => a.issuedAt - b.issuedAt || a.sessionId.localeCompare(b.sessionId));
}
export function login(
ledger: SessionLedger,
request: LoginRequest,
policy: DevicePolicy,
): LoginResult {
const playerId = requireText(request.playerId, "playerId");
const deviceId = requireText(request.deviceId, "deviceId");
const nowMs = requireTime(request.nowMs);
const active = activeSessionsFor(ledger, playerId);
const sameDevice = active.filter((session) => session.deviceId === deviceId);
const others = active.filter((session) => session.deviceId !== deviceId);
let next: SessionLedger = {
...ledger,
sessions: { ...ledger.sessions },
byAccess: { ...ledger.byAccess },
byRefresh: { ...ledger.byRefresh },
};
const kicked: SessionRecord[] = [];
for (const session of sameDevice) {
next = setSession(next, revoke(session, "replaced"));
}
const keepForOtherDevices = policy === "single" ? 0 : 1;
const excess = Math.max(0, others.length - keepForOtherDevices);
for (const session of others.slice(0, excess)) {
const revoked = revoke(session, "device_policy");
next = setSession(next, revoked);
kicked.push(revoked);
}
const sequence = ledger.sequence + 1;
const session: SessionRecord = {
sessionId: `s-${sequence}`,
familyId: `f-${sequence}`,
playerId,
deviceId,
accessToken: `at-${sequence}`,
refreshToken: `rt-${sequence}`,
issuedAt: nowMs,
accessExpiresAt: nowMs + ACCESS_TTL_MS,
refreshExpiresAt: nowMs + REFRESH_TTL_MS,
status: "active",
};
next = appendSession(next, session);
return { ledger: next, session, kicked };
}
export function authenticate(
ledger: SessionLedger,
accessToken: string,
nowMs: number,
): AuthResult {
const time = requireTime(nowMs);
const sessionId = ledger.byAccess[accessToken];
if (sessionId === undefined) {
return { ok: false, code: "INVALID_ACCESS" };
}
const session = ledger.sessions[sessionId];
if (session === undefined) {
return { ok: false, code: "INVALID_ACCESS" };
}
if (session.status !== "active") {
return { ok: false, code: "SESSION_REVOKED" };
}
if (time >= session.accessExpiresAt) {
return { ok: false, code: "ACCESS_EXPIRED" };
}
return { ok: true, session };
}
export function refresh(
ledger: SessionLedger,
refreshToken: string,
nowMs: number,
): RefreshResult {
const time = requireTime(nowMs);
let next: SessionLedger = {
...ledger,
sessions: { ...ledger.sessions },
byAccess: { ...ledger.byAccess },
byRefresh: { ...ledger.byRefresh },
};
const sessionId = ledger.byRefresh[refreshToken];
if (sessionId === undefined) {
return { ok: false, code: "INVALID_REFRESH", ledger: next };
}
const current = ledger.sessions[sessionId];
if (current === undefined) {
return { ok: false, code: "INVALID_REFRESH", ledger: next };
}
if (current.status === "rotated") {
for (const record of Object.values(next.sessions)) {
if (record.familyId === current.familyId && record.status === "active") {
next = setSession(next, revoke(record, "refresh_reuse"));
}
}
return { ok: false, code: "REFRESH_REUSE", ledger: next };
}
if (current.status === "revoked") {
return { ok: false, code: "SESSION_REVOKED", ledger: next };
}
if (time >= current.refreshExpiresAt) {
return { ok: false, code: "REFRESH_EXPIRED", ledger: next };
}
next = setSession(next, { ...current, status: "rotated", revokedReason: "rotated" });
const sequence = next.sequence + 1;
const rotated: SessionRecord = {
sessionId: `s-${sequence}`,
familyId: current.familyId,
playerId: current.playerId,
deviceId: current.deviceId,
accessToken: `at-${sequence}`,
refreshToken: `rt-${sequence}`,
issuedAt: time,
accessExpiresAt: time + ACCESS_TTL_MS,
refreshExpiresAt: time + REFRESH_TTL_MS,
status: "active",
};
next = appendSession(next, rotated);
return { ok: true, ledger: next, session: rotated };
}
export function formatSession(session: SessionRecord): string {
return `${session.sessionId} player=${session.playerId} device=${session.deviceId} status=${session.status} access=${session.accessToken} refresh=${session.refreshToken}`;
}
export function formatLoginResult(result: LoginResult): string {
const devices = result.kicked.map((session) => session.deviceId).join(",") || "none";
return `${formatSession(result.session)} kicked=${devices}`;
}
模型的输出是状态账本,不是真实令牌。生产实现还需要签名、密钥轮换、存储与并发控制;但状态机部分应该与本模型一致:会话有家族、令牌有索引、轮换有旧状态、复用会吊销家族。
双案例:消消的后登录顶掉先登录
《潮汐消消》案例设定中多端登录用户占 18%,采用 single 策略:新设备登录即吊销其他活跃会话。它是弱联网休闲产品,单局短,玩家被踢后重登成本低,单端策略更容易控制账号共享。
《潮汐方舟》多端登录占 34%,采用 max_two:同账号最多 2 端并存,第 3 端登录踢最旧。它是强联网中重度产品,单局长,玩家在手机与平板间切换更常见,宽松策略减少中途被顶下线的损失。
代价与取舍:令牌家族的账
轮换与家族吊销换来的是「旧刷新令牌不能无限续期」,代价是会话记录只增不减:每次刷新都留下一条 rotated 记录,30 天内一个活跃玩家可能积累上百条。会话表需要按家族归档,或者把已轮换记录压缩成只保留家族索引,否则存储与查询都会变慢。
复用检测也有误伤成本。正常客户端在弱网下重试刷新,可能把已经轮换的旧令牌再提交一次,触发整家族吊销。缓解办法是客户端串行化刷新、服务端对短时间重复请求做幂等,或者在吊销前要求第二次证据;但任何缓解都会削弱检测强度,必须按产品风险承受度选择。
单端策略牺牲的是多端便利,双端策略牺牲的是账号共享控制。消消选择单端,是因为休闲产品的账号共享收益低、风控收益高;方舟选择双端,是因为长单局玩家对「被顶下线」的容忍度更低。这个取舍不能反过来抄。
观测与回归
| 指标 | 含义 | 异常信号 |
|---|---|---|
| 活跃会话数/玩家 | 当前有效会话分布 | 单端策略下长期大于 1,说明踢人未生效 |
| 刷新成功率 | 刷新请求中成功轮换的比例 | 下降可能意味着客户端并发刷新或时钟偏差 |
| 复用检测次数 | 触发 REFRESH_REUSE 的次数 | 突增可能是攻击,也可能是弱网重试误伤 |
| 被踢原因分布 | device_policy 与 replaced 的占比 | 同设备重登占比过高,说明设备 id 不稳定 |
| 访问令牌过期率 | 请求因 ACCESS_EXPIRED 失败的比例 | 过高说明客户端提前刷新策略缺失 |
回归至少覆盖:单端踢人、双端保留两台、同设备替换、刷新轮换、旧刷新复用、刷新到期边界、访问令牌到期边界、不同玩家互不影响。模型测试只能验证状态机,真实并发与存储一致性仍要在服务端集成测试里覆盖。
小结
- 访问令牌 2 小时、刷新令牌 30 天是案例设定值;真正决定行为的是刷新轮换、复用检测与互踢策略。
- 会话记录要保存玩家、设备、家族、签发时间、两种到期时间与状态,令牌索引只是查找入口。
- 刷新必须轮换,旧刷新令牌再次出现时吊销整个家族;误伤风险要靠客户端串行化与幂等控制。
- 消消采用单端「后登录顶掉先登录」,方舟采用双端「最多 2 端并存」,策略由单局长度与多端比例决定。
- 同设备重登是替换不是互踢;过期判断用
now >= expiresAt,时间以服务端为准。
阅读导航:上一节:2.1 游客、账号与三端互通的标识设计 · 下一节:2.3 账号迁移、合并与风控 。
常见错误与边界
- 把刷新实现成延长同一个令牌。刷新令牌一旦泄露就能无限续期,短访问令牌的安全收益被完全抵消。
- 轮换后不保留旧令牌状态。旧刷新令牌再次出现时无法识别复用,攻击者可以在受害者刷新后继续使用旧令牌。
- 复用检测只拒绝当前请求。不吊销家族等于告诉攻击者「这次不行,换一个再来」,正常会话仍然暴露。
- 同设备重登也计入互踢。玩家会在同一台设备上把自己踢掉,日志里出现大量无意义的异地登录告警。
- 设备 id 用广告 id 或系统账号 id。这些值可能变化或重复,同一台设备会被识别成多台,互踢策略频繁误触发。
- 客户端时间参与鉴权。客户端时间可被修改,服务端必须用自己的时钟判断过期,客户端只做提前刷新。
- 过期判断用
>而不是>=。恰好到期的那一刻会出现服务端与客户端口径不一致,边界测试也会不稳定。 - 单端策略下不清理旧会话。会话表持续增长,查询变慢,客服也无法判断玩家当前到底哪台设备在线。
- 双端策略下踢最新而不是最旧。玩家每次登录都可能把刚登录的设备踢掉,体验比单端更差。
- 边界:本节不处理账号合并。两个已有账号的绑定、资产与风控属于 2.3;把合并塞进登录流程会让会话状态机承担资产事务。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。