ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

多账号统一登录与单点登录架构设计及落地实践

多账号统一登录与单点登录架构设计及落地实践

做过多系统账号打通的人大概都有体会:最难的从来不是写一个登录接口,而是让十几个业务系统、五六个端、几套历史遗留账号,在同一套登录态下相安无事。多账号统一登录这个题目,表面看是“单点登录”四个字,实际落地时会牵扯账号模型、令牌设计、会话管理、单点登出、多端互踢、账号合并、老系统改造一连串问题。我前后参与过几次类似改造,有一次是把一个内容平台旗下七个子系统从各自为战的登录页收敛到一个身份中心,过程中踩过的坑比文档里写的多得多。这篇就把这套东西从头到尾拆一遍:它解决什么问题、架构怎么搭、令牌怎么设计、代码怎么写、线上最容易在哪里翻车。

1. 先把“多账号统一登录”这件事说透

1.1 它到底解决什么问题

很多团队一开始并没有“统一登录”的概念,业务跑得快,每个系统各自建用户表、各自发会话,前端各自存 Cookie 或 localStorage。等到系统多了,问题会集中爆发:用户在 A 系统注册过,进 B 系统还要再注册一次;运营在后台改了个昵称,前台三个端显示三个名字;客服接到投诉说“我明明退出了,换个页面又自动登录了”;安全团队要求全站强制二次验证,结果发现要改十几个系统。这些问题的根子不是登录页长得不一样,而是身份数据和服务会话分散在不同系统里。

多账号统一登录要做的,是把“你是谁”和“你能访问什么”这两件事从业务系统里抽出来,交给一个独立的身份层。业务系统不再关心密码怎么存、验证码怎么发、第三方渠道怎么回调,它只认一个可信的令牌。这个令牌由统一身份中心签发,所有系统共享同一套校验规则。用户在一个地方登录,其他系统自动识别;在一个地方登出,全局会话一起失效。它适合正在做多端扩张的团队、有多个子系统的中台型产品,也适合那些历史系统太多、账号体系已经割裂到无法维护的项目。

需要强调的是,统一登录不等于“所有系统共用一个用户表”这么简单。账号是数据,登录态是运行时状态,两者要分开设计。账号层面要解决唯一标识、绑定关系、合并规则;登录态层面要解决令牌签发、校验、续期、吊销。很多项目失败就失败在一开始把这两层混在一起,后面想拆都拆不开。

1.2 三种常见落地形态与选型

市面上常见的落地形态大致三类,选型时不要只看技术流行度,要看团队规模和历史包袱。

形态核心做法适合场景主要代价
协议型单点登录采用 OAuth 2.0 / OIDC 或 SAML,身份中心作为 IdP,业务系统作为 SP系统数量多、有外部合作方接入、需要标准化协议细节多,老系统改造成本高
轻量令牌型自研统一登录服务,签发 JWT 或不透明令牌,各系统引入 SDK 校验内部系统为主、团队可控、追求落地速度需要自己处理登出、续期、密钥轮换
网关收敛型所有流量经过统一网关,网关完成身份校验后透传用户信息已有统一入口、服务治理较成熟网关成为单点,非 HTTP 场景覆盖不到

我的经验是:内部系统为主、没有外部身份联邦需求时,轻量令牌型加统一网关是最容易落地的组合;一旦要跟外部合作方对接,或者要接企业级身份源,尽早切到 OIDC 标准协议,后面会省很多事。SAML 更适合传统企业软件生态,互联网产品用得更少。协议选型没有绝对优劣,关键是别在项目中期换协议,那基本等于重做。

1.3 整体架构与核心链路

一套完整的统一登录,至少包含四个角色:身份中心、业务系统、客户端、缓存与存储。身份中心负责账号、凭证、令牌、会话;业务系统负责资源鉴权;客户端负责持有令牌并在请求时携带;缓存负责会话的快速读写和吊销。

核心链路有六条:登录、令牌校验、令牌刷新、单点登出、多端管理、账号绑定。登录时客户端把凭证交给身份中心,身份中心校验通过后创建会话,签发访问令牌和刷新令牌。业务系统收到请求后先校验访问令牌的签名和有效期,再查会话是否仍然有效。访问令牌快过期时,客户端用刷新令牌换新令牌。用户主动登出时,身份中心吊销会话,所有系统在下一次校验时失效。多端管理负责列出当前账号在哪些设备上登录、支持踢下线。账号绑定负责把手机号、邮箱、第三方渠道身份挂到同一个账号上。

这里有个容易被忽略的点:访问令牌校验不能只验签名。JWT 签名只能证明令牌是身份中心签发的,不能证明它没有被登出。如果业务系统只做本地验签,用户登出后旧令牌在有效期内依然能用,这就是很多“退出登录没生效”投诉的来源。正确做法是验签之后再查一次会话状态,或者维护一个短期的吊销列表。查询会带来额外开销,所以访问令牌有效期要短,通常十几分钟,用刷新令牌续期,把吊销的实时性压力控制在可接受范围内。

2. 账号体系设计:统一身份中心怎么建

2.1 账号模型:从“一个账号”到“一个身份”

统一登录最核心的抽象不是“用户表”,而是“身份”和“账号”的分层。账号是业务主体,身份是登录凭证。一个账号可以绑定多个身份:手机号、邮箱、第三方渠道标识、企业工号。用户可以用任意一个已验证的身份登录,登录后拿到的是同一个账号。这层抽象决定了后面账号合并、换绑、解绑能不能做得干净。

我见过不少项目直接把手机号当主键,结果用户换手机号时只能新建账号,历史订单全丢。正确做法是账号表用内部生成的不可变 ID 做主键,身份表用“类型 + 标识”做唯一约束,账号和身份是一对多关系。手机号、邮箱只是身份的一种,换绑就是改身份表的关联,不动账号主键。第三方渠道同理,渠道返回的用户标识作为身份记录,不要拿来当账号 ID。

还有一个细节:身份要区分“已验证”和“未验证”。用户填了个邮箱但没点验证链接,这条身份不能用于登录,也不能参与账号合并。验证状态要落库,不能只靠前端标记。很多账号被抢注、被恶意合并的问题,都是因为未验证身份被当成了可信凭证。

2.2 账号绑定与合并的规则设计

账号合并是统一登录里最容易出事的部分。典型场景:用户先用手机号注册了账号 A,后来用第三方渠道登录,系统自动创建了账号 B,用户发现两边数据不互通,要求合并。这时候不能简单地把 B 的数据挂到 A 上,要考虑唯一约束冲突、业务数据归属、历史登录态处理。

我的建议是把“绑定”和“合并”分开。绑定是增加一条身份记录,指向现有账号,风险低;合并是把两个账号的业务数据归并,风险高,必须走人工确认或二次验证。绑定流程可以自动化:用户登录账号 A 后,发起绑定第三方渠道,回调验证通过后写入身份记录,如果该身份已属于账号 B,则提示“该渠道已绑定其他账号,是否验证后迁移”。迁移动作本质上是一次合并,需要验证账号 B 的凭证,防止有人拿别人的渠道身份做文章。

合并规则要提前定死,并且写进产品文档。比如昵称冲突时保留主账号的,头像保留最近修改的,订单和内容类数据全部挂到主账号,从账号标记为已合并并禁止登录,历史会话立即吊销。合并过程要记录操作日志,保留从账号到主账号的映射关系,方便后续排查。不要物理删除从账号,否则用户反悔或者数据出错时没有回退余地。

注意:账号合并绝不能只在前端做提示,后端必须校验双方凭证。否则攻击者只要知道对方绑定的渠道,就能发起合并把对方账号吞掉。

2.3 数据表与关键字段

下面是一套最小可用的表结构,用通用 SQL 表达,字段名可以根据团队规范调整。

CREATE TABLE account ( id BIGINT PRIMARY KEY, nickname VARCHAR(64), avatar_url VARCHAR(512), status TINYINT NOT NULL DEFAULT 1, -- 1正常 2冻结 3已合并 merged_to BIGINT NULL, created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL ); CREATE TABLE identity ( id BIGINT PRIMARY KEY, account_id BIGINT NOT NULL, id_type VARCHAR(32) NOT NULL, -- mobile/email/oauth_channel/employee_no identifier VARCHAR(191) NOT NULL, verified TINYINT NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, UNIQUE KEY uk_type_identifier (id_type, identifier), KEY idx_account (account_id) ); CREATE TABLE session ( sid VARCHAR(64) PRIMARY KEY, account_id BIGINT NOT NULL, device_id VARCHAR(128) NOT NULL, device_name VARCHAR(128), refresh_hash VARCHAR(128) NOT NULL, client_ip VARCHAR(64), status TINYINT NOT NULL DEFAULT 1, -- 1有效 2已登出 3已踢出 expires_at DATETIME NOT NULL, created_at DATETIME NOT NULL, last_active DATETIME NOT NULL, KEY idx_account_status (account_id, status) );

identity表的唯一索引是整个账号体系的基石,它保证一个手机号或一个渠道标识只能挂在一个账号上。session表用sid作为会话主键,访问令牌里携带sid,业务系统校验时拿sid查会话状态。refresh_hash存刷新令牌的哈希而不是明文,防止拖库后直接拿着刷新令牌登录。

设备标识device_id要由客户端生成并持久化,不能每次请求都变,否则多端管理会列出一堆重复设备。Web 端可以存在 localStorage,移动端存在系统偏好设置里。设备名可以取浏览器 UA 或机型,展示给用户看,方便识别“这是不是我自己的设备”。

3. 登录态设计:Token、会话与单点登出

3.1 Token方案选型:JWT还是不透明Token

访问令牌有两种主流做法:JWT 和不透明令牌。JWT 自带声明,业务系统本地验签即可拿到用户 ID 和过期时间,不需要每次都访问身份中心;不透明令牌就是一串随机字符串,每次校验都要回查。两者没有绝对好坏,取决于业务系统数量和性能要求。

对比项JWT不透明令牌
校验方式本地验签,可附加会话查询必须中心化查询
吊销实时性依赖短有效期或额外吊销列表天然实时
令牌体积较大,声明越多越大很小
密钥管理需要签名密钥和轮换机制只需存储随机串
适合场景系统多、读多写少、能接受短有效期安全要求高、系统少、调用量可控

我通常选 JWT 做访问令牌,有效期控制在 10 到 15 分钟,同时在网关层加一次会话存在性检查。这样既保留了本地验签的性能优势,又能在登出后最多一个校验周期内失效。如果业务系统数量少、QPS 不高,直接用不透明令牌更省心,别为了“看起来先进”上 JWT。

JWT 的声明只放必要信息:账号 ID、会话 ID、签发时间、过期时间、令牌唯一标识。不要放手机号、邮箱、角色列表这类会变化或敏感的数据。角色和权限建议由业务系统根据账号 ID 实时查询或走缓存,否则用户权限被回收后,旧令牌里的角色还能用很久。签名算法用非对称的 RS256 或 ES256,身份中心持私钥,业务系统持公钥,避免对称密钥在多系统间复制导致泄露面扩大。

3.2 双Token机制与刷新窗口计算

访问令牌短、刷新令牌长,这是标准做法。问题在于参数怎么定。访问令牌太长,登出后残留窗口大;太短,刷新请求太频繁,身份中心压力大。刷新令牌太长,被盗后风险窗口大;太短,用户频繁重新登录。

我的经验值:访问令牌 15 分钟,刷新令牌 7 天,刷新令牌采用滑动续期,但设置绝对上限 30 天。也就是说,用户持续活跃时,刷新令牌每次使用后换新并重置 7 天有效期,但同一会话从创建算起最多存活 30 天,超过后必须重新登录。这个策略兼顾了体验和安全,适合大多数内容型、工具型产品。金融类或后台管理类可以缩短到访问令牌 5 分钟、刷新令牌 12 小时。

容量估算可以这么算:假设日活 100 万,人均每天活跃 3 次,每次活跃产生 1 次登录或刷新,峰值集中在晚高峰两个小时,刷新请求约占总请求的 5%。按峰值 QPS 估算,刷新接口需要支撑的 QPS 大约是100万 × 3 × 5% ÷ 7200 ≈ 21,再乘 3 倍余量,按 60 QPS 设计足够。会话存储按日活 100 万、人均 1.5 个设备估算,约 150 万个会话,每个会话记录按 500 字节算,不到 1GB,普通缓存实例完全扛得住。真正需要注意的是缓存 key 的过期策略和批量清理,不要用全量扫描。

3.3 单点登录与单点登出的实现

单点登录的触发点通常有三个:用户主动访问业务系统、业务系统发现无令牌、令牌过期。标准流程是业务系统把未登录用户重定向到身份中心的授权页,带上回调地址和状态参数;身份中心判断是否有全局会话,有就直接签发令牌回调,没有就展示登录页。回调地址必须做白名单校验,状态参数要随机生成并校验,防止授权码被截获后重放。

单点登出比单点登录难。用户在一个系统点退出,其他系统怎么知道?常见做法是身份中心吊销会话,业务系统在下一次校验时发现会话无效,跳转登录页。这依赖业务系统每次都查会话。如果业务系统只做本地 JWT 验签,就必须接受“最长一个访问令牌有效期”的延迟。想要更实时,可以加一层登出广播:身份中心把登出事件写到消息队列,各业务系统消费后清理本地缓存。广播不是必须的,但用户量大的产品建议加,否则客服会收到大量“退出不彻底”的反馈。

前端侧要注意,登出时不能只清 localStorage,还要清理 Cookie、内存缓存、Service Worker 缓存。如果用了 HttpOnly Cookie 存刷新令牌,登出接口要负责下发清除指令。多个标签页之间可以通过 storage 事件同步登出状态,避免一个标签页退了、另一个还在。

3.4 多端登录与互踢策略

多端登录策略是产品问题,不是纯技术问题。常见策略有三种:允许多端同时在线、同类型设备互踢、限制最大在线设备数。内容类产品通常允许手机和电脑同时在线,但同类型设备只保留最近登录的一个;后台管理类系统通常只允许一个会话,新登录踢掉旧会话。

实现上,会话表按账号 ID 建索引,登录时查询该账号的有效会话列表,根据策略决定是否吊销旧会话。互踢时要给被踢的客户端一个明确提示,不能静默失效。可以在令牌校验接口返回特定的错误码,客户端收到后清理本地状态并跳转登录页,提示“账号已在其他设备登录”。

还有一种容易被忽略的情况:用户修改密码或冻结账号后,所有会话必须立即失效。这要在账号状态变更的逻辑里显式调用会话吊销,不能指望令牌自然过期。我见过一个案例,用户账号被盗后改了密码,但攻击者持有的旧刷新令牌还能继续续期,原因就是改密码逻辑没有清理会话。这个坑必须在设计阶段就写进 checklist。

4. 实操:从零搭一套可运行的最小闭环

4.1 环境与依赖准备

下面用 Java 生态做一个最小示例,缓存用 Redis,令牌用 JWT。选这套组合的原因是大部分团队已有 Spring Boot 和 Redis 基础设施,接入成本低。示例里的项目名用“模拟项目 X”代替,代码只保留核心逻辑。

依赖大致是:Web 框架、Redis 客户端、JWT 库、参数校验库。配置里把签名密钥、令牌有效期、刷新上限放到配置中心,不要硬编码在代码里。

auth: jwt: issuer: "identity-center" private-key-path: "/etc/auth/private.pem" public-key-path: "/etc/auth/public.pem" access-token-ttl-seconds: 900 refresh-token-ttl-seconds: 604800 session-absolute-ttl-seconds: 2592000 session: redis-key-prefix: "auth:sess:" refresh-key-prefix: "auth:refresh:" user-sessions-prefix: "auth:user:sess:" login: max-fail-count: 5 lock-seconds: 900

密钥用非对称方式,私钥只放在身份中心,公钥下发给业务系统。密钥要有kid标识,支持双密钥并存轮换。轮换时新令牌用新私钥签,旧公钥保留一个访问令牌有效期以上再下线,避免切换瞬间全站验签失败。

4.2 核心表结构与缓存Key设计

数据库表沿用上一节的account、identity、session。缓存层负责三件事:会话状态、刷新令牌映射、用户会话集合。

auth:sess:{sid} -> Hash { accountId, deviceId, status, expiresAt } auth:refresh:{tokenHash} -> Hash { sid, accountId, deviceId } auth:user:sess:{accountId} -> Set [sid1, sid2, ...]

会话 key 的 TTL 设为刷新令牌有效期,每次刷新时续期;刷新令牌 key 的 TTL 与会话一致;用户会话集合的 TTL 可以设得略长,靠定期清理无效成员。登录时先写会话,再写刷新映射,最后把sid加入用户集合。登出时反向删除,顺序不能乱,否则会出现“会话没了但刷新映射还在”的脏数据。

注意:缓存和数据库的双写一致性要靠“先写库、再写缓存、失败重试”来保证,不要依赖缓存过期兜底。会话这类数据对一致性要求高,宁可多一次重试,也不要出现用户已经登出、缓存里还显示在线的状态。

4.3 登录、校验、刷新、登出的关键代码

登录接口的核心逻辑:校验凭证、查找或创建账号、创建会话、签发双令牌。

public LoginResult login(LoginCommand cmd) { // 1. 根据身份类型查找 identity Identity identity = identityRepository.find(cmd.getType(), cmd.getIdentifier()); if (identity == null || !identity.isVerified()) { throw new BizException("身份未注册或未验证"); } // 2. 校验凭证,失败计数走缓存 if (!credentialService.verify(identity, cmd.getCredential())) { loginAttemptService.recordFailure(identity.getAccountId()); throw new BizException("账号或凭证错误"); } // 3. 检查账号状态 Account account = accountRepository.get(identity.getAccountId()); if (account.getStatus() != AccountStatus.NORMAL) { throw new BizException("账号状态异常"); } // 4. 根据多端策略处理旧会话 sessionService.applyDevicePolicy(account.getId(), cmd.getDeviceId()); // 5. 创建会话 String sid = IdGen.next(); String refreshToken = TokenGen.random(48); sessionService.create(sid, account.getId(), cmd.getDeviceId(), refreshToken); // 6. 签发访问令牌 String accessToken = jwtService.sign(account.getId(), sid); return new LoginResult(accessToken, refreshToken, jwtService.getAccessTtl()); }

校验逻辑放在网关或公共拦截器里,先验签再查会话:

public AuthContext verify(String accessToken) { Claims claims = jwtService.verify(accessToken); // 验签 + 过期校验 String sid = claims.get("sid", String.class); Session session = sessionService.get(sid); if (session == null || session.getStatus() != SessionStatus.ACTIVE) { throw new AuthException("会话已失效"); } if (session.getExpiresAt().isBefore(Instant.now())) { throw new AuthException("会话已过期"); } return new AuthContext(claims.getSubject(), sid); }

刷新接口要做三件事:校验刷新令牌、旋转令牌、检测重放。刷新令牌一次性使用,旧令牌用完后立即删除;如果有人拿旧令牌再次刷新,说明令牌可能泄露,直接吊销整个会话。

public LoginResult refresh(String refreshToken) { String hash = Digest.sha256(refreshToken); RefreshRecord record = refreshStore.get(hash); if (record == null) { // 可能是重放,按策略吊销会话 replayDetector.onUnknownRefresh(hash); throw new AuthException("刷新令牌无效"); } Session session = sessionService.get(record.getSid()); if (session == null || session.getStatus() != SessionStatus.ACTIVE) { throw new AuthException("会话已失效"); } // 旋转:删旧、发新 refreshStore.delete(hash); String newRefresh = TokenGen.random(48); refreshStore.save(Digest.sha256(newRefresh), record.getSid(), record.getAccountId()); sessionService.touch(session); String accessToken = jwtService.sign(record.getAccountId(), record.getSid()); return new LoginResult(accessToken, newRefresh, jwtService.getAccessTtl()); }

登出接口要幂等,重复调用不报错。删除顺序是先删刷新映射,再删会话,最后从用户会话集合移除。

public void logout(String sid) { Session session = sessionService.get(sid); if (session == null) { return; // 已登出,幂等返回 } refreshStore.deleteBySid(sid); sessionService.revoke(sid, SessionStatus.LOGGED_OUT); userSessionSet.remove(session.getAccountId(), sid); }

4.4 接入侧改造:老系统如何平滑接入

老系统改造是统一登录落地最耗时的部分。我的建议是分三步走:先让老系统“认令牌”,再让它“跳登录”,最后才做“单点登出”。

第一步,在老系统的入口加一个拦截器,从请求头或 Cookie 里取访问令牌,调身份中心的校验接口,拿到账号 ID 后写入本地上下文。这一步不改老系统的用户表,只把登录来源换掉。第二步,把老系统的登录页下线,未登录请求统一重定向到身份中心。第三步,老系统在令牌失效时清理本地会话,配合身份中心的登出广播。

改造期间要允许双跑:老会话和新令牌同时有效,通过配置开关控制。双跑时间不要拖太久,一般两到四周,否则两套登录逻辑会互相干扰。改造顺序建议从边缘系统开始,先拿一个访问量小的系统试水,跑稳了再动核心交易系统。

5. 安全与风控:统一登录最容易踩的坑

5.1 令牌安全

统一登录把风险集中了,令牌一旦泄露,影响面是全站。几个硬性要求:访问令牌只通过 Authorization 头传递,不要放在 URL 里,避免被日志和 Referer 带走;刷新令牌优先放 HttpOnly、Secure、SameSite 的 Cookie,前端拿不到,降低 XSS 窃取风险;如果必须放 localStorage,要接受更高的泄露风险,并配合短有效期和绑定设备。

JWT 验签要固定算法,禁止接受none或客户端指定的算法。密钥要定期轮换,支持多kid并存。令牌里加jti和sid,方便排查和吊销。不要在令牌里放权限列表,权限变更后旧令牌还能用很久,这是越权的常见来源。

跨域场景要仔细配 CORS 和 Cookie 的 SameSite。前后端分离且不同域时,SameSite=Lax可能导致跨站请求不带 Cookie,需要根据实际域名关系选择None加Secure,同时做好 CSRF 防护。不要为了省事直接关掉 CSRF 校验,统一登录的入口一旦被 CSRF,攻击者可以诱导用户以自己身份完成敏感操作。

5.2 账号合并与越权

账号合并的越权风险前面提过,这里再强调一次:合并的发起方必须证明对两个账号都有控制权。常见做法是要求用户先登录主账号,再验证从账号的凭证(短信验证码或密码),两边都通过才执行合并。合并后立即吊销从账号的所有会话,并记录操作日志。

另一个容易忽略的点是身份解绑。用户解绑手机号后,如果该手机号还能通过某些缓存路径登录,就是漏洞。解绑时要清理该身份相关的所有登录态,并检查是否还有其他可用身份,没有的话要提示用户先绑定新身份,否则账号会变成无法登录的孤岛。

5.3 审计与限流

统一登录是攻击面最集中的地方,必须有限流和审计。登录接口按 IP 和账号双维度限流,失败次数达到阈值后锁定一段时间,锁定期间即使密码正确也拒绝。验证码要在失败多次后强制触发,不能只在注册时用。刷新接口同样要限流,防止攻击者用泄露的刷新令牌高频试探。

审计日志至少记录:登录成功、登录失败、刷新、登出、绑定、解绑、合并、踢下线。日志里不要记录完整令牌和密码,记录哈希前缀或令牌 ID 即可。审计数据保留时间根据合规要求定,一般不少于六个月。排查线上问题时,能通过sid串起一次会话的完整生命周期,比什么都重要。

6. 常见问题与排查技巧实录

6.1 典型问题速查表

现象常见原因排查方向处理建议
登出后另一个系统仍在线业务系统只验签未查会话检查拦截器是否调用会话校验加会话存在性检查或登出广播
刷新接口偶发 401并发刷新导致旧令牌被提前删除看日志是否有同令牌多次刷新加分布式锁或允许短暂宽限期
跨域请求不带 CookieSameSite 或域名配置不对看浏览器网络面板的 Cookie 属性调整 SameSite 和域名白名单
令牌刚签发就过期各机器时钟不一致对比身份中心和业务系统时间启用时间同步,校验时留时钟偏移
账号合并后订单丢失只迁移了账号表未迁移业务数据检查合并逻辑的数据迁移范围按业务域逐项迁移并记录映射
多端列表重复设备客户端每次生成新 deviceId检查客户端持久化逻辑deviceId 落本地存储,不随请求变
修改密码后旧设备仍可用未吊销会话检查改密逻辑是否调用会话清理账号敏感变更统一吊销全部会话

6.2 几个压测和线上排查的真实体会

第一个体会是关于并发刷新。移动端网络切换时,客户端可能同时发出两个刷新请求,第二个请求拿着已经被旋转掉的旧令牌,如果服务端严格一次性校验,就会把整个会话吊销,用户被强制退出。这个问题的解法不是放宽校验,而是在刷新接口加短时间的幂等窗口:旧令牌使用后保留一个很短的标记,比如 30 秒,同一设备在窗口内重复刷新返回同一个新令牌。窗口不能太长,否则重放检测会失效。

第二个体会是关于时钟偏移。JWT 的exp校验依赖系统时间,容器化环境里节点时间不同步很常见,表现为“令牌刚发就提示过期”或“过期了还能用”。解决办法是校验时允许 30 到 60 秒的偏移,同时在部署层面保证时间同步。不要用关闭过期校验来绕过问题,那等于把安全机制拆了。

第三个体会是关于缓存雪崩。会话数据集中在一个缓存实例,如果大量 key 同时过期,或者实例重启,所有用户会被迫重新登录。会话 key 的过期时间要加随机抖动,避免同一时刻批量失效;缓存要做持久化或主从,重启后能快速恢复。真到了极端情况,要有降级方案,比如允许短期内的令牌仅验签通过,给缓存恢复争取时间,但这只能作为临时手段,不能长期开着。

第四个体会是关于老系统的“假登出”。有些老系统在本地清除了会话就认为登出完成,实际身份中心的会话还在,用户重新访问又自动登录。这类问题排查时不要只看老系统日志,要拿sid去身份中心查会话状态。接入统一登录时,登出逻辑必须由身份中心驱动,老系统只负责清理本地状态,不能自行决定全局会话的生死。

最后分享一个我在实际项目里养成的习惯:把每一次登录、刷新、登出都用同一个sid串起来,日志里打印sid的前八位。线上出问题时,拿到用户反馈的时间点和账号,先查这个账号最近的sid链路,基本能还原出是令牌过期、会话被踢、还是刷新失败。这个习惯比任何监控大盘都直接,尤其是在多系统串联的场景里,能省掉大量猜谜的时间。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进