
从单体应用拆成微服务的那段日子我想很多人都有过这种体验每个服务都要做一套自己的登录校验用户在一个页面登录完跳转到另一个功能模块又要重新登录体验割裂得不行。我当时接手某电商平台的用户中心重构就面临过这种尴尬——订单服务和库存服务各自维护Session前端调用两个接口得带两个不同的登录态。后来我把登录体系收拢成单点登录,基于Spring Security OAuth2和JWT重新设计了一套微服务认证方案。这篇就完整梳理一下当时的落地方案包括认证中心、网关统一鉴权、资源服务的密钥校验以及最容易被忽略的单点退出闭环希望能给正在折腾微服务登录体系的同学一些参考。1. 为什么微服务拆分后登录会变成老大难问题单体时代登录态一般挂在Session里所有请求打到同一个应用按SessionId查一下Redis或者内存Session就能定位到用户。但微服务化之后请求会被网关分发到不同的服务节点Session归属哪个服务、怎么共享、怎么做到一次登录处处通行这些问题一下子全冒出来了。1.1 共享Session方案的三个致命伤很多团队的第一反应是把Session往Redis里一放所有服务都去Redis查。这个思路乍看成立但实际走一圈就会发现问题业务服务强依赖Redis的可用性Redis一抖动所有登录态一起失效线上必炸。每个服务的代码都要感知Session存储结构跟业务逻辑严重耦合。订单服务本来只想拿到当前用户ID却不得不在业务代码里写Session解析逻辑。跨域场景很难处理。浏览器对不同域名的Cookie隔离策略决定了Session方案天然跨不了域前端域名一多就穿帮。Session共享这条路架构上不是不能走但它把“登录态识别”这个通用能力压到了业务服务内部越往后维护越累。1.2 无状态Token为什么更适合微服务后来我们切换成了无状态Token方案核心思路是认证中心签发TokenToken本身携带用户身份信息服务端不保存状态只要拿着Token就能验证身份。这里面JWT是应用最广的载体。JWT是一个三段的字符串Header、Payload、Signature。Header指定签名算法Payload里放用户ID、用户名、过期时间这类业务声明Signature则用密钥对前两段签名防篡改。资源服务拿到JWT后用约定的密钥算一遍签名验证通过就直接信任Payload里的用户身份整个过程不需要查共享存储。跟我一起参与重构的同事A当时说得很形象Session方案好像是给每个用户发一张带照片的会员卡但商场每个保安都要打电话回总部核实卡片是否有效JWT方案则是商场在门口统一验一次章进到内部各楼层商户只看章是真的就直接放行不再每次打电话确认。这个类比特别能说明无状态Token在微服务下的价值——业务服务不需要再回源查询会话状态减少了一次分布式调用也把鉴权逻辑跟业务彻底解耦了。2. 登录体系的整体骨架认证中心、网关与资源服务的分工想要让单点登录可靠前提是把每个节点该干的事捋清楚。我们当时的架构分了三层每一层的职责边界非常明确。2.1 三个核心组件的职责边界认证中心是唯一允许校验用户名密码的地方负责登录、签发JWT、管理客户端的注册信息。网关是所有请求的唯一入口对全局Token做初步校验和解析。资源服务是业务真正干活的地方它接收网关转发的已认证请求然后基于解析出的用户信息完成业务逻辑。这里有一个关键设计点经常被新手忽略网关已经校验过Token了业务服务还需要重复校验吗我的答案是必须做但校验方式可以轻量化。网关校验只保证Token经过签名验证但业务服务如果完全信任网关转发的Header一旦某个内部链路绕过网关直连业务服务就相当于带了外挂进场。正确的做法是业务服务用同一套公钥自己验一遍JWT签名验完再取用户信息这样即使URL被绕过Token本身也是可信的。2.2 完整登录流程的设计最终我们跑通的流程是这样的用户访问某个前端页面请求进入网关网关检查Token是否存在且签名有效。Token缺失或失效网关直接返回401前端捕获后统一302跳转到认证中心登录页。用户在认证中心输入用户名密码认证中心校验通过后签发JWT。认证中心把JWT通过重定向方式送回前端前端存到本地存储或HttpOnly Cookie里。后续所有请求在请求头中携带JWT网关验签后把用户ID、用户名等声明字段透传到下游。资源服务收到请求先校验JWT签名确认可信后从Payload取用户信息进行业务处理。这里我们做了个取舍OAuth2协议标准中还有一个授权码模式适合第三方应用通过授权码换取Token。但我们内部系统之间是直接信任关系没必要让用户先拿授权码再换Token那样反而多一次跳转。所以我们在对接内部业务系统时采用资源拥有者密码模式用户名密码直接换Token将来如果有外部第三方接入再补授权码模式OAuth2的机制是支持灵活扩展的。2.3 为什么不用各服务自己管理登录态有朋友问过我既然JWT是无状态的那每个服务自己解析一下不就行了非要整个认证中心干啥答案是如果每个服务各自签发和校验Token那就回到了各自为政的局面。今天订单服务用A密钥明天支付服务用B密钥前端对接不同服务还得拿不同的Token跟Session方案一样割裂。认证中心的价值在于“统一信任锚点”——所有Token都在一个地方签发、使用同一套签名策略所有服务用同一套密钥校验。这样用户的Token在全链路是一致的就实现了真正意义上的单点登录。我们排查用户登录问题的时候也只需要盯认证中心一个点不用跑到每个服务里去翻日志。3. 认证中心实现用户凭证换JWT的全过程认证中心是整套体系中改动最大、也最容易踩坑的部分。我直接讲实现方案技术栈是Spring Boot 3.x Spring Security 6 JJWT库。3.1 密钥方案怎么定HS256还是RS256先说签名算法选择。很多人初学JWT时会用HS256因为编码少一个字符串密钥就能干完所有事。但HS256是对称签名签名和验签用的是同一个密钥这个密钥一旦被业务服务拿到理论上业务服务就能冒充认证中心签发任意用户的Token后果不堪设想。生产环境的正确姿势是RS256非对称签名。认证中心用私钥签名资源服务和网关用公钥验签。公钥分发出去也不怕它只能验签不能签名认证中心的私钥在物理上不会离开认证中心所在的机器。我在很多项目里见过HS256上线之后泄露密钥导致被越权的案例凡是准备长期运行的系统我都建议直接上RS256别在这上面省事。密钥对我放到classpath的jwt.jks文件中通过keytool生成keytool -genkeypair -alias jwt -keyalg RSA -keysize 2048 \ -keystore jwt.jks -storepass changeit \ -dname CNAuthServer, OUSecurity, ODemo, LBeijing, STBeijing, CCN然后在application.yml中声明jwt: keystore: location: classpath:jwt.jks alias: jwt password: changeit认证中心读取私钥用于签名资源服务和网关拿到同一个JKS文件里的公钥做验签。JKS文件本身就是安全的私钥有密码保护分发公钥时只需要把证书导出来即可。3.2 登录认证这一环Spring Security只管登录JWT签发单独做我们历史项目里遇到过一种坑想用Spring Security把登录和Token签发一把梭结果配置被搞复杂调试起来特别痛苦。实际更干净的做法是Spring Security负责校验用户名密码签发JWT由自己写的一个TokenService接手。先配置Spring Security放行登录接口Configuration EnableWebSecurity public class SecurityConfig { Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .authorizeHttpRequests(auth - auth .requestMatchers(/oauth/token, /oauth/logout).permitAll() .anyRequest().authenticated() ) .sessionManagement(session - session .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED) ); return http.build(); } }登录接口核心逻辑长这样RestController RequestMapping(/oauth) public class AuthController { private final AuthenticationManager authenticationManager; private final TokenService tokenService; private final UserRepository userRepository; PostMapping(/token) public ResponseEntityTokenResponse getToken(RequestBody LoginRequest request) { // 1. 用Spring Security的AuthenticationManager做认证 Authentication authentication authenticationManager.authenticate( new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword()) ); // 2. 认证通过后从UserDetails里取出用户信息 UserDetails user (UserDetails) authentication.getPrincipal(); // 3. 调用TokenService签发JWT String accessToken tokenService.generateAccessToken(user); return ResponseEntity.ok(new TokenResponse(accessToken, bearer, 7200)); } }这里有个细节authenticationManager.authenticate()里如果用户名密码错误会抛BadCredentialsException全局异常处理器统一转成401响应前端根据401跳转登录页逻辑链路很清晰。3.3 JWT生成细节声明里该放什么不该放什么TokenService的核心代码Service public class TokenService { private final PrivateKey privateKey; private static final long ACCESS_TOKEN_VALIDITY_SECONDS 7200; public String generateAccessToken(UserDetails userDetails) { Instant now Instant.now(); return Jwts.builder() .setSubject(((MyUser) userDetails).getId().toString()) .claim(username, userDetails.getUsername()) .claim(authorities, userDetails.getAuthorities().stream() .map(GrantedAuthority::getAuthority).collect(Collectors.toList())) .setIssuer(auth-server) .setIssuedAt(Date.from(now)) .setExpiration(Date.from(now.plusSeconds(ACCESS_TOKEN_VALIDITY_SECONDS))) .signWith(privateKey, SignatureAlgorithm.RS256) .compact(); } }说到JWT的Payload设计有两条经验务必记牢第一子主题字段必须放用户唯一标识比如数据库用户ID千万不能只放用户名。用户名是可能变更的ID永久不变。整个微服务链路里识别用户一律以ID为准。第二不往Payload里塞敏感信息。JWT的Payload只是Base64编码不是加密任何拿到Token的人解码一下就能看到内容。我见过有人把手机号、邮箱地址、银行卡号都塞进去这等于把用户敏感信息印在了不设防的令牌上隐患非常大。真要存敏感信息必须放到后端存储里用Token里的ID去查别贪图方便往JWT里塞。3.4 登录成功后的交互逻辑用户登录成功后认证中心需要把Token返回给前端。三端分离场景下更安全的做法是设置一个HttpOnly的Cookie让JavaScript拿不到Token避免XSS攻击窃取。但Cookie有跨域限制前端域名跟认证中心域名不同的话Cookie也不好使所以当时我们权衡后选择了Respose Body直接返回Token前端存到内存再由统一拦截器加到请求头。这个方案对API型架构更顺手只要前端做好XSS防护问题可控。4. 网关层统一鉴权为什么JWT要在这里验一遍网关是整个认证体系的第二道防线它不干业务只做一件事识别请求里有没有Token、Token是否可信、识别出用户后把身份透传下去。4.1 基于Spring Cloud Gateway的JWT鉴权过滤器核心过滤器代码Component public class JwtAuthGlobalFilter implements GlobalFilter, Ordered { private final JwtTokenValidator tokenValidator; Override public MonoVoid filter(ServerWebExchange exchange, GatewayFilterChain chain) { String path exchange.getRequest().getURI().getPath(); // 白名单直接放行 if (WhiteListConfig.contains(path)) { return chain.filter(exchange); } // 提取Authorization头 String authHeader exchange.getRequest().getHeaders().getFirst(Authorization); if (authHeader null || !authHeader.startsWith(Bearer )) { return unauthorizedResponse(exchange); } String token authHeader.substring(7); try { Claims claims tokenValidator.parseToken(token); ServerHttpRequest mutatedRequest exchange.getRequest().mutate() .header(X-User-Id, claims.getSubject()) .header(X-Username, claims.get(username, String.class)) .header(X-Authorities, String.join(,, claims.get(authorities, List.class))) .build(); return chain.filter(exchange.mutate().request(mutatedRequest).build()); } catch (Exception e) { return unauthorizedResponse(exchange); } } Override public int getOrder() { return -100; } }4.2 白名单机制的边界条件划分白名单必须反复打磨我当时初版只配了/oauth/token和/oauth/logout结果登录成功之后页面一直401。排查半天才发现登录页本身加载了很多静态资源这些资源路径也走了过滤器没有Token自然被拦了。解决方式是把静态资源路径和不校验身份的路径全部划进白名单。划分白名单时我们按以下原则来认证中心的登录页、静态资源、Token签发接口全部放行。涉及用户登录态判断的接口必须校验。对外开放的营销类接口也要放行但对外接口不能不加限制地裸奔要配合IP白名单或签名校验一起使用。4.3 网关校验与资源服务校验的关系网关这层验签的动作其实是提前拦截把明显非法的请求挡在业务服务门外。但业务服务内部必须再做一次验签不能用网关传的Header直接当身份来源。网关和资源服务的配合规则是网关不重建Token只做透传与声明注入资源服务用本地公钥验证JWT签名然后以JWT中的sub字段为准获取用户身份网关注入的Header只作为快速索引和审计日志用。5. 资源服务侧的公钥校验与用户身份识别资源服务是业务真正运行的地方这个环节的常见误区是只从网关透传的Header里读ID不验证JWT本身。我特意把Positives独立一节来讲就是因为这个痛点太典型了。5.1 轻量级JWT解析而不是完整OAuth2资源服务器Spring Security里集成JWT最省事的方式是做资源服务器配置Configuration EnableWebSecurity public class ResourceServerSecurityConfig { Value(${jwt.public-key-location}) private Resource publicKeyResource; Bean public SecurityFilterChain resourceServerChain(HttpSecurity http) throws Exception { http .csrf(csrf - csrf.disable()) .authorizeHttpRequests(auth - auth .requestMatchers(/public/**).permitAll() .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 - oauth2 .jwt(jwt - jwt.publicKeyLocation(publicKeyResource)) ); return http.build(); } }通过oauth2ResourceServer配置Spring Security会自动完成JWT验签、过期时间检查和签名算法匹配。业务代码里想获取当前用户直接GetMapping(/order/list) public ListOrder listOrders(AuthenticationPrincipal Jwt jwt) { String userId jwt.getSubject(); // userId直接作为业务参数使用 return orderService.listByUserId(userId); }5.2 为什么用JWT的sub字段作为用户身份主键Jwt.getSubject()对应sub声明就是我们在认证中心放进去的用户ID。整个链路的关键约定在这里网关透传的X-User-Id和资源服务从JWT解析出来的subject必须相等如果不相等说明网关被绕过或者Header被篡改业务服务应当拒绝处理。5.3 公钥管理滚动更新的正确时机公钥不是一成不变的私钥泄露或者例行安全维护时需要滚动更新。滚动更新最平滑的方案是JWKSJSON Web Key Set端点认证中心暴露/oauth/jwks接口返回公钥信息资源服务定时拉取并缓存公钥列表轮换时旧公钥保留一段时间等所有Token自然过期后再彻底下线旧公钥。我推荐有条件的团队直接按这个方式做只在公钥变化不频繁、规模较小的项目里才适合使用配置一次写死的做法。6. 单点退出微服务体系里最容易被忽略的闭环登录做了单点退出也必须单点。用户在一个系统里点了退出如果其他系统还保持着登录态那单点登录就成了单点摆设安全隐患也在那里摆着。6.1 为什么JWT让退出变得很别扭JWT是无状态设计Token一旦签发在过期之前任何服务都认为它有效。用户点退出JWT本身没有立即失效的机制除非等它自然过期。这个矛盾的常见解法是维护一个Token黑名单退出时把Token加进黑名单资源服务每次解析时检查一下黑名单。但引入黑名单之后又得依赖Redis/数据库无状态的优势就被削弱了。微服务系统里哪个选择都有代价我倾向的组合方案是短Token比如两小时 Redis黑名单只针对主动退出场景生效过期Token既不占黑名单缓存也不需要回查成本可控。6.2 基于事件广播的全局退出方案具体落地时认证中心处理退出请求后做两件事把JWT加入黑名单、把退出事件发到消息中间件广播。所有业务服务监听事件后把该用户在当前服务本地维持的缓存信息清理掉。示意图里虽然不用画时序图但流程我展开描述用户浏览器带Token请求认证中心退出接口。认证中心把JWT的jti值写入Redis黑名单设置过期时间为Token剩余有效期。认证中心发布LogoutEvent事件包含用户ID与Token的jti。各业务服务监听事件把用户在本地的会话缓存、临时授权状态清理干净。网关后续收到这个Token验签时发现黑名单命中直接拒绝。6.3 网关缓存与Redis黑名单的一致性这里有个容易被忽视的细节网关为了提升性能会缓存一部分JWT解析结果。如果网关本地有各自独立的缓存某个Token被加入黑名单之后网关缓存还没过期用户仍然能通过网关访问业务服务。处理这个漏网之鱼我当时采取的策略是网关验签通过后不立即放行逢单数的请求再做一次黑名单回源确认关键接口强制回源普通接口用5秒短缓存。性能和实时性做了折中至少核心接口的退出实时性是有保证的。7. 实践下来最容易踩的六个坑提前说透从方案设计到上线前后踩了不知道多少坑。把最典型、也最有代表性的六个记录下来能帮后来者节省不少排查时间。7.1 依赖版本冲突Spring Security OAuth2老写法直接编译失败网上大量教程还在教使用EnableAuthorizationServer和EnableResourceServer开箱看起来很方便。但Spring Boot 3.x已经把这些注解彻底移除了这个坑非常常见。早期我们有一个服务用了老写法编译直接报警告升级时折腾了整整一天。如果你还在维护Spring Boot 2.x的老项目继续用旧注解没问题新项目直接拥抱Spring Authorization Server或自建认证中心。自己掌控token逻辑虽然代码多写几个类但版本兼容性问题基本绝迹了。7.2 网关转发时Header覆盖导致用户身份漂移网关把X-User-Id加到请求头后如果下游服务再向外调用其他微服务时没有把Header逐层传递身份信息就丢了。更隐蔽的坑是有的服务自己也会设置X-User-Id这个自定义值覆盖了网关写入的合法值下游就会拿虚假ID做业务等用户投诉时才发现数据错乱到了离谱的程度。解决方案很明确定义一个不常见的内部Header命名比如X-Internal-User-Id在网关入口强制覆盖任何同名Header下游服务一律以它为准。7.3 JWT解析使用对称密钥与非对称密钥错配这个坑属于配错密钥类型的。某服务把签名公钥配置成了认证中心的私钥字符串运行时报签名校验失败排查了很久才发现是配置项写反了。强烈建议把JWT公钥格式统一为Base64的PUBLIC KEY配置路径单独命名不要把private和public两个词写到同一个服务里减少混淆的可能。7.4 时间同步与时钟漂移问题JWT的签发时间、过期时间都是依赖系统时钟的。假设认证中心机器比业务服务快30秒那么一个刚签发的Token到业务服务那边很可能被判为“尚未生效”或者“已过期”。Docker容器之间时钟不同步尤其常见。对策有两个一是所有机器启用NTP时间同步二是JWT校验时允许一定的clockSkew一般300秒足够。我现在做资源服务配置都会在验签底层加入clockSkew参数这是云原生环境下的基本操作。7.5 白名单配置过宽导致鉴权失效白名单过宽是我见过最多、最危险的问题。有的开发图省事把所有路径都丢进白名单相当于整个JWT过滤器白写了。白名单配置要遵循最小原则逐个路径确认且配置改动要通过代码评审。另外要注意路径匹配用的是Ant风格还是Path Pattern配置/oauth/**和/oauth/*的匹配范围差异很大配错了可能连登录接口都被错误地拦截。7.6 退出时没清理认证中心的会话Cookie即使JWT黑名单生效了认证中心自己的登录页如果是基于Session的用户再次访问时可能自动恢复登录态退出就成了假退出。解决方式是在退出接口里同时让Spring Security的SessionRegistry失效并删除认证中心域下的Cookie双管齐下。我们上线这套体系三个月后最直观的变化是前端同学再也不用为每个应用配置单独的登录逻辑了。之前接一个新业务系统至少要半天的联调现在只需要对接认证中心再在网关注册一下路由就行。单点登录的价值本质上是用一次架构上的收敛消掉改不完的口径和维护不完的Session缓存。我个人的建议是别急着动手写代码先把认证中心、网关、资源服务三者的信任边界画清楚后面所有实现都会顺畅很多。