做过微服务的人,早晚都会碰到一个绕不开的痛点:业务系统越拆越多,认证登录却越来乱。用户记一堆账号密码,每次换个系统都要重新登录;内部平台明明是一套人马,却要在五六个系统里各登录一次;到了年底盘点,发现每个服务都自己维护用户表、session、密码规则,权限审计更是查无可查。这些问题说到底就是一个字:SSO(单点登录)没有做好。
我所理解的微服务环境下的单点登录,不是简单地把所有系统接入同一个登录页,而是要解决三个层面的问题:身份认证怎么统一、登录状态怎么共享、权限信息怎么在各服务间安全传递。今天这篇内容,就把我在实际项目中落地过的一套SSO方案完整拆开讲透,包括整体设计、认证中心搭建、网关接入、前端对接、安全加固,以及踩过坑才总结出来的排查方法和经验教训。内容偏实战,适合正在做微服务架构改造的后端开发、架构师,以及被“一堆系统登录不过来”逼疯的运维同学参考。
1. 整体设计与方案选型
1.1 先从核心模型说起
不管用什么样的框架和技术栈,SSO的核心模型其实非常固定,就是三个角色的协作:用户、客户端应用、认证中心。用户只和认证中心打交道,提交账号密码或者扫码;认证中心验证通过后,给客户端应用发一个凭证;客户端应用拿着凭证去换取访问令牌,后续带着令牌访问微服务。这个模型不是拍脑袋定的,它是几乎所有成熟SSO方案的底层骨架,CAS、OAuth2、OIDC都跑不出这个大框架。
理解这个模型的关键在于“信任关系”。认证中心是唯一信任源,所有应用都信任认证中心的判定结果。这里最容易犯的错是把用户信息直接放在各个系统里各自维护,还指望SSO能一键打通。实际上SSO只能统一“认证”和“登录状态”,用户基础数据、权限数据这些还是一定要收口到统一账号中心,否则即使登录打通了,各个系统的“张三”到底是同一个人还是同名同姓,你又得头痛。
1.2 常见方案对比
我在选型的时候,基本把主流方案都过了一遍,从最早的票据共享、到CAS、再到目前流行的OAuth2和OIDC,各有适用的场景。下面这张表是我比较常用的判断依据:
| 方案 | 核心机制 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Session共享 | 统一存储Session,如Redis | 实现简单,老项目改造快 | 耦合度高,移动端不友好 | 小规模内部系统 |
| CAS | 票据TGT/ST,服务端集中认证 | 成熟稳定,Java生态友好 | 定制化较重,前后端分离不友好 | 传统校园、企业内部系统 |
| OAuth2授权码模式 | 授权码换令牌,客户端和服务端分离 | 开放标准,生态成熟,适合API场景 | 流程稍复杂,需理解授权模型 | 微服务、开放平台 |
| OIDC | 在OAuth2上增加身份层 | 能拿到标准用户信息,支持JWT | 协议细节多,选型成本略高 | 新项目、多端场景 |
如果只是几个老系统做统一登录,CAS确实是稳妥路线,尤其是已有大量服务端渲染页面的系统,改造量小。但微服务环境下,系统普遍是前后端分离,前端是独立应用,后端服务通过API互相调用,这种情况下OAuth2优势更明显。要是还想顺手把用户身份信息标准化,OIDC会是更现代的补充。但坦白讲,对多数内部微服务系统来说,OAuth2授权码模式加JWT已经足够了,没必要一上来就把OIDC全家桶堆上去。
1.3 我为什么选择OAuth2 + JWT
最终我落地的方案是:OAuth2授权码模式负责“授权流程”,JWT负责“令牌格式”,Redis负责“可控会话状态”,网关负责“统一校验”。这套组合的好处在于三件事能分开治理。
第一,授权流程交给OAuth2,登录交互体验可以统一。用户访问任何一个接入系统,都会被引导到认证中心完成登录,认证中心拿到用户身份后发一个授权码,前端拿授权码去换访问令牌,这个流程对前端是完全透明的。第二,JWT天然适合无状态微服务。用户信息、过期时间、权限标识都可以直接编码在令牌里,资源服务本地验签就能拿到身份,不必每次请求都远程调认证中心问“这个token有没有效”。第三,网关统一校验避免每个服务重复造轮子,服务内部不用关心登录逻辑,专注业务。
这样组合还能应对一个常见问题:一个系统接入时可能只是几个页面要匿名,几个接口要登录,几个接口要管理员权限。如果把校验逻辑散落在各个服务里,权限规则改一处漏三处,而网关统一处理之后,规则集中、变更可控。实际项目中我见过不少团队把JWT当万能药,资源服务各验各的,最后密钥一盘散沙,出问题时连token在哪个环节失效都定位不了。
2. 认证中心设计
2.1 认证中心该有哪些数据
认证中心本质是一个独立服务,它不承载业务,只专注三件事:管理用户身份、管理客户端应用、颁发和吊销令牌。我的数据库设计里最少会包含五类表:用户表、角色权限相关表、客户端应用表、授权记录表、令牌表。很多新手做SSO只建一个用户表和一个token表,结果客户端应用标识不知道存哪、refresh token查不到、授权范围没法控制,后期全是窟窿。
客户端应用表是容易被忽略但极其重要的表,它记录每个接入系统的client_id、client_secret、回调地址、允许的授权范围。回调地址必须在这里登记并做精确匹配,这是防止授权码被第三方截获的关键防线。令牌表则要注意,JWT虽然可以无状态,但为了支持主动下线、强制退出、异常检测,还是得有一份令牌的冗余存储,只是读取上优先本地验签、必要时再查状态,而不是每次请求都查库。
用户从账号中心同步过来之后,密码字段的保存规则也要统一。我见过微服务改造时直接把老系统明文密码倒进新库的项目,这是极端危险的。不管原来怎么存,迁到认证中心后至少要通过BCrypt或scrypt做哈希,密码永不反解、永不写日志。
2.2 授权码模式登录流程拆解
流程看起来复杂,但拆开就四步。第一步,未登录用户访问业务系统前端,前端判断没有token,就重定向到认证中心,拼上client_id、redirect_uri、response_type=code。第二步,认证中心展示登录页,用户输入账号密码,认证通过后认证中心生成一个一次性授权码,重定向回业务系统前端带着code参数。第三步,前端把这个code发给自己的后端,后端在服务端用client_secret去认证中心换token,这个步骤必须放在服务端,因为client_secret不能暴露给浏览器。第四步,业务后端拿到access_token和refresh_token,之后前端请求携带access_token访问微服务。
这里有一个关键细节:授权码是短时效、一次性使用的,通常5分钟有效,使用后立即失效。如果前端反复拿同一个code去换token,认证中心应该直接报错。有些团队在联调时发现偶尔换不到token,排查半天发现是前端在不经意间重复请求了接口,而认证中心又没对code做一次性校验。无论授权码有效期多短,服务端都必须落库记录使用状态,这是标准动作。
授权码模式中还有一个容易被业务方纠结的点:用户信息到底什么时候返回。正确的做法是,认证中心默认只返回身份标识,比如user_id和基本用户名,更详细的资料、角色权限,由业务后端用自己的client_id去用户中心接口获取,或者直接将权限信息编码进token。这样做的好处是认证中心不成为所有业务数据的咽喉,接口压力可控。
2.3 令牌设计与刷新策略
令牌设计直接决定了用户体感和系统安全,我的习惯是双令牌:access_token和refresh_token。access_token有效期短,一般30分钟到2小时;refresh_token有效期长,按业务需要7天到30天。前端访问资源时带access_token,过期后就用refresh_token静默换取新的access_token,用户无感知续期,不用重新登录。
access_token我选JWT格式,payload里至少包含这几个字段:user_id、user_name、client_id(这个token是发给哪个应用的)、scope(授权范围)、exp(过期时间)、jti(唯一ID,查禁用列表用)。注意不能放密码、手机号这些敏感字段,因为JWT默认只是Base64编码,不是加密,明文可读,而且会随着每次请求头传过去,泄露面比较广。真要放敏感信息,至少用JWE做加密,但一般场景完全没必要。
refresh_token则没必要做成JWT,用随机字符串存Redis更合适。原因是refresh_token的使用频率低,必须支持服务端主动吊销,而随机字符串天然可以被撤销。刷新令牌时不能简单地返回同一个refresh_token,要做轮换策略:每次刷新都生成新的refresh_token,旧的立即失效。这样即使refresh_token在某个环节被泄露,攻击者也只能使用很短一段时间。而access_token短时效,能进一步缩小攻击窗口。
3. 微服务网关与资源服务接入
3.1 网关统一认证过滤器
很多微服务项目都会引入Spring Cloud Gateway或APISIX这类网关,SSO落地时最好的位置就是在网关层做统一认证。网关的作用不是自己完成登录,而是拦截请求、校验access_token、把用户身份转换成下游服务能识别的信息。这样做最大的好处是接入新服务时不需要写认证逻辑,服务只管业务。
我落地过的网关校验逻辑大致是这样的:先判断当前请求路径是否需要登录,比如登录接口、健康检查、静态资源直接放行;再判断请求头里有没有Authorization携带access_token,没有就返回401,并携带跳转认证中心的地址;如果有则做JWT验签和过期校验,通过后解析出用户信息,以X-User-Id、X-User-Name这样的标准请求头传给下游服务;最后记录一些审计日志。
要注意的是网关统一认证不能解决所有安全职责。网关只是入口,微服务之间也存在互相调用的可能,服务A调用服务B时可能根本没有走网关。因此服务间调用要单独设计一套信任机制,例如内部服务用mTLS,或者服务B校验调用方来源IP和服务名。不能因为网关校验了,下游就完全不设防。
3.2 用户信息在下游怎么传递和消费
网关校验完JWT后,不能把原始token直接转发给所有下游,那样每个服务都得解析一遍JWT,重复劳动且性能浪费。更好的做法是网关把token解析结果转换为规范化的用户上下文,通过HTTP Header传下去。Header的命名要统一,比如X-User-Id、X-User-Name、X-Client-Id,最好在内部文档里固定下来,避免不同服务各叫各的。
这条设计还有一个隐性好处:引入一种轻量的“内部身份”机制。下游服务只要信任网关传递过来的Header即可,不需要关心用户到底是从OAuth2流程来的,还是管理员在后端模拟的,还是定时任务触发的。我在项目里会刻意区分X-User-Id(真实用户)和X-User-Name(操作者),避免内部定时任务调用业务接口时,因为缺少用户信息导致NPE满天飞。
但Header传递也有风险,最典型的是客户端伪造Header。比如外部请求直接自己带一个X-User-Id=admin发到网关,如果网关不够严密,这个伪造头就会穿透到下游。所以网关在接收到请求时,必须先把请求里的X-User-Id、X-User-Name等内部Header全部清除,再从JWT重新生成,绝不能让客户端控制内部身份字段。这个清除逻辑放在过滤器最前面,属于底线策略。
3.3 JWT本地验签还是远程校验
资源服务拿到用户上下文之后,还要不要自己再验一遍token,这是架构设计中一个常见争论。我的观点是:外部请求必须经过网关校验,但内部服务之间的请求,不能只依赖网关。原因在于微服务调用中可能经过消息队列、定时任务、等等异步链路,这些场景根本没有网关参与。
服务之间需要校验调用方身份时,我更推荐本地JWT验签方案。每个服务通过Nacos或配置中心共享同一个公钥,本地解析和验证JWT签名,不依赖认证中心在线。这样既避免每次请求都远程调用认证中心的高延迟和单点压力,又能保证服务调用链路上的身份可信。
不过本地验签也有前提条件:必须保证密钥分发安全,公钥放配置中心,私钥在认证中心妥善保管。若发现私钥泄露,需要立即轮换密钥,并且要能容忍旧token的短暂失效期。这个场景下Redis的密钥版本号机制就很有用,token里带上kid(密钥ID),服务端用kid选择对应的公钥进行验签,轮换时增加新密钥标号即可平滑过渡,不会导致所有用户瞬间被踢下线。
4. 客户端应用接入与前端兼容
4.1 前端跳转登录的回调链路
客户端应用接入时,前端是整个SSO体验的“脸面”,跳转逻辑写不好用户就会觉得“这系统怎么回事,一下就白屏了”。以一个Vue或React单页应用为例,最基础的做法是在前端路由守卫里判断本地的access_token是否存在,如果不存在就重定向到认证中心的登录地址,带上client_id和redirect_uri,redirect_uri指回当前页面。
认证中心登录成功后回调到redirect_uri,并且带上?code=xxx参数。前端这时候有两种处理方式。一种是前端直接拿code去自己的后端接口换token,这也是我推荐的方式,因为前后端分离下,client_secret只能在后端持有。另一种是前端通过隐藏在页面里的iframe对code复用,完成多系统同步登录,这种方式对第三方Cookie依赖太强,现在主流浏览器已经逐渐放弃第三方Cookie,我不再推荐。
换到token之后就不要在URL上再带任何敏感信息了,前端要立即清理URL里的code参数,用history.replaceState把地址去掉。很多白屏问题就出在用户收藏夹里存了带code的地址,下次访问时code已过期,前端又没处理这个分支,直接白屏。
4.2 移动端内置浏览器SSO登录白屏问题
这里特别想聊一个我实际遇到过、也在各种社区被反复提问的场景:移动端App内置浏览器里访问门户,跳SSO登录后,登录成功了但页面一直白屏,怎么调试都没有报错。这类问题大概率是“往返跳转”被内置浏览器拦了,或者Cookie被限制导致会话无法衔接。
典型原因有两种。第一种,内置浏览器把跳转当成第三方页面,禁用了Cookie,SSO登录页写入的会话Cookie丢失,登录完回到业务系统又变成未登录。第二种,链路上的重定向次数太多,内置浏览器出于安全策略直接拦截。排查这类问题不能只在PC端浏览器试,一定要拿真机、用内置浏览器开远程调试,看网络请求、看Cookie是否写入、看跳转链条是否断在某一步。
我最终采用的解决方案很土但很有效:登录回调地址用顶层页面跳转,而不是用iframe嵌套;Cookie设置里把SameSite设为Lax或None(还需加上Secure),同时把认证中心域名和业务域名尽量收敛到同一个主域名下,用域内Cookie做会话保持。还有一个补充手段是在登录成功后由前端主动通过JS轮询一次会话状态,如果检测到会话丢失就自动再跳一次登录,比让用户卡在白屏上强。
4.3 单点登出流程
单点登录做得再顺,单点登出如果忽略,用户照样会骂人。登出的核心是“一处退出,处处退出”。最简单的方案是认证中心提供一个全局登出接口,客户端调用它去清除认证中心的会话,同时把该用户签发过的token标记为失效。
问题在于微服务下,token可能分散在各个资源服务本地缓存里,认证中心没法实时通知所有服务。可行的处理方式是双管齐下:一是认证中心维护一个黑名单,里面记录被强制退出的token的jti;二是网关校验token时除了验签和过期时间,还要查一下黑名单。这个黑名单可以放在Redis里并设置过期时间,避免数据无限膨胀。为了性能考虑,网关可以本地缓存一份黑名单,比如30秒同步一次,牺牲极短时间的延迟,换来更好的并发性能。
真正严格的全端登出,还需要接入系统的前端去清理本地的token,并同步跳转到认证中心的登出地址。这一步不要指望纯后端能完成,否则用户点击退出后,本地localStorage里的旧token仍然存在,下次页面刷新又自动带上,造成“明明点了退出,刷新又变成登录状态”的怪异体验。
5. 安全加固与高可用落地
5.1 令牌存储与XSS、CSRF防护
SSO做得越多,越要明白一个道理:登录入口越集中,攻击价值就越高。保护令牌和凭证是第一优先级。浏览器端存储access_token,我推荐的方式是内存变量加localStorage辅助,而不是直接塞Cookie。如果放Cookie,就必须严格设置HttpOnly、Secure、SameSite属性,避免被脚本读取。不过OAuth2流程下,前端换取token后更常见的还是localStorage。
LocalStorage的缺点是无法防XSS攻击,一旦页面被注入恶意脚本,token会被直接偷走。因此接入SSO的前端项目必须做基础的XSS防护,比如对用户输入输出做转义、不信任任何富文本内容、使用CSP等。后端要防的则是CSRF,在授权码模式下因为token是放在Authorization头里而不是自动携带的Cookie,天然能防御大部分CSRF,但仍要避免使用Cookie来保存token的旧式做法。
还有一个容易被忽视的安全点是回调地址校验。认证中心在接收授权码请求时,必须严格比对redirect_uri和客户端应用注册时填写的地址,不能只判断域名前缀一致,否则攻击者可以构造一个同域但不同路径的地址,把授权码带到自己控制的页面。精确匹配回调地址是OAuth2协议明确要求的动作,不要因为联调麻烦就去掉。
5.2 认证中心的高可用设计
认证中心是整个微服务体系的“命门”,它挂了,所有依赖SSO的系统都登录不了。因此认证中心不能当作普通业务服务一样只部署单节点,至少要保证多实例部署、负载体均衡。但多实例会带来一个新问题:用户登录成功后如果落在实例A,下一次请求被负载均衡转到实例B,实例B没有用户的会话怎么办。
解决方案依然是用Redis做会话共享。登录态统一存Redis,upstream的实例都能读到。授权码、refresh_token、黑名单这些数据天然都应该在Redis里,认证中心实例本身要做到尽可能无状态。我还会给认证中心单独配置一个Redis集群,和业务缓存隔离,避免业务系统高峰期的缓存击穿把认证中心拖垮。
另外一个高可用细节是限流和降级。认证中心最容易被打爆的是两个接口:登录接口和换token接口。建议用Sentinel或网关层限流对这两个接口做定向限制,比如单IP每秒最多几次登录尝试,全局限流策略按容量设定。登录接口还要做防暴力破解,连续失败N次锁定账号一段时间,或者引入人机校验。这些措施要在早期设计,否则线上被刷了才加,用户已经被折磨完了。
5.3 网关与认证中心的故障隔离
即使认证中心挂了,已经登录过的用户能不能继续访问业务系统,这是一个需要提前决策的问题。从体验角度讲,access_token的有效期内,资源服务本地验签完全不需要访问认证中心,所以已登录用户的请求不受影响,这是JWT方案相对中心化Session方案的一个天然优势。
但refresh_token刷新接口还是依赖认证中心,如果认证中心宕机,用户token过期后就无法续期,会被迫重新登录。可以接受的降级方案是网关在认证中心不可用期间,临时放行一部分低频操作,或者返回明确的错误提示,让用户知道是SSO系统在维护而不是业务系统坏了。千万不要让业务系统在认证中心不可用时也连锁崩掉,体验雪崩比登录失败更可怕。
实践中我会给网关的认证中心调用配置超时和熔断,比如调用刷新接口超过1秒就放弃本次刷新,返回401并提示稍后重试,而不是无限制地重试,把认证中心压垮。服务之间的调用超时设置是微服务高可用里最常见的细节,但也是最容易被SSO方案忽视的。
6. 常见问题与排查技巧实录
6.1 登录后访问接口一直401
这是我被问得最多的问题。登录成功、token也拿到了,但访问微服务接口还是401。首先打开浏览器的Network面板,看实际请求里是否带上了Authorization头。很多前端项目只在登录接口里写了token,其他请求的拦截器没配,导致后续请求没有带token。其次看JWT是否过期,如果拿到token后很久才发请求,exp已过,网关当然拒绝。
还有一种隐蔽情况是网关验签用的公钥和认证中心生成JWT的私钥不匹配。尤其是多环境多配置中心时,环境A的微服务用的是环境B的公钥,签名验不过去,接口就一路401。排查方法很简单:用认证中心签发的token,在网关本地用当前公钥手动验一次签名,能验过再查下游,验不过直接怀疑密钥配置。
6.2 出现无限重定向循环
SSO接入后最常见的糟糕体验是:访问业务系统,跳到认证中心登录,登录成功后回到业务系统,又检测到未登录,再次跳认证中心……如此循环,用户直接崩溃。这个问题通常出在Cookie或token的写入与读取不一致。前端把token存进去了,但路由守卫里读取用的key不一致;或者认证中心的会话Cookie没有在业务域名下生效,导致每次回调都感觉是新会话。
无限重定向还有一个高发原因:业务系统前端自己维护了一个“本地登录状态”,它不是以token为准,而是以是否从认证中心跳转回来为准。一旦回调链路中某个环节被浏览器拦截,比如第三方Cookie被禁,前端就一直认为自己没登录,反复触发跳转。此时不要在前端继续加状态判断,先从浏览器是否正常写入会话Cookie入手排查。
6.3 第三方Cookie被禁导致的白屏
前面提到过移动端内置浏览器的白屏,其实在PC端的Chrome、Safari上也会出现。很多老旧的SSO方案喜欢用iframe + Cookie的方式在多个域名之间同步登录态,但浏览器对第三方Cookie的限制越来越严,这种方案会逐渐失效。遇到白屏时,先看开发者工具里有没有关于Cookie的警告,再检查认证中心写Cookie时有没有设置SameSite=None和Secure。
如果你不想把认证中心和业务系统收敛到同一个主域名,也不想动Cookie策略,那就老老实实走标准的页面重定向流程。页面跳转不受第三方Cookie限制,每次登录都让认证中心发一个一次性授权码,业务系统拿code换token,体验上慢几百毫秒,但胜在稳定和兼容。在浏览器隐私策略不断收紧的当下,重定向换code是更值得投的方案。
6.4 网关内网服务互相调用时丢用户信息
外部请求走网关没问题,但微服务内部互相调用时,服务A调服务B,B拿不到用户上下文。这个问题的根因是网关只在入口处解析了一次token,服务A内部继续调用服务B时,不会自动携带X-User-Id这些Header。
我的做法是封装一个内部调用工具,它可以从当前线程上下文里取出用户信息,然后覆盖到HTTP请求头里,保证用户上下文在调用链上传递。这里要特别小心覆盖逻辑,发起调用前先清空目标请求里已有的X-User-ID等内部Header,再写入当前上下文的值,防止恶意标记或旧数据残留。如果链路特别长,服务A调B、B调C,每一跳都要传递,这时候可以考虑引入链路追踪来观察Header的流向,避免排查时两眼一抹黑。
7. 个人实操中的最终体会
说句实在话,SSO方案本身没有多少新技术含量,真正磨人的是把它落地到真实业务和时间线里。我在多个项目里做过“将多个独立的若依系统改造为统一单点登录”的类似事情,每次改造最先要说服的不是技术团队,而是业务方:为什么要等登录中心建好才能动工?为什么要统一账号体系?因为SSO一定是一个“先收口、再放开”的过程,账号不统一、客户端应用不登记清楚,接入再多系统都是表面功夫。
如果让我给一个最小可行建议,那就是不要刚开始就追求大而全。先做一个认证中心,支持一个业务系统接入,把授权码流程、网关校验、刷新令牌、登出这个完整闭环跑通,再复制到第二个、第三个系统。中间用到Nacos做配置和发现、knife4j做调试、Sentinel做限流都可以,但核心永远是那套稳定的协议和持续维护的信任关系。方案是死的,踩坑是必经之路,代码可以找一个简洁的开源项目参考,但安全和兼容性的细节,必须靠你自己在真实浏览器、真实设备上一个个验证。
最后再分享一个经验:每次踩完坑,把问题记录成一份按现象索引的排查手册,比看十遍官方文档都管用。下面几个问题建议你单独记录下来:授权码没一次性校验、回调地址没精确匹配、网关没有清空外部Header、Cookie的SameSite设置错误、认证中心私钥和资源服务公钥不一致。这些坑我已经替你先踩了一遍,你项目里大概率也会遇到其中一两个。到时候翻到这一节,能帮你少折腾几小时,也算这篇长文没白写。