
你有没有遇到过这种尴尬公司内部同时跑着OA、CRM、工单系统、项目管理平台每个系统都有自己的登录页账号密码还不一样。早上上班先挨个登录一遍密码忘了去重置重置完回来又忘了另一个。这不是你记性差是系统架构在偷懒。单点登录Single Sign-On简称SSO就是专门治这个病的一次登录全网通行。你只需要在认证中心登录一次其他所有接入的系统都自动识别你的身份不用重复输入账号密码。这篇文章不是给你灌概念而是从Java实现的视角把SSO的完整链路拆开揉碎讲清楚核心原理、常见方案选型、落地实操步骤以及我在实际项目中踩过的坑。不管你是个刚接触SSO的新手还是已经在做接入开发的工程师这篇文章都能帮你少走弯路。1. 单点登录的核心会话、票据与信任传递要理解SSO先要理解它解决的问题本质。传统的单系统登录逻辑很简单用户输入账号密码服务端校验通过后创建一个Session把Session ID写入浏览器Cookie。之后每次请求带上Cookie服务端一查Session就知道是谁。但到了多系统场景这套逻辑就失灵了。每个系统各自维护一份SessionA系统登录了B系统完全不知情。SSO的思路是把“登录”这个动作从各个业务系统里剥离出来统一交给一个独立的认证中心处理。所有业务系统不再自己校验账号密码而是信任认证中心给出的“登录凭证”。1.1 一个比喻帮你理解SSO的信任模型打个比方SSO就像小区大门门禁。你进了小区大门认证中心大门给了你一张门禁卡Ticket/Token。小区里的每栋楼业务系统都认这张卡因为所有楼的门禁系统都从同一个物业中心信任源获取权限数据。你没有在每个楼门口重新登记但每栋楼都知道“这人是小区的业主可以进”。这个模型里有两个关键点。第一所有业务系统必须统一信任认证中心而不是各自为政。第二登录凭证必须在各系统间可验证。如果每栋楼还要自己打电话给物业核实卡的真实性体验就会大打折扣。1.2 会话的三种状态与流转路径实际落地时SSO涉及三个层级的会话认证中心会话用户与认证中心之间的登录态通常以CookieSession或Token形式存在。客户端会话单个业务系统的本地会话用于维持用户在该系统内的操作状态。Token凭证认证中心签发的临时凭证用于在业务系统之间传递身份信息。一次完整的SSO流程大致是这样用户访问系统A发现未登录跳转到认证中心。认证中心检查用户是否已有全局会话——如果有直接签发Token并跳回系统A如果没有展示登录页校验通过后建立全局会话再签发Token。系统A拿着Token去认证中心验证或本地验证验证通过后建立本地会话。用户接着访问系统B系统B发现未登录同样跳转到认证中心——这次认证中心发现用户已有全局会话直接签发Token给系统B用户全程无感知。这就是“一次登录处处通行”的完整路径。2. 常见SSO方案对比从CAS到OAuth2再到JWT方案没有绝对的好坏只有适不适合。我在实际项目里用过CAS、OAuth2、以及基于JWT自研的轻量级方案各自的适用场景差别很大。2.1 CAS传统企业级应用的首选CASCentral Authentication Service是最经典的SSO协议Apereo基金会维护的开源项目。它的核心角色有三个CAS Server认证中心、CAS Client业务系统接入端、以及用户浏览器。CAS的流程比较重。用户访问业务系统CAS Client检测到未登录重定向到CAS Server的登录页。用户登录后CAS Server生成一个TGTTicket Granting Ticket存自己的会话里同时生成一个STService Ticket拼在回调URL后面跳回业务系统。业务系统拿着ST去CAS Server的后台接口验证验证通过才建立本地会话。CAS有个很实用的功能叫票据超时管理。TGT可以设置滑动过期和绝对过期两种策略滑动过期是“只要用户持续操作就不过期”绝对过期是“到了时间不管有没有操作都失效”。企业内部门户这种场景特别吃这套员工一整天都在各个系统间切换滑动过期体验最好。但我实际用下来发现CAS的缺点是协议复杂需要部署和维护独立的Server对开发团队的运维能力有要求。不过如果你接手的是传统企业项目CAS依然是最稳的选择。2.2 OAuth2适合开放平台与第三方授权OAuth2严格来说是授权协议不是认证协议但它的授权码模式经常被拿来当SSO用。最典型的场景是“使用微信/支付宝登录”第三方网站这就是OAuth2的SSO应用。在Java生态里Spring Authorization Server前身是Spring Security OAuth2可以快速搭建一个OAuth2认证服务器。接入方只需要在认证服务器注册一个Client ID和Client Secret然后引导用户跳转到授权页。用户授权后认证服务器回调带有授权码接入方用授权码换Access Token。OAuth2做SSO的好处是标准成熟生态完善适合有多方外部系统接入的场景。比如一个平台要开放API给合作伙伴合作伙伴的系统需要调用平台数据OAuth2天然适合。缺点就是重协议细节多非标准场景处理起来比较折腾。自己实现授权码模式要处理的状态多回调地址的校验、授权码的一次性使用、Token的过期刷新每一环都是坑。2.3 JWT自研轻量灵活适合服务间认证JWTJSON Web Token本身不是SSO协议但它提供了一种无状态的Token签发和验证机制非常适合自研轻量级SSO。JWT的结构是三段式Header头部说明算法和类型、Payload载荷存放用户信息和过期时间、Signature签名用私钥对前两段加密。特点是不需要服务端保存Session签发方用私钥签名验证方用公钥验签就能确定Token是否合法、是否被篡改。基于JWT做SSO的思路是认证中心负责签发JWT业务系统配好公钥每次收到请求先验签再检查过期时间解析出用户身份。省去了每次回调认证中心验证票据的网络请求性能好部署也简单。我在内部工具链项目里用过这个方案十几个后端服务同时接入效果很理想。JWT方案最怕的是密钥管理混乱私钥一旦泄露整个系统的信任链就崩了。密钥的轮换机制必须提前设计好。2.4 方案选型的决策清单维度CASOAuth2JWT自研协议复杂度高高低部署成本需独立Server需授权服务器仅需密钥管理适用场景企业内部多系统第三方平台接入内部服务集群前后端分离支持一般好最好运维要求高中低生态成熟度老牌成熟标准成熟依赖自身实现我的建议是内部系统多、团队运维能力强选CAS需要对接外部第三方选OAuth2前后端分离为主、想要快速落地JWT自研最省心。另外这几个方案不互斥CAS可以用OAuth2做外部扩展JWT可以作为CAS签发票据的格式。架构上灵活一点后期扩展会省很多事。3. Java实现JWT单点登录的完整流程下面用Spring Boot Spring Security JJWT举例分享一套可直接落地的JWT单点登录实现。这个方案我已经在多个项目里验证过性能和安全性都能兼顾。3.1 项目结构与依赖准备先创建一个认证中心服务auth-server和一个业务系统服务biz-service两个都是独立的Spring Boot应用。认证中心负责登录、签发JWT、管理密钥业务系统只做JWT验签和用户身份解析。Maven依赖方面核心就三个dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-api/artifactId version0.11.5/version /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-impl/artifactId version0.11.5/version scoperuntime/scope /dependency dependency groupIdio.jsonwebtoken/groupId artifactIdjjwt-jackson/artifactId version0.11.5/version scoperuntime/scope /dependencyJJWT库分成了api、impl、jackson三个模块api是编译期依赖impl和jackson是运行时依赖。这个设计的好处是把核心API和实现解耦换底层实现不需要改代码。新手容易漏掉runtime依赖跑起来直接NoClassDefFoundError提前加上免得折腾。3.2 认证中心密钥管理与JWT签发密钥管理是JWT SSO的命门。我用RSA非对称加密认证中心持有私钥签名业务系统只拿公钥验签。私钥绝对不能下发到业务系统否则任何一个业务系统被攻破整个SSO体系都完了。生成RSA密钥对写个工具类加载Component public class RsaKeyManager { private PrivateKey privateKey; private PublicKey publicKey; PostConstruct public void init() throws IOException { // 生产环境请从配置中心或KMS加载不要打包进jar String privateKeyStr loadFromSecureStore(rsa/private_key.pem); String publicKeyStr loadFromSecureStore(rsa/public_key.pem); byte[] privateBytes Base64.getDecoder().decode(privateKeyStr); byte[] publicBytes Base64.getDecoder().decode(publicKeyStr); PKCS8EncodedKeySpec privateSpec new PKCS8EncodedKeySpec(privateBytes); X509EncodedKeySpec publicSpec new X509EncodedKeySpec(publicBytes); KeyFactory keyFactory KeyFactory.getInstance(RSA); this.privateKey keyFactory.generatePrivate(privateSpec); this.publicKey keyFactory.generatePublic(publicSpec); } public PrivateKey getPrivateKey() { return privateKey; } public PublicKey getPublicKey() { return publicKey; } }这一段有讲究。PKCS8EncodedKeySpec是用来解析PKCS#8格式的私钥绝大多数RSA私钥导出都是这个格式。公钥是X.509格式。如果格式对不上会报InvalidKeySpecException排查时先确认自己手里的密钥文件是什么编码格式。生产环境私钥别放jar包里从环境变量、配置中心或者KMS拉取这一条我希望你能记牢。登录接口的核心逻辑如下RestController RequestMapping(/auth) public class AuthController { Autowired private RsaKeyManager rsaKeyManager; Autowired private UserService userService; PostMapping(/login) public ResponseEntityLoginResponse login(RequestBody LoginRequest request) { // 1. 校验用户名密码 User user userService.authenticate(request.getUsername(), request.getPassword()); if (user null) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } // 2. 生成JWT String jwt generateToken(user); // 3. 返回Token return ResponseEntity.ok(new LoginResponse(jwt)); } private String generateToken(User user) { Instant now Instant.now(); Instant expiry now.plus(2, ChronoUnit.HOURS); return Jwts.builder() .setSubject(user.getId().toString()) .claim(username, user.getUsername()) .claim(roles, user.getRoles()) .setIssuer(auth-server) .setIssuedAt(Date.from(now)) .setExpiration(Date.from(expiry)) .signWith(rsaKeyManager.getPrivateKey(), SignatureAlgorithm.RS256) .compact(); } }JWT签发有几个细节值得注意。Payload里不要放敏感信息手机号、身份证号、紧急联系方式这些都别放JWT默认只是Base64编码任何拿到Token的人都能解码看Payload加密才是防读取的手段。过期时间要控制好2小时是内网系统的合理范围太短影响体验太长增加泄露风险。issuer字段要统一业务系统验签时校验issuer可以防止别人伪造认证中心签发的Token。3.3 业务系统无状态验签与拦截实现业务系统不存Session拿到JWT直接验签。先配置一个拦截器或者Spring Security的OncePerRequestFilterComponent public class JwtAuthenticationFilter extends OncePerRequestFilter { Autowired private PublicKey publicKey; Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String token resolveToken(request); if (token ! null validateToken(token)) { Claims claims parseToken(token); // 把用户信息放入SecurityContext UsernamePasswordAuthenticationToken authentication new UsernamePasswordAuthenticationToken(claims.getSubject(), null, authorities); SecurityContextHolder.getContext().setAuthentication(authentication); } filterChain.doFilter(request, response); } private String resolveToken(HttpServletRequest request) { String bearerToken request.getHeader(Authorization); if (bearerToken ! null bearerToken.startsWith(Bearer )) { return bearerToken.substring(7); } return null; } private boolean validateToken(String token) { try { Jwts.parserBuilder() .setSigningKey(publicKey) .requireIssuer(auth-server) .build() .parseClaimsJws(token); return true; } catch (JwtException | IllegalArgumentException e) { return false; } } }resolveToken里Bearer后面有个空格JWT Bearer Scheme的标准格式就是“Bearer 空格 Token”很多人写成“BearerToken”这种错误格式服务端怎么解析都拿不到Token。验签失败别区分具体原因统一返回未认证就行不要把“Token过期”和“签名错误”返回给前端细节暴露越少越安全。3.4 前端与跨域处理前端拿到Token后常见的存储方式有localStorage和内存两种。localStorage能持久化但存在XSS风险内存存储安全性高刷新页面就丢了。我的建议是折中方案Token放内存同时用HttpOnly Cookie存一个refreshToken用于无感续期。这样主Token不落本地存储XSS偷不到刷新页面又能通过refreshToken拉新Token体验和安全性都兼顾。跨域是前后端分离的必经之坑。业务系统要配置CORS允许认证中心的域名访问。如果是Cookie方案的跨域Cookie必须设置SameSiteNone和Secure属性否则浏览器会直接拦截。Chrome从80版本起默认阻止跨域携带Cookie这一步容易漏但后果非常大——用户明明登录了跳转回来却发现还是未登录排查半天发现是浏览器把Cookie吃了。3.5 无感知刷新与Token续期JWT过期是另一个绕不开的问题。2小时过期用户正在填一个长表单Token突然失效提交时被踢回登录页表单内容全丢了这种体验能让人直接骂街。实现思路是加一个refreshToken接口PostMapping(/refresh) public ResponseEntityLoginResponse refresh(RequestBody RefreshRequest request) { String refreshToken request.getRefreshToken(); try { // 校验refreshToken Claims claims Jwts.parserBuilder() .setSigningKey(rsaKeyManager.getPrivateKey()) .build() .parseClaimsJws(refreshToken) .getBody(); // 检查refreshToken的type是否为refresh if (!refresh.equals(claims.get(tokenType, String.class))) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } // 重新签发accessToken String newToken generateToken(userService.getUserById(claims.getSubject())); return ResponseEntity.ok(new LoginResponse(newToken)); } catch (JwtException e) { return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); } }refreshToken用专门标识区分可以和accessToken用同一套签发逻辑但要在claim里加tokenType区分防止有人拿refreshToken当accessToken用。refreshToken的有效期可以设长一些比如7天但它必须只能走刷新接口不走业务接口。前端在拦截器里检测到accessToken过期先调刷新接口拿新Token拿到后重放原请求全程用户无感知。4. 单点登录的安全加固与性能优化SSO是身份认证的第一道关卡安全设计不能只看“能跑就行”。我在实际代码审查里经常碰到一些基础问题逐个说说。4.1 登录态安全的关键防线第一登录接口必须有防暴力破解机制。最简单的做法是记录IP和用户名的失败次数超过阈值锁定一段时间。用Spring Cache做个计数器5分钟失败5次锁10分钟够应对大部分场景。Redis做分布式限流更靠谱多实例部署时才不会因为本地内存各数各的导致防线失效。第二密码传输必须走HTTPS。JWT Token传输也一样。抓包工具一抓明文密码或者Token裸奔在HTTP流量里等于把大门钥匙挂在了门口。所有登录和Token相关的接口强制HTTPS没有商量余地。第三Token也要支持服务端吊销。JWT无状态是优点也是缺点用户修改密码或者被踢下线Token在过期前依然有效。一个缓解方案是设置一个Redis黑名单记录被吊销Token的jtiJWT ID业务系统验签时先查一下黑名单。这牺牲了一点无状态性但换来了可控性。4.2 密钥轮换与多环境管理JWT私钥泄漏是灾难性事件轮换机制必须提前规划。我的做法是JWT的kidKey ID字段标识当前使用的密钥版本。签发时带上kid验签时根据kid选公钥。轮换时新密钥先在认证中心生效业务系统的公钥配置中心热更新两个密钥并存一段时间等旧密钥签发的Token全部过期后再下线旧密钥。业务系统多环境部署dev、staging、prod时每个环境的密钥要独立。不管是用Nacos还是Apollo配密钥按环境隔离是底线。我见过把生产私钥配置在测试环境配置文件里的这属于安全事故不是操作失误。4.3 性能优化验签的并发瓶颈JWT验签是CPU密集型操作RS256验签每次要做大数模幂运算。业务系统在高峰期每秒上千次验签性能会有一点压力。优化方案有两个方向。第一引入本地缓存。验签结果可以缓存一段时间同一个Token在短时间内重复请求不用反复验签。但缓存时间要短比如5分钟避免Token吊销后缓存期内的请求还能通过校验。第二考虑EC算法替代RSA。ES256ECDSA with P-256的验签性能和密钥长度都优于RSA生成的Token也更短。不过EC算法的实现细节多很多踩坑点如果没有把握先用RSA性能瓶颈真出现了再考虑切换。5. 接入SSO过程中的高频报错与排查指南最后这部分把我在实战中常遇到的报错和排查思路整理成一份速查表读者遇到同类问题可以直接按图索骥省去绕弯子的时间。5.1 高频问题速查表问题现象可能原因解决方案回调地址报redirect_uri不匹配认证中心注册的地址和实际回调地址不一致检查末尾斜杠、大小写、端口号必须完全一致登录后跳转回业务系统仍提示未登录Cookie被浏览器拦截或HttpOnly属性配置错误检查SameSite和Secure配置确认跨域域名Token验签通过但用户信息为空claim名称写错大小写不一致检查签发和解析两端的claim名称定义403 Forbidden而非401认证通过但权限不足检查Spring Security的authorities配置确认角色字段正确映射刷新Token时提示Invalid Token没有对refreshToken做类型校验误用了业务Token在claim中增加tokenType标识并校验业务系统集群部署后登录状态无法保持本地Session存储没有做Session共享或全部切换为JWT无状态模式统一改为Token模式或引入Redis共享Session用户改了密码旧Token依然有效JWT无状态特性服务端无法感知密码变更引入Token黑名单机制密码变更后注销旧Token5.2 排查思路实录有一次线上反馈用户频繁掉线排查半天最终发现问题出在Token长度上。我们用的是RS256签名的JWT单Token有几百字节从认证中心跳回业务系统时URL承载不下被服务器截断了。解决方案是把Token放到POST body里回传而不是拼在URL参数上。这个坑比较隐蔽建议从一开始就用POST方式传递Token。另一次是业务系统在高峰期出现大量401错误。查日志发现不是Token过期是业务系统重启导致SecurityContext里的公钥信息丢失。原因是我们把公钥放在内存缓存没有从配置中心重新拉取。后来加了启动时强制加载的逻辑才解决。这些问题的共性规律是协议细节和环境状态比代码逻辑更容易出问题。排查时先看报文流转确认Token有没有被完整传递再看验签环境最后看代码逻辑。我的最终体会单点登录做了几年最大的感悟是技术上每个环节都有对应方案难的是方案之间的取舍。CAS最稳定但重OAuth2最标准但复杂JWT最轻但安全责任全在自己肩上。没有银弹只有结合自己团队的实际场景去选型和落地。如果你刚开始接触SSO先从JWT方案入手是最快的路径——依赖少、代码量小、原理直观跑通后你对Token流转、验签逻辑会有清晰体感。跑通再回头去看CAS的源码理解TGT和ST的交互难度就降下来了。最后再补充一个实操细节登录页面一定要有个“记住我”的选项这个功能看似简单但实现时涉及refreshToken有效期动态调整能把这个细节处理好你的SSO登录体验基本就可以说练出来了。