ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

SSO单点登录实战:JWT/CAS票据流转与登录态安全避坑

SSO单点登录实战:JWT/CAS票据流转与登录态安全避坑 简介面向Java Web开发者的SSO单点登录资源基于Servlet容器演示多应用统一认证实现。内容涵盖认证中心、Ticket生成与校验、自定义Filter拦截受保护资源等核心机制适合学习分布式会话共享或准备集成CAS/OAuth2的读者参考。压缩包共80个文件包含Java源码、JSP页面、XML配置、properties资源文件及编译后的class文件并附Maven工程描述导入IDE即可解析模块结构。资源仅103KB轻量便于快速研读目前已有146人学习。包内按sso-server、sso-oa、sso-pro三个子工程组织清晰呈现服务端与两个客户端应用间的跳转和令牌传递流程同时提供登录页、注销接口及Token校验逻辑代码可据此搭建最小可用SSO示例并为后续扩展OAuth2等协议打下基础。1. SSO 单点登录一次登录全线通行SSO 单点登录这个名字听起来像一个登录模块但实际上它解决的是分布式系统里最头疼的会话一致性问题用户在一个系统登录后访问其他系统不再重复输入账密。我在生产环境里见过太多翻车案例真正的问题往往不在认证中心而是登录态在传输过程中被悄悄漏了出去。那些号称做了 SSO 的系统换个域名就丢登录态、回调被伪造、票据被人重放这类事故占了 SSO 排障的七成以上。本文从认证模型讲起落到 Spring Security JWT 的最小实现再展开票据传递、日志脱敏和五个高频踩坑点。适合手里有三五个系统、想把账号体系收拢到一个认证中心的团队也适合接手遗留系统、准备做登录迁移的同学。2. 认证模型迁移从 Session 黏滞到 Token 票据SSO 的第一步是改思路2.1 Session 黏滞为什么登录一次在分布式下会失效传统单体应用里登录状态存在服务端 Session本质是服务器内存里的一张哈希表通过 cookie 里的 JSESSIONID 找到对应的会话。单机下没问题一旦系统顺着业务拆分部署成多台实例负载均衡把请求轮询到不同机器麻烦就来了用户在 A 实例上登录成功下一次请求被转发到 B 实例B 的内存里没有这个会话于是又让你登录。常见的救法有两种Session 复制和黏滞会话Sticky Session。Session 复制让每台机器都同步全量会话多几个节点就有大量的内存与网络开销黏滞会话则把同一个用户的请求固定打到同一台机器上这等于给应用加了一个隐形的部署约束。这两种方案在单系统内部还行放到 SSO 场景里就彻底不够用了。单点登录要解决的是多个独立系统共享一个登录态每个系统都在自己的进程里维护会话A 系统登录成功B 系统依然不认你。要打通横跨两个系统的信任就必须把登录态从服务器内存里拿出来变成一种可传递、可验证的凭证。这个凭证不能依赖某台机器的内存它要么存储在统一的地方由各系统查询要么直接发给客户端由各系统独立验证。CAS 选了前者JWT 选了后者两条技术路线的取舍成了后面所有设计的起点。2.2 Token 与票据把登录态从服务器搬到客户端我一般会把 SSO 的凭证模型分成两类。一类是共享存储型认证中心把会话 ID 写进 Redis业务系统拿这个 ID 去 Redis 查会话状态好处是登出后可以立刻失效坏处是每个业务系统都要访问 Redis网络分区时登录接口会直接雪崩。另一类是无状态令牌型JWT 把用户 ID、过期时间、签名信息打包成一串字符串发给客户端业务系统本地验签不依赖统一存储。JWT 的三段结构很值得拆开看Header 是{alg:HS256,typ:JWT}Payload 里至少要放sub用户标识、exp过期时间、iat签发时间Signature 是拿密钥对前两段做的签名。业务系统拿到 token 后用同一把密钥验签只要签名对得上就信任 Payload 里的内容。这个模型最核心的边界是令牌可以防篡改但不能防重放。签名只能证明内容没被改过证明不了拿令牌的人就是当初那个用户所以令牌的有效期必须短传输必须走安全通道。参数上我常用的做法是exp设 15 到 30 分钟再配一个 refresh token避免用户每 20 分钟被踢下线一次。2.3 CAS 票据流转认证中心怎么把信任传递给业务系统无状态令牌适合内部系统之间的认证如果要对接第三方系统或者要严格遵守业务系统永远不接触用户密码的原则CAS 的票据模型才是经典答案。CAS 协议里有三个角色客户端浏览器、业务系统、CAS 认证中心。用户第一次访问业务系统时没带凭证业务系统把用户重定向到认证中心的登录页URL 里带上service参数告诉认证中心验证完回哪去。用户在认证中心登录成功后认证中心创建一个全局会话 TGT同时生成一个一次性服务票据 ST把浏览器带着 ST 跳回业务系统的回调地址。业务系统收到 ST 后在服务端直接请求 CAS 的serviceValidate接口换取对应用户的用户名这个环节票据 ST 只走服务端到服务端浏览器完全看不到。这个流程的关键点在于ST 是一次性的、绑定目标 service 的。认证中心在签发 ST 时把service值和 ST 绑在一起业务系统回验时带上的 service 参数必须和签发时一致否则验证失败。这个设计防的就是有人拿一个在 A 系统领到的票据放到 B 系统的回调里冒用。很多 SSO 事故就出在这个绑定的校验上后面避坑章节会重点展开。整套时序可以总结为一次重定向、一次服务端回验、一个全局会话理解这三样CAS 的流转就通了。步骤角色动作传递信息1浏览器 → 业务系统访问受保护资源无凭证2业务系统 → 浏览器302 重定向到认证中心service 回调地址3浏览器 → 认证中心展示登录页用户提交账密用户名、密码4认证中心 → 浏览器下发全局票据 TGT重定向回 serviceST 票据5浏览器 → 业务系统带 ST 访问回调地址ST6业务系统 → 认证中心服务端回验 STST service7认证中心 → 业务系统返回用户名与属性验证结果 XML3. 用 Spring Security JWT 搭最小 SSO认证中心与两个客户端的完整代码3.1 认证中心令牌签发与回调校验这部分我按最常见的做法给出一套可以照着抄的 JWT 版 SSO适合内部系统互信不需要额外部署 Redis。认证中心负责两件事处理登录请求、签发令牌提供一个校验接口供业务系统确认令牌。先看登录接口的核心逻辑。PostMapping(/sso/login) public ResponseEntity? login(RequestBody LoginRequest req, HttpServletResponse response) { // 1. 校验用户名密码真实项目里这里应该走 UserDetailsService if (!admin.equals(req.getUsername()) || !secret.equals(req.getPassword())) { return ResponseEntity.status(401).body(用户名或密码错误); } // 2. 生成 JWT有效期 30 分钟 String token Jwts.builder() .setSubject(req.getUsername()) .setIssuer(sso-server) .claim(tenant, default) .setIssuedAt(new Date()) .setExpiration(new Date(System.currentTimeMillis() 1800_000)) .signWith(SignatureAlgorithm.HS256, jwtSecret) .compact(); // 3. 把 token 写到 HttpOnly cookie同时返回给前端存 localStorage Cookie cookie new Cookie(SSO_TOKEN, token); cookie.setPath(/); cookie.setHttpOnly(true); cookie.setMaxAge(1800); response.addCookie(cookie); return ResponseEntity.ok(new LoginResponse(token)); }这段代码里有两个参数值得重点说。setExpiration里的1800_000是 30 分钟的毫秒数令牌有效期越短重放风险越小但用户体验越差我一般会压到 15 到 30 分钟之间。signWith指定的jwtSecret是认证中心和所有业务系统共享的对称密钥这个密钥一旦泄露等于所有系统的登录态都可以被伪造。生产环境不建议把密钥硬编码在代码里更稳妥的做法是通过环境变量注入并在部署时用独立的密钥管理服务下发。claim(tenant, default)是租户字段多租户场景用于区分数据域单租户可以删掉。业务系统拿到 token 后不能只相信它存在必须回验一次。常见做法是提供GET /sso/validate接口业务系统把 token 放在 Authorization 头里传过来认证中心解出签名和过期时间后返回用户信息。这个接口要限制为内网调用不要让公网直接访问否则等于把验证逻辑暴露给攻击者反复试探。3.2 客户端拦截器解析令牌并兜底重定向业务系统要做的第一件事是把所有受保护接口的请求都拦下来检查有没有合法令牌。Spring 里实现这个动作最简单的是 HandlerInterceptorpublic class SsoInterceptor implements HandlerInterceptor { private final JwtParser parser; private final String ssoServerUrl; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { String token resolveToken(request); if (token ! null parser.isValid(token)) { // 解析出用户名放入 request 上下文供 Controller 使用 request.setAttribute(currentUser, parser.getSubject(token)); return true; } // 没登录重定向到认证中心登录页并带上当前地址作为回调 String returnUrl request.getRequestURL().toString(); String redirect ssoServerUrl /sso/login?service URLEncoder.encode(returnUrl, UTF-8); response.sendRedirect(redirect); return false; } private String resolveToken(HttpServletRequest request) { // 优先从 header 取兼容 Ajax 场景再退回到 cookie String header request.getHeader(Authorization); if (header ! null header.startsWith(Bearer )) { return header.substring(7); } Cookie[] cookies request.getCookies(); if (cookies ! null) { for (Cookie c : cookies) { if (SSO_TOKEN.equals(c.getName())) { return c.getValue(); } } } return null; } }这段代码里最容易出问题的是returnUrl的处理。request.getRequestURL().toString()拿到的地址可能带查询参数拼接时必须整体编码后再塞进 service 参数不然回调地址里原本的?会和认证中心的参数混在一起。另外一个隐蔽的坑是重定向地址如果业务系统同时提供 HTTP 和 HTTPS 两种访问方式回调地址必须统一到 HTTPS否则令牌会被明文的 HTTP 链路抓走。拦截器配置上/sso/logout和健康检查接口要放行不然登出逻辑自己都进不来。还有前端静态资源图片、CSS 这些通常不拦截拦了只会增加认证中心的压力。放开资源的名单我用一个配置文件维护每加一个公开接口都要走评审避免有人图省事把敏感接口也放进白名单。3.3 单点登出清除全局会话与局部会话JWT 无状态登出是最容易被做糊的一环。很多人以为前端删掉 token 就完事但用户在其他系统里的 token 还活着别人拿那个 token 依然能继续访问。我一般会在认证中心维护一个 Redis 集合存已注销的 jti业务系统的拦截器每次校验时先去查这个集合一旦发现 jti 在里面就算签名验过了也拒绝。PostMapping(/sso/logout) public ResponseEntity? logout(HttpServletRequest request) { String token resolveToken(request); if (token ! null) { Claims claims parser.parse(token); // 把 jti 加入黑名单剩余有效期内的请求全部失效 redisTemplate.opsForSet().add(sso:blacklist: claims.getId(), token); redisTemplate.expire(sso:blacklist: claims.getId(), Duration.ofSeconds(claims.getExpiration().getTime() - System.currentTimeMillis())); } return ResponseEntity.ok(已登出); }这段代码的核心逻辑是把 token 的jtiJWT ID作为唯一标识丢进 Redis 黑名单过期时间对齐 token 本身的剩余有效期。为什么用 jti 而不是直接用 token 字符串当 key因为 token 字符串很长做集合成员比对时内存开销大jti 是一个短 ID教学里容易忽略生产里值得坚持。单点登出还有个跨系统的联动问题。用户从 A 系统退出B 系统怎么同步最省事的方案是让业务系统重定向到认证中心的/sso/logout认证中心把全局会话吊销后再通过异步通知或者前端轮询让所有系统本地清理。依赖消息队列做通知是常见做法但队列延迟会导致一小段时间内令牌依然可用。预算允许的话把业务系统的 token 有效期收紧到 5 分钟加上 refresh token 机制登出后的最坏残留时间就能压到刷新周期以内。4. 登录态泄露排查一个伪装成 sso 的 curl 参数暴露了什么4.1 HTTP Header 与 URL 传 Ticket 的差异搜索引擎里搜单点登录 sso结果页第二屏有一条高频出现的安装命令开头是if [ -f /usr/bin/curl ];then curl -sso https://download.bt.cn/install/install...。看起来很像是SSO 一键安装但-sso并不是什么单点登录协议参数它是 curl 的-s静默模式和-o输出到文件两个参数合写在一起的简写后面的地址是安装脚本整条命令把脚本下载下来直接执行。把这段安全习惯对照到 SSO 上恰恰点中了认证体系最容易泄露的地方你的登录态走了哪一层票据在 HTTP 里的传递位置决定了它的暴露面。放在 URL 查询参数里比如https://app.example.com/sso/callback?ticketST-12345这个地址会被记录在 Web 服务器访问日志、CDN 日志、浏览器历史记录、反向代理日志里还会被页面里的第三方脚本通过 Referer 头带出去。放在 HTTP Header 里Authorization: Bearer ...浏览器的地址栏看不到大多数日志系统默认不会打印 Header暴露面显著变小。放在请求体里传输路径上只有 POST 消息体风险进一步收敛。用哪个位置基本决定了这个 SSO 能不能扛住日志泄露事故。传递位置会被访问日志记录会被浏览器历史记录会出现在 Referer 中适合场景URL 查询参数是是是服务端回验的一次性票据Cookie否否否浏览器会话保持HTTP Header否否否前后端分离的 API 认证请求体否否否服务端到服务端这条对比表背后的原则是任何会长期留存的通道都不放长期凭证。一次性票据走 URL 可以接受因为它两分钟内就用掉了长期令牌如果走 URL等于把钥匙丢在马路上。我见过一个生产事故财务系统的 token 在 URL 里跳转nginx 默认的 access.log 把完整 URL 打了出来运维日志拿去给第三方做监控分析第三方那边一个实习生随手把日志文件发了内部群登录态当场满天飞。4.2 日志与网关层的信息脱敏日志必须做两层防护。第一层是入口处不记录敏感字段第二层是万一记录了要有兜底清洗机制。nginx 的 access log 格式默认记录完整请求行要把它改成不包含查询参数的格式log_format sso_safe {time:$time_local,remote:$remote_addr, method:$request_method,path:$uri,status:$status}; access_log /var/log/nginx/access.log sso_safe;这里的关键是把$request_uri换成$uri因为$request_uri带原始查询参数$uri只保留路径部分。nginx 默认格式里用的就是$request_uri所以很多人并不知道自己的日志一直在完整记录带票据的 URL。Java 应用这边Logback 的 pattern 里不要打印request.getRequestURL()的完整值如果确实需要跟踪某个请求改用自定义 traceId 关联。应用程序打印日志的习惯也要盯。很多团队在拦截器里会顺手加一行log.info(sso callback: {}, request.getRequestURL())方便排障这一行会把正在校验的票据打到日志上。我一般要求这个 logger 必须是脱敏过滤器处理过的结果把ticket、token、code这些参数值直接替换成***。过滤器实现起来不复杂一个 FilterRegistrationBean 挂在所有接口前对 queryString 做一遍正则替换再放行。4.3 加固清单HTTPS、回调白名单与短期票据SSO 的安全基线是 HTTPS这条没有讨价还价的余地。令牌在 HTTP 里传等于明文口令在公网上裸奔加再多签名算法都挡不住抓包。在此基础上我通常会要求交付的 SSO 至少满足下面几条回调地址白名单由认证中心统一维护业务系统注册时登记service前缀认证中心重定向时校验service必须落在白名单前缀内服务票据有效期不超过 30 秒一次性使用验证通过立刻作废刷新令牌走单独的接口且必须校验grant_type防止拿刷新令牌当访问令牌到处用。还有一条经常被忽略的令牌里的用户属性不要放敏感信息。JWT 的 Payload 只是 Base64 编码不是加密任何人拿到令牌都能解出来看。有些实现把手机号和邮箱直接填进 claim这是把用户隐私分发给每一个持有令牌的人。需要用户资料时业务系统用令牌里的sub去用户中心按需拉取而不是把资料塞进令牌里到处复制。5. SSO 落地避坑五条高频踩坑记录与排查清单5.1 票据过期刷新流程没接用户 30 分钟就被踢下线现象业务系统上线后用户每隔半小时左右就被强制重新登录工作完全中断。排查日志发现认证中心签发的令牌确实设置了三十分钟有效期拦截器也在校验有效期逻辑上没问题但前端没有处理令牌刷新。原因认证中心只发了访问令牌没有下发刷新令牌前端也没写静默刷新的逻辑。很多人理解短期令牌更安全却忘了令牌到期后要有替代机制。方案不完整到期时间一到用户只能手点重新登录。解决访问令牌压到 15 分钟同时签发有效期 7 天的 refresh token。前端用 axios 的响应拦截器遇到401且错误码是TOKEN_EXPIRED就调刷新接口换新令牌换成功重发原请求换失败才跳登录页。后端在刷新接口里校验 refresh token 的存在性和过期时间并让新令牌的 jti 与旧令牌不同。5.2 跨域 CookieSameSite 设置把登录态悄悄吞掉现象用户在认证中心登录成功跳回业务系统时依然是未登录状态重定向了一圈又回到登录页形成死循环。F12 看网络面板C 系统的 Set-Cookie 响应头里没有 SSO_TOKEN 这条记录。原因业务系统与认证中心不同域名浏览器收到跨域域名设置的 Set-Cookie在SameSiteLax的默认策略下只有顶层导航的 GET 请求才携带这类 Cookieajax 请求直接不带如果开了SameSiteStrict连顶层导航都不带。浏览器把 Cookie 吞了前端拿不到令牌页面认为未登录。解决最稳的做法是令牌不放在 Cookie 里改用Authorization: Bearer token头由前端从某个统一的内存 store 读取。非走 Cookie 不可的场景把SameSite设为None并同时加上Secure属性Secure要求页面必须在 HTTPS 下否则浏览器会直接拒绝这条 Cookie。很多团队在本地 http 开发时发现明明设置了就是不生效多半就是漏了Secure。5.3 回调地址校验开放重定向比想象的更近现象安全测试报告里出现开放重定向拿着正常票据把service参数改成攻击者控制的域名认证中心仍然放行重定向票据被第三方站点截获。原因认证中心签发 ST 时没有校验service是否属于已注册的回调白名单。这个服务参数完全由 URL 拼接攻击者在用户点击的链接里把service换成自己的地址认证中心只负责带上这个地址把用户送过去就把一次性票据送到了攻击者手里。解决认证中心在登录页展示前和签发 ST 时各做一次校验比对service的参数是否命中白名单前缀。这里不要用字符串contains判断要用 URL 解析后拿 scheme、host、port 做精确匹配。https://app.example.com.evil.com这种域名前者校验方式会直接放行后者解析出的 host 是evil.com一次拦截。做到这一步才算是防住了开放重定向。5.4 时钟偏移令牌 iat/exp 校验在分布式时钟下失真现象用户刚登录成功立刻访问业务系统就收到令牌尚未生效的报错或者令牌明明过期了某台业务服务器依然放行。两拨请求打到不同实例得到相反结果。原因JWT 的iat和exp都是时间戳业务系统在本地校验这两个字段各台服务器系统时间不一致就会得出不同结论。云上环境通常有好几个机房NTP 同步偶尔一次失败偏移数量级就能到分钟级而访问令牌的有效期本身也只有十几分钟误差占比相当大。解决部署基线要求所有实例启用 NTP 并做告警时间偏移超过 5 秒就要纳入告警通知。代码层面给校验器加一个clockSkew容忍窗口常见做法是允许 30 秒的exp延后!claims.getExpiration().before(new Date(System.currentTimeMillis() - 30000))把校验误差让出去而不是把服务器时间卡死。时钟偏移不等于安全隐患但没处理就是线上故障。5.5 共享密钥管理一个 JWT 密钥走天下的代价现象某个业务系统被攻破后攻击者拿泄露的密钥伪造了全网所有系统的登录令牌逐个系统登录进去做了横向移动。事后排查发现所有系统用的都是同一个jwtSecret而且这个密钥以明文形式写在一个共享代码库里。原因无状态令牌的信任根基就是签名密钥。业务系统和认证中心共用一把对称密钥省掉了密钥交换的复杂度代价是密钥一泄露所有系统的信任同时崩塌。共享代码库里明文写密钥等于把信任根打印出来贴在公司走廊里。解决至少做到认证中心用私钥签名业务系统用公钥验签。可以使用非对称算法 RS256认证中心持有私钥业务系统只拿公钥泄露公钥不影响签名。密钥轮换按季度执行轮换时业务系统的公钥列表里同时保留新旧两个公钥保证旧令牌在叠加周期内仍可验证。非对称方案比 HS256 更复杂但正规系统的安全教育就该按这个标准走。6. 四个实测手段验证 SSO 是否真的合格从重放攻击到跨域剔除我交付 SSO 从来不看功能演示只看四组实测通过与否。第一项是重放攻击测试用抓包工具把一个合法令牌原样保存等到令牌过期后再重放接口必须返回 401如果用的是服务票据 ST验证通过后立刻在同一会话内再次提交同一个 ST必须被判定为已使用。这两个动作都通过令牌的时效性和一次性才算合格。第二项是登出边界验证。用户在 A 系统点了登出立刻在另一个浏览器打开 B 系统的受保护接口登录态必须已经被吊销。我一般同时验证两种路径由认证中心发起的主动登出和业务系统本地登出后前端的静默跳转两种情况都不能让旧令牌继续可用。第三项是过期边界。把认证中心配置里的令牌有效期临时调成 60 秒用户登录后等 70 秒再操作观察系统是否按设定踢出、前端刷新逻辑是否接管。很多实现里到期自动续期的代码只存在于后端接口前端没有调这组测试十次有八次能现原型。测试结束后必须把配置调回生产值改配置这步忘了就属于发布事故。第四项是溯源能力。真实操作里我的标准是能看到任意一次业务访问都能在日志链路里找到对应的认证中心会话 ID。做法是在签发令牌时把 jti 写入访问日志的扩展字段业务系统处理请求时把令牌的 jti 透传到自己的日志里两边用同一个 ID 做关联查询。日志能关联起来才谈得上事故追溯。最后附一张验收表这条表我每次都打印出来贴在工位旁边验证项操作预期结果重放攻击原样重放过期令牌401服务票据一次性同一 ST 提交两次第二次拒绝单点登出A 系统登出后 B 系统再访问未登录令牌到期有效期 60 秒等 70 秒后操作自动刷新或踢出日志关联业务日志按 jti 追踪能对应到认证中心会话以前我上线第一套 SSO 时只测了正向流程生产环境第一周就被到期时间不刷新的事故打脸用户投诉工单堆了一整页。从那以后我每次交付 SSO 之前都强制走一遍上面这四个实测全部通过才敢写验收报告。这套验证习惯替我省掉的深夜排障次数比任何框架选型都值钱希望帮到你。本文还有配套的精品资源点击获取
返回列表