
单位里上了好几个业务系统今天这个要单独登录明天那个又弹出登录框用户烦运维也烦。后来我们做了一个“域登录态分享”的轻量级方案说白了就是类SSO但比正儿八经的SSO轻得多不引入重型框架不改造所有系统的认证逻辑只解决“登录一次到处能用”的刚需。这篇文章就把整个方案的思路、实现过程、踩坑记录完整扒出来给同样被多系统登录折腾的朋友一个参考。1. 整体方案设计为什么不做“真SSO”而做“登录态分享”1.1 先搞清楚目标场景我们面临的现状其实很典型内部有A、B、C三个Web系统分别由不同小组维护各自有自己的用户表和登录接口。A系统是主门户B是项目管理系统C是报表中心。用户每天要在三个系统间来回切换每次切换都要输入账号密码。领导提的需求是“能不能只登一次”业务部门希望“点链接直接进”安全部门希望“别搞出明显漏洞”。第一反应是上CAS或者OAuth2.0搞一套独立的认证中心。但评估下来发现几个问题三个系统技术栈不一致有Java、有Python、有PHP改造难度差异很大。各系统已有自己的会话体系比如A系统用的是内存SessionB系统用RedisC系统虽然也用Session但Key格式不同。团队人力有限不可能花一两个月去统一认证接口。有些系统是厂商封闭开发的改不了源码只能通过配置层面解决。于是我们换了个思路不重新做认证而是把“登录成功后”的会话凭证在多个系统间传递和识别。核心不是处理“怎么验证用户”而是处理“怎么让所有系统都信任同一个登录凭证”。这其实就是SSO的雏形叫“登录态分享”更准确。1.2 方案选型Cookie共享域 Token校验我们最终选择了基于Cookie共享域和Token校验的组合方案而不是直接套CAS。原因很简单CAS要求每个应用都去认证中心换取Ticket理论上最规范但改造量大我们是已有系统不是从零建设。方案的核心逻辑是这样的有一个统一的登录入口还是A系统因为它已经有用户密码校验能力。登录成功后认证服务生成一个全局唯一的Token并写入主域名下的Cookie。其他系统在收到请求后先检查本地是否已有可信会话没有则从主域名Cookie里读取Token向认证服务验证Token的合法性。验证通过后各系统建立自己的本地会话比如生成各自的Session之后就不再频繁请求认证服务。这里的关键点是“主域名”。三个系统如果都在同一个主域名下比如a.company.com、b.company.com、c.company.com那Cookie的Domain设置为company.com三个系统就都能读到。如果系统域名五花八门比如一个abc.com一个def.comCookie共享就走不通了那就得换iframeP3P或者Post Message方案我们这边恰好都是同一个主域名下的子域名所以方案可行。1.3 为什么用Token而不是直接共享Session很多人会问既然Cookie能跨子域共享那把SessionID直接放进共享Cookie不就行了我们一开始也这么想过但很快否决了。SessionID是各系统自己生成的A系统的SessionIDB系统拿到之后没有意义因为B的Session存储里根本没有这个ID。除非所有系统都共用一套Session存储那得改配置不说Session序列化方式、过期策略还得统一成本太高。而Token是一个独立的全局凭证它本身不依赖任何系统的Session存储认证服务拿到Token后能解析出用户身份各系统再拿这个身份去建立自己的会话。Token在这套方案里充当的角色是“中间凭证”对外是登录态的凭证对内是身份信息的载体。我们选用的是一个自签名的令牌结构类似JWT但没完全按标准来包含以下部分{ token: T_S_3F9A..., user_id: 10086, username: zhangsan, display_name: 张三, email: zhangsancompany.com, expire_at: 1714305600, sign: HMAC-SHA256签名 }生成时用服务端密钥对user_id username expire_at做HMAC签名防止伪造。各系统不需要自己验证签名逻辑只要拿着Token到认证服务换个用户信息即可。不过考虑到网络开销我们后来也在各系统里维护了一份公钥缓存让它们能本地验签只有验不过时才回源。这个优化后面细说。2. 核心细节解析Cookie共享域、Token生命周期和本地会话嫁接2.1 Cookie共享域的几个坑跨子域共享Cookie看起来简单实际配置时全是细节。我们用的是Nginx对外提供HTTPS服务后端各系统独立部署。共享Cookie的设置要点如下Cookie的Domain必须是父级域名比如company.com不能是空或者某个子域。Path设为/保证该域名下所有路径都能带。Secure必须为True因为我们需要HTTPS传输。HttpOnly建议为True防止脚本读取Token降低XSS泄露风险。SameSite要特别注意。新版本浏览器默认SameSiteLax在跨站请求时可能不带Cookie。但我们是同一个主域名下的不同子域属于SameSiteStrict范围内吗并不完全。Chrome对SameSite的判断依据是“site”而不是“子域”a.company.com和b.company.com其实是同一个site所以Lax模式下跨子域请求是会带Cookie的。但如果登录入口所在域主门户和其他系统完全不同比如一个.com一个.cn那就必须设置SameSiteNone同时不能设置为Secure为False否则浏览器直接拒绝。我们的实际配置看起来像这样Set-Cookie: GLOBAL_TOKENT_S_xxx; Domaincompany.com; Path/; ExpiresFri, 31 Dec 2025 23:59:59 GMT; Secure; HttpOnly; SameSiteLax有几个容易踩的细节开发环境下如果用的是localhost访问不同端口localhost:8080、localhost:9090Cookie的Domain没法设为localhost的父域所以共享是失效的。开发联调时得改本机Host把a.local.com、b.local.com指向127.0.0.1才能模拟出子域环境。某些内部系统可能还是HTTP协议如果强制Cookie带Secure标记这些系统就算设置了共享域也读不到Cookie请求会变成HTTPCookie不传输。我们后来干脆把所有系统的对外入口统一收口到网关由网关做HTTPS终结再内部HTTP转发这个问题就没了。如果内部有多个域名比如测试环境用test.company.com线上用company.com测试环境的Cookie不要加Domain否则测试环境登录后Cookie会串到线上域存在安全隐患。2.2 Token的有效期设计长Token配合短会话Token不能跟用户会话一样设个Session超时时间就完事因为它是跨系统共享的任何一方的登录退出都会影响全局。我们设计了双时间概念全局Token的绝对过期时间比如8小时。这是Token本身的生命周期到期后必须重新登录。各系统本地会话的闲置超时比如30分钟。用户在某系统不操作30分钟后该系统的本地会话失效但全局Token还有效。此时用户重新访问该系统系统会拿着全局Token重新换取身份再建立新的本地会话用户无感知。这样做的好处是一个系统30分钟没操作不会被强制全局下线而全局Token在8小时内有效用户只需要8小时内登录一次。有一个边界要注意用户主动退出时必须同时清除共享Cookie和各系统本地会话。但各系统独立部署退出操作无法主动通知到所有系统。我们的解决办法是在共享Cookie里存一个“版本号”每次登录或改密码都会递增版本号。各系统在每次请求时拿当前版本号和你本地的版本号比对不一致就强制重新建立会话。这样即使某个系统不知道其他系统发生了退出或改密也能在下次请求时发现版本不符主动清理。2.3 本地会话的嫁接方式各系统原本都有自己的登录逻辑比如Java系统是HttpSessionPython系统是自定义Session我们不打算动它们的核心代码而是通过一个前置过滤器/中间件在认证环节之前拦截请求。处理流程如下客户端请求B系统的一个受保护页面。B系统的中间件检查当前请求是否已有有效本地会话比如Session里存在login_user。如果没有则检查请求中是否携带共享CookieGLOBAL_TOKEN。如果没有GLOBAL_TOKEN跳到A系统的登录页带上return_url参数登录成功后跳回B系统。如果有GLOBAL_TOKEN中间件把它发给认证服务换取用户信息。认证服务返回用户信息后B系统用自己的方式创建本地会话比如往Session里塞对象然后放行请求。这套流程里各系统只需要实现一个“在本地Session里写入用户信息”的适配接口不需要关心Token怎么生成、怎么验证。我们把通用的Token验证逻辑抽成了一个独立Jar包分发给A、B、C系统各自集成。实际改造中最难的是C系统因为是厂商产品改不了代码。我们退而求其次在C系统前面加了一层网关过滤器用Nginx的auth_request模块先请求认证服务验证Token通过后再把用户信息以HTTP头的方式转发给C系统。C系统虽然没有本地会话嫁接但我们可以借助网关在Cookie里写入一个C系统自己认识的会话标识比如把用户ID映射成C系统的内部用户编码这样C系统依然能识别用户身份只是无法感知退出操作能接受。3. 实操过程与核心环节实现3.1 认证服务端接口设计认证服务我们单独部署了一个小应用对外暴露三个接口POST /auth/login GET /auth/verify?tokenxxxuser_idxxx GET /auth/logout?tokenxxx/auth/login接收用户名密码校验通过后生成Token写共享Cookie返回用户信息。这个接口就是原A系统的登录接口我们只是把它的返回逻辑改了改。重点在/auth/verify它接收Token和用户标识返回用户详情或错误码。/auth/verify的实现逻辑虽然简单但性能要考虑。因为我们希望各系统每次建立本地会话时都调一次而不是每请求都调。实际场景是用户第一次访问B系统中间件发现没有本地会话调verifyverify通过后B系统创建本地会话后续所有请求都走本地会话不再调verify。只有当本地会话过期后才会再次调verify。所以verify接口的QPS不会太高压力不大。verify接口的关键代码如下Java伪码GetMapping(/auth/verify) public ResultAuthUser verify(RequestParam String token, RequestParam String user_id) { // 1. 校验Token格式 if (!token.startsWith(T_S_)) { return Result.error(INVALID_TOKEN); } // 2. 读取缓存中Token状态 TokenInfo info tokenStore.get(token); if (info null || info.isExpired()) { return Result.error(TOKEN_EXPIRED); } // 3. 校验用户ID是否匹配 if (!info.getUserId().equals(user_id)) { return Result.error(USER_MISMATCH); } // 4. 返回用户信息 return Result.ok(info.getUser()); }这里有个细节TokenStore用的是RedisKey是Token本身Value是用户信息JSON和过期时间。我们把过期时间设成跟Token绝对过期时间一致这样Redis里的数据和Cookie里的Token生命周期同步不会出现Cookie还在但Redis已经删了的情况。3.2 中间件适配器的实现要点各系统语言不同适配器也得分别写。Java系统用Spring Boot最方便的是实现一个HandlerInterceptor在preHandle里做登录态检查public class SsoInterceptor implements HandlerInterceptor { Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 检查本地会话 HttpSession session request.getSession(false); if (session ! null session.getAttribute(LOGIN_USER) ! null) { return true; } // 读取共享Cookie String globalToken CookieUtils.getCookieValue(request, GLOBAL_TOKEN); if (StringUtils.isEmpty(globalToken)) { // 跳转统一登录页 response.sendRedirect(https://a.company.com/login?return_url URLEncoder.encode(request.getRequestURL().toString(), UTF-8)); return false; } // 请求认证服务校验Token AuthUser user authService.verify(globalToken, getUserIdFromRequest(request)); if (user null) { response.sendRedirect(https://a.company.com/login?return_url ...); return false; } // 建立本地会话 session request.getSession(true); session.setAttribute(LOGIN_USER, user); session.setMaxInactiveInterval(1800); return true; } }Python系统的实现就类似装饰器思路一样。需要注意的是getUserIdFromRequest不好拿所以我们简化了verify接口只校验Token不校验user_id用户信息完全从Token解析出来。这样中间件不用管用户标识反正Token本身包含完整用户信息认证服务在存储时就已经确保Token和用户绑定。后来在生产环境也确实发现靠URL参数传user_id容易导致缓存不一致干脆去掉只保留Token即可。3.3 前端跳转逻辑与return_url校验打开A系统登录页登录成功后跳回原来的页面。这个return_url参数会被拼接在跳转链接里如果被恶意篡改用户登录后可能被导到钓鱼网站。我们对此做了两层防护第一层return_url必须满足白名单后缀比如必须以company.com结尾或合法子域开头。第二层登录成功后不直接302跳转而是返回一个带Token的页面页面通过JS读取Cookie后跳转。这样return_url是通过后台拼接签名参数传给前端前端校验签名后跳转。实际项目里我们用了更稳妥的方案登录成功后认证服务返回一段HTML包含一个隐藏的表单和一段自动提交脚本表单里带上return_url用POST方式提交到目标系统。目标系统收到POST后同样校验Token并建立会话。这样比GET跳转更安全因为POST参数不会出现在浏览器历史记录里而且能在服务端精确控制URL合法性。跳转参数完整示例form idsso_form methodpost actionhttps://b.company.com/sso/accept input typehidden nametoken valueT_S_xyz input typehidden namereturn_url value/project/detail?id123 input typehidden namesign valuemd5(tokenreturn_url密钥) /form scriptdocument.getElementById(sso_form).submit();/script各系统的/sso/accept接口负责接收表单验签验证Token建立本地会话然后重定向到return_url。这个方案比之前说的中间件方案更主动一点能在用户从A系统点链接跳B系统时提前建立会话而不是等用户访问B系统某个页面时才被动处理。我们把两种模式都做了一种是“被动模式”即中间件拦截另一种是“主动模式”即跨系统跳转时通过/sso/accept预建会话。实际使用中用户从A系统门户点图标进入B系统时走主动模式用户体验最顺滑如果是直接在浏览器输入B系统地址或刷新B系统页面就走被动模式也能正常跳登录后回来。两种模式背后共用同一套Token校验逻辑不冲突。3.4 各系统对接时的时间同步问题这个坑必须单独提。最早联调时B系统每次验证Token都提示过期后来排查发现B系统服务器时间比认证服务器慢了近3分钟。因为我们验签时会把expire_at与当前服务器时间做比较时间不一致就会误判。解决方案有两层基础层所有服务器启用NTP时间同步确保偏差在秒级以内。代码层验签时不直接拿当前时间跟过期时间比而是允许5分钟的时钟偏移窗口。比如Token的expire_at是10:00客户端在10:04校验还在容忍范围10:05则拒绝。这样即使某些服务器同步周期没跟上也不会偶发登录失败。具体签名校验代码片段long expireAt tokenInfo.getExpireAt(); long now System.currentTimeMillis() / 1000; long allowedDrift 300; // 5分钟容忍 if (now - expireAt allowedDrift) { throw new TokenExpiredException(); }3.5 密钥管理与签名算法Token的签名我们最初用的是HmacSHA256直接用统一字符串密钥。后来觉得每次都让各系统调用认证服务验签太慢就改成让各系统本地验签。本地验签需要知道密钥又不能直接把密钥写在代码里所以安排了独立的密钥配置中心启动时拉取定期轮换。签名算法最终采用标准JWT的HS256但载荷结构自定义。Token生成逻辑String header Base64URL(JSON({alg:HS256,typ:JWT})); String payload Base64URL(JSON({uid:10086,name:zhangsan,exp:1714305600,ver:3})); String signature HmacSHA256(header . payload, secretKey); String token header . payload . signature;各系统本地验签时按同样方式重算签名一致则认为Token合法。由于Token本身携带用户信息所以本地验签通过后不需要再请求认证服务直接拿到用户信息建立会话。这大大减轻了认证服务的压力也提高了响应速度。不过本地验签有个隐患如果密钥泄漏攻击者可以自造任意Token等于所有系统沦陷。所以我们的密钥不会发给所有系统而是把公钥跟密钥分离的模式改成了“JWT非对称加密”认证服务用私钥签名各系统用公钥验签。这样即使公钥泄露也无法伪造Token。代码用的是RS256算法。改造后各系统只需在配置中心拿一个公钥字符串安全性好很多。3.6 本地会话与全局会话的版本联动前面提到版本号机制再细化一下。我们定义了一个全局版本号login_version存储在Redis里每次用户登录、退出、改密都会自增。Token里也包含version字段表示该Token生成时的版本号。各系统本地会话建立时会把version存下来。每次请求时中间件读取请求里的全局Token解析出version如果和本地会话中保存的version不一致就认为全局会话已变化强制清理本地会话并重新走认证流程。这个机制解决了几个实际问题用户在某系统点了“退出登录”该系统的本地会话被清除共享Cookie也会被删除。但其他系统的本地会话还在。用户下次访问B系统时中间件找不到共享Cookie会跳登录页。但如果用户手动清掉Cookie再访问就有点乱了。用户在A系统修改了密码旧Token的version和全局版本号不一致各系统会在下一次请求时观察到差异自动让旧会话失效。管理员强制下线某个用户时直接把该用户的login_version增加即可所有已登录系统下一次请求都会失效。这个设计是我们迭代到第二阶段才加的因为一开始做了简单的Token过期发现某些系统里的会话能存活很久即使管理员已经冻结账号用户还能在另一个系统里操作。有了版本号后基本能做到秒级失效虽然需要请求时顺带查一次全局版本号但我们把它放到Redis里性能影响可以忽略。4. 常见问题与排查技巧实录4.1 子域Cookie读不到现象A系统登录成功跳转到B系统B系统日志显示请求里找不到GLOBAL_TOKEN。排查步骤在浏览器Console执行document.cookie确认Cookie是否存在Domain和Path是否正确。F12打开Network面板查看请求头里的Cookie字段看有没有GLOBAL_TOKEN。确认B系统请求的URL是不是b.company.com开头而不是127.0.0.1或者localhost。检查B系统是否在HTTPS环境下访问如果Token带了SecureHTTP请求不会带。确认是否被前置的WAF或网关去掉了Cookie头。最常见的原因就是第3条开发环境用localhost或IP访问Cookie的Domain是company.com浏览器不会把它发给localhost。解决办法就是本机改Host把几个子域全部映射到127.0.0.1。4.2 跳转登录页死循环现象用户访问B系统B系统跳到A系统登录页登录成功后跳回B系统B系统又跳登录页反复循环。这个问题的根源通常是Token没有写进共享Cookie或者B系统校验Token失败。我们排查时先在浏览器里确认跳到A系统登录页时Cookie是否已经写成功如果写成功了再确认B系统收到的Token和A系统写入的Token是否一致。往往是因为B系统域名写死成了http://b.company.com而A系统写入Cookie的Secure属性导致HTTP连接不带Cookie跳转时B系统无法验证。还有一种情况是return_url带了旧Token参数跳转后B系统优先读URL参数里的Token而那个Token已经过期。解决办法是统一逻辑只从Cookie里读TokenURL参数里的一律忽略。这样能避免缓存Token拼接导致的混淆。4.3 用户ID映射问题各系统的用户标识体系不同有的用自增ID有的用员工工号。我们的认证服务里保存了统一的用户ID比如工号但B系统本地账号表可能是另一套ID。直接拿工号去B系统里查记录是查不到的。解决方案是建立一张用户映射表在B系统里维护“全局用户ID - 本地用户ID”的映射关系。中间件在建立本地会话时先通过映射表转换。这块如果前期没有规划后期补数据会比较痛苦。我们在实施时先导出了一份用户对照Excel让各系统管理员确认匹配关系然后初始化映射表。如果是C系统这种封闭系统映射表都建不起来我们只能通过网关在HTTP头里传全局用户ID由C系统自己通过外部接口查用户如果查不到就拒绝登录。好在C系统的用户量不大就几百人后期通过脚本同步补上了映射。4.4 退出登录时其他系统没有同时退出严格意义上这个问题没法百分之百解决因为HTTP是无状态的你无法主动通知所有系统。但有了版本号机制后用户退出时在认证服务清除Token并递增版本号其他系统在下一次请求时会发现本地版本号不一致自动清理本地会话并弹出登录页。但注意这里的前提是用户真的有下一次请求。如果用户关闭了浏览器那无所谓如果B系统页面通过Ajax持续轮询那么轮询请求会触达中间件版本号不一致时中间件直接返回401前端检测到登录失效后跳转登录页体验上也是同步退出的。如果要求“退出即全局失效”需要引入消息队列广播或者WebSocket通知但成本高我们评估后选择不实现因为业务上可以接受最多10秒的延迟。4.5 错误的时间戳或时区问题多语言系统的时间处理容易出现时区差异。Java默认时区是系统时区Python用的是UTCPHP也可能不同。我们统一约定所有Token里的时间字段一律使用Unix时间戳秒服务端解析时转成各自本地时间。这个约定写进了对接文档前端不用管后端必须遵守。实际联调中Python系统因时区问题踩了一次坑后来在代码里加了一行os.environ[TZ] Asia/Shanghai解决。4.6 大量本地验签带来的密钥轮换问题本地验签虽然快但密钥轮换时所有系统都要及时更新。我们在配置中心里支持多密钥版本配置项是一个数组验签时先试最新版本失败再试旧版本。这样轮换时可以有过渡期不必要求所有系统同时切换。等旧密钥过期时间到了再统一清理。4.7 网关层透传用户信息的安全问题我们给C系统做网关透传时一开始直接信任HTTP头里的X-User-Id后来发现内网用户可以直接构造请求头伪造身份这非常危险。整改后网关会先验证Token然后生成一个一次性TGT临时凭证把它放进Cookie传给C系统C系统用TGT向认证服务换取真实用户ID。即使攻击者伪造HTTP头没有合法TGT也换不到身份。这个坑值得强调内部系统同样存在越权风险。5. 经验总结类SSO方案走到现在的体会现在这套“域登录态分享”机制运行了大半年日常并发不算高几百人同时在内网使用整体稳定。个人体会最深的几点第一方案选择比技术细节更重要。我们没有一开始就冲上去做CAS而是评估了现有系统状态选了一条“投入产出比”最高的路。虽然叫类SSO但解决了业务痛点用户感受就是“登录一次全都能进”。如果当初直接要求所有系统接入OAuth可能到现在还在拉锯。第二Token设计的核心不是加密而是“多方共识”。不同系统对Token的信任来源于共享密钥、校验接口和统一规则只要这些共识清晰就能保证登录态可靠传递。签名算法只是底座真正的规范在于版本号、过期时间、密钥管理和错误码约定。第三兼容存量系统是最大的变量。有些系统改不了代码或者维护团队不在你必须在网关层想办法。C系统最终能接入靠的就是前置网关的适配能力这也说明做SSO不一定要侵入业务系统边缘层也能完成很多工作。第四排查问题时先看Cookie再查Token别急着翻代码。我们遇到的大多数问题打开浏览器开发者工具看一遍请求头基本就能定位。Cookie有没有、Token对不对、签名算法是否匹配这些都是有明确检查路径的。如果你也想在内部系统之间共享登录态但又不愿意做大改造可以参考这个思路先统一Cookie域再设计一个轻量Token把校验逻辑做成可复用的中间件最后针对特殊系统加网关适配。整个过程不需要高深技术但对细心程度和跨团队沟通能力有要求。希望这篇记录对你有用。