从单体到微服务之后,很多团队第一个被搞崩的不是业务,而是登录。原来在单体应用里一套HttpSession躺平搞定的事,拆成十几个服务后立刻变得尴尬:Session在哪个服务里?用户明明登录了,另一个服务怎么不认识?更别提还有第三方应用要接入、前端要拿Token、内部服务要互相调用这些需求。我最早踩这个坑是在一次电商中台重构里,登录状态在订单服务和库存服务之间来回传,最后实在受不了,才引入了Spring Security OAuth2 + JWT这套组合。这篇文章就围绕这套架构,把方案选型、核心原理、实际操作和那些文档里不写的坑都讲一遍。
先给个定位:这篇内容适合已经熟悉Spring Boot基本用法、准备接触微服务认证授权的读者,也适合已经在用Session登录但正被跨服务会话问题折磨的团队。读完以后你能搞明白OAuth2和JWT解决的是两个不同的问题,能照着文章搭一套授权服务器加资源服务器的最小可用架构,还能避开至少五个我实际踩过的坑。
1. 整体设计与方案选型:为什么是OAuth2 + JWT
1.1 先分清楚:认证和授权是两件事
微服务架构里,团队经常把"认证"和"授权"混着说,但心里必须把这俩概念拆开。认证解决的是"你是谁",授权解决的是"你能做什么"。Session方案实质只解决了认证问题,用户一登录,服务器记住你是谁,至于你能访问哪个订单接口、能删哪条数据,全靠代码里if判断,授权逻辑散落在各个业务方法里。这在单体里还能忍,到了微服务里每个服务都写一套权限判断,维护成本高得吓人。
OAuth2把授权这件事标准化了:它定义了一个叫授权服务器的角色,专门负责颁发Token。用户登录后,授权服务器确认了身份,颁发一个Token;资源服务器(业务服务)拿到Token后,不需要亲自去问用户密码,只需要校验Token本身,再根据Token里携带的信息决定给不给你访问。这等于把"判断你是谁的逻辑"和"判断你能否做什么的逻辑"从业务代码里抽出来了。
JWT就是Token的一种具体编码格式。它本身不解决认证问题,也不解决授权问题,它只是提供了一种"自包含"的令牌格式:所有关键信息都写在Token里,资源服务器只要验个签名,就能拿到用户身份和权限列表,不用每次远程请求授权服务器。无状态这个特性对微服务特别重要,每一个服务都能独立校验Token,不需要共享Session存储。
1.2 为什么不用Session方案
微服务下Session方案最典型的三个痛点,我列成表格对比一下:
| 痛点 | Session方案表现 | OAuth2 + JWT表现 |
|---|---|---|
| 会话同步 | 每次请求都要查Session存储,一旦Redis挂了全员掉线 | Token自带信息,服务本地验签,不依赖共享存储 |
| 跨服务传递 | 每个微服务都要从Session里解析用户,代码重复 | 网关统一解析Token,或各服务自验,信息一致 |
| 第三方接入 | 不支持标准化授权,得自己写授权逻辑 | 支持授权码模式,第三方应用直接接入 |
实际项目里Session方案并非不能用,特别是用户量不大、服务拆得不狠的时候,Spring Session配上Redis也能凑合。但一旦涉及第三方系统对接、SSO单点登录、内部服务间调用授权,Session方案就特别拧巴。OAuth2和JWT这套组合最大的价值,是把"登录态"这个原本跟业务强耦合的东西,变成了一个可以独立设计、独立扩展的模块。
1.3 Spring Security 6带来了什么变化
很多老项目还停留在Spring Security 5的写法,升级到Spring Boot 3 + Security 6之后,配置方式彻底变了。以前是继承WebSecurityConfigurerAdapter,重写configure方法,现在改成了SecurityFilterChain Bean声明式配置。OAuth2那块改动更大:Spring Security 6直接把授权服务器的实现从Spring Security中分离出去,变成了独立的Spring Authorization Server项目。网上很多教程还是老写法,照抄的话在Spring Boot 3底下根本编译不过,这一点后面实操部分会演示正确姿势。
2. 核心原理拆解:JWT、OAuth2与Spring Security过滤器链
2.1 JWT的三个段分别存了什么
JWT由三部分组成,用点号分隔,分别是Header、Payload、Signature。Header存放令牌类型和签名算法;Payload放具体业务声明,比如用户ID、角色列表、过期时间;Signature是对前两段的签名。有人喜欢在Payload里塞大量业务字段,这样做会导致Token体积迅速膨胀。每次请求网关和各个服务都要解析它,Token越大,网络和时间开销越明显。我见过有人往里塞一整个用户对象,几千个字节的Token到处都是,实在没必要。声明只放必要信息:用户ID、角色、过期时间、可选的jti(JWT ID),其他细节让业务服务按需查询。
签名的意义在于防篡改。Header和Payload是Base64编码,谁都能解码看到内容,但Key只保存在授权服务器。任何人对Payload动手脚,签名校验都会失败。这也引出一个经常被忽视的安全点:JWT里的信息是明文可见的,千万别放敏感数据,比如密码、手机号、身份证号。用生活类比的话,JWT像一张签名盖章的通行证,内容别人看得见,但想改内容就会被发现。存放敏感数据等于把个人信息写在通行证正面,风险不言而喻。
2.2 OAuth2的四种模式里,微服务常用哪几种
OAuth2定义了授权码模式、简化模式、密码模式、客户端凭证模式。很多初学的人以为OAuth2只有一种流程,实际上选错模式会让整个架构别扭很久。
授权码模式是首选,适用于有前端的场景,前端跳转到授权服务器,用户确认后授权服务器通过重定向把授权码带回来,前端再拿授权码换Token。密码模式因为需要客户端直接接触用户密码,在信任度较低的场景下不被推荐,但一些内部项目图省事还在用。客户端凭证模式没有用户参与,纯粹是机器对机器,非常适合微服务内部调用时的服务身份认证。
根据我的经验,微服务里最常用的是授权码模式管用户登录,客户端凭证模式管服务间调用。这两者互相配合,前端用户走授权码流程拿User Token,内部服务间调API时用Client Token,身份语义清晰,权限控制也可以完全分开。
2.3 Spring Security 6的过滤器链到底是怎么工作的
理解Spring Security,核心是理解过滤器链。一个请求进来后,会依次经过一串过滤器,每个过滤器只做一件事:认证过滤器负责解析Token,授权过滤器检查当前用户有没有权限访问这个URL。Spring Security 6里,这套链路通过SecurityFilterChain Bean定义,一个应用可以配置多条过滤链,按请求路径匹配不同规则。
实际配置中,常见的做法是授权服务器占一条链,资源服务器占一条链,Spring Boot 3的写法如下:
@Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer(); http .with(authorizationServerConfigurer, (authorizationServer) -> authorizationServer .oidc(Customizer.withDefaults())) .authorizeHttpRequests((authorize) -> authorize .anyRequest().authenticated()) .formLogin(Customizer.withDefaults()); return http.build(); }@Bean @Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize .requestMatchers("/public/**").permitAll() .anyRequest().authenticated()) .oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); return http.build(); }这段配置最核心的是oauth2ResourceServer这个配置项,它把当前应用变成了OAuth2资源服务器,自动从请求头里读取Authorization: Bearer xxx,解析并校验JWT。安全过滤链的执行顺序也需要注意,授权服务器链在前面,权限更高;业务服务链在后面,主要负责资源保护。顺序写反了,所有请求都会被后面那条链拦截,授权服务器根本没法正常工作。
2.4 Token里为什么既有JWT又有Opaque的选择空间
有人会问,既然JWT这么好,为什么还保留Opaque Token?因为JWT一旦签发,在有效期内无法主动作废,注销登录只能等它自然过期。Opaque Token是一串随机字符串,授权服务器把它存在内存或Redis里,校验时远程查询一次。看起来是倒退了,但它支持实时吊销。
我目前的经验是:对外部第三方应用接入的场景,可以用Opaque Token,方便随时吊销;内部服务间调用,用JWT能省掉每次校验的远程调用开销。没有绝对好坏,只有合不合适。
3. 实操搭建:从零跑通认证授权最小闭环
3.1 整体项目结构怎么设计
我建议先把工程按职责拆成三个模块:认证服务、网关服务、业务服务。认证服务就是授权服务器,负责登录页、发Token、管理客户端信息;网关负责统一入口,做基础的Token校验和路由转发;业务服务是实际的资源服务器,比如订单服务、用户服务,各自校验Token并完成业务。
早期我踩过一个大坑:把所有校验逻辑全放在网关,业务服务完全不校验Token。这样一旦跳过网关直连业务服务(比如服务间调用、运维同事用测试工具直连),整个认证体系就形同虚设。正确姿势是网关做粗粒度校验(有没有Token、Token格式对不对),业务服务做细粒度校验(验签、权限判断)。每次被问"网关校验了为什么业务还要验签",我都用一句话回复:不要把希望全押在入口上。
3.2 认证服务配置的关键步骤
使用Spring Authorization Server,需要配置已注册的客户端,也就是哪些应用允许走OAuth2流程。我通常会从数据库加载客户端信息,但为了快速跑通,可以先内存配一个:
@Bean public RegisteredClientRepository registeredClientRepository() { RegisteredClient registeredClient = RegisteredClient.withId("order-service") .clientId("order-client") .clientSecret("{noop}order-secret") .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) .redirectUri("http://localhost:8080/login/oauth2/code/gateway") .scope("order.read") .scope("order.write") .tokenSettings(TokenSettings.builder() .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) .accessTokenTimeToLive(Duration.ofHours(1)) .build()) .build(); return new InMemoryRegisteredClientRepository(registeredClient); }这里有个重要细节:{noop}前缀表示明文密码。生产环境绝对不能这么用,必须用BCrypt等密码编码器。网上大量的demo代码都用{noop},照搬到生产环境就等于把客户端密钥明文暴露。
授权服务器还需要配置JWK(JSON Web Key),这是JWT签名用的密钥对。Spring Authorization Server会生成一个JWK Set端点,资源服务器通过这个端点获取公钥验证签名。生产环境建议用RSA密钥对,并把私钥放在配置中心或KMS里,每次重启不重新生成,否则已经发给用户的Token在重启后全部失效。
3.3 业务服务如何校验JWT
业务服务的核心配置是声明自己是资源服务器,并且配置JWT校验器。Spring Boot 3下的JWT资源服务器配置有两种方式,一种是通过issuer-uri自动发现JWK,另一种是直接配置公钥。我优先推荐issuer-uri方式,因为它会自动从授权服务器的/jwks端点拉取公钥,后续做密钥轮换时资源服务无需重启:
spring: security: oauth2: resourceserver: jwt: issuer-uri: http://localhost:9000Java配置里只需要声明SecurityFilterChain,其他交给自动配置:
@Bean public SecurityFilterChain resourceServerSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests((authorize) -> authorize .requestMatchers("/public/**").permitAll() .requestMatchers("/admin/**").hasAuthority("SCOPE_admin") .anyRequest().authenticated()) .oauth2ResourceServer((resourceServer) -> resourceServer .jwt(Customizer.withDefaults())); return http.build(); }hasAuthority("SCOPE_admin")这里有很多人困惑,为什么权限名带SCOPE_前缀。原因在于Spring Security把OAuth2的scope映射成authority时,为了保证命名空间不冲突,自动加了这个前缀。如果你希望使用JWT自定义claim来做权限控制,需要自定义JwtAuthenticationConverter。这个我在后面问题排查部分会单独说,因为这里踩坑的人特别多。
3.4 网关层怎么做统一Token校验
网关使用Spring Cloud Gateway时,我一般不会在网关里引入整套Spring Security,而是用GlobalFilter做轻量校验,只做三件事:检查Authorization头是否存在、把Token解析出来的userId放到请求头传给下游、如果访问的是公开白名单就直接放行。
@Component public class AuthGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { String token = extractToken(exchange.getRequest()); if (StringUtils.hasText(token)) { exchange = exchange.mutate() .request(r -> r.header("X-User-Id", parseUserId(token))) .build(); } return chain.filter(exchange); } @Override public int getOrder() { return -100; } }这里要特别注意,网关不能做到完整的验签,因为网关性能敏感,每次请求做RSA验签会带来额外开销。它更重要的职责是路由透传和审计。真正的验签必须由下游业务服务做,否则直连问题会捅出大篓子。网关里解析用户信息只是方便统一埋点,不要依赖网关解析结果做权限控制。
3.5 Token续签:过期体验怎么处理
JWT无状态带来的最大痛点就是过期问题。用户Token有效期设短了,体验差,每隔一小时就要重新登录一次;设长了,安全风险增加,泄漏Token相当于长时间裸奔。业界常见的方案有两种:Refresh Token刷新和Redis续签。
Refresh Token是OAuth2标准里的机制:Access Token有效期短,Refresh Token有效期长,Access Token过期后拿Refresh Token换新的Access Token。Spring Authorization Server对Refresh Token的支持很完整,关键是前端要处理好静默刷新逻辑。Redis续签方案则是完全不依赖OAuth2标准,每次请求时如果Token剩余有效期低于阈值,签到新Token下发。前者更标准,后者更灵活。我的建议是标准优先,不要一上来就自创刷新机制。
4. 常见问题与排查技巧实录
4.1 401和403到底是谁的问题
这是排障时最先要分清的。401是未认证,意味着Token缺失、过期或签名校验失败;403是已认证但没权限,意味着Token有效但角色、scope不够。很多人一张嘴就说"接口403了,是不是Token过期",完全搞反了。
我建议遇到这类问题,先看三处:第一,请求头里Authorization是否带了Bearer前缀,格式不能错;第二,如果用的issuer-uri方式,授权服务器地址是否可达、公钥能否拉取;第三,该接口要求的权限和Token里的scope是否匹配。有一次我排查了两小时,最后发现是请求头写成了Authorization: token xxx,少了一个Bearer单词。
4.2 授权服务器换密钥后,所有Token全部失效
生产环境偶尔会遇到这样的问题:运维机器重启了授权服务器,所有用户突然报401。原因基本都是JWK密钥没有持久化,重启时重新生成了新的RSA密钥对,旧Token验签失败。解决方案是把密钥配置固定下来,也支持定期密钥轮换。JWK Set配置里可以包含多个密钥,通过kid字段区分,资源服务器会自动获取最新的公钥,旧密钥配置的宽限期后移除即可。
这里也提醒一句,网上流传的"默认密钥绕过"事件,本质就是因为不少框架或中间件内置了固定密钥,攻击者用这个公开密钥就能伪造合法Token。生产环境必须确保签名密钥的真正随机与私密存放,别图省事用默认值。
4.3 JWT的alg混淆攻击是怎么回事
JWT签名算法有一个经典攻击面:签发时使用RS256(非对称),校验时由于某些库的兼容性,支持了HS256(对称),攻击者把header里的alg改成HS256,然后用公钥当作HMAC密钥来签名,服务端如果用公钥去验HS256签名,就可能被绕过。这个攻击在较新的库中已经默认防御,但老项目升级不彻底仍存在风险。
防御手段也很简单:校验时固定允许的算法列表,不要动态信任header里的alg。Spring Security 6默认行为已经比较安全,但如果你自己封装JWT解析工具类,一定要检查有没有用宽松的算法配置。
4.4 自定义权限claim为什么老是不生效
默认情况下,Spring Security把JWT里的scope字段映射成权限。如果我们自己塞了roles字段,写代码想hasAuthority("ADMIN"),但怎么都不生效,就是因为没有自定义JwtAuthenticationConverter。正确写法如下:
@Bean public JwtAuthenticationConverter jwtAuthenticationConverter() { JwtGrantedAuthoritiesConverter converter = new JwtGrantedAuthoritiesConverter(); converter.setAuthoritiesClaimName("roles"); converter.setAuthorityPrefix("ROLE_"); JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(converter); return jwtAuthenticationConverter; }权限命名需要在团队里统一约定。我用过很多项目,有的用ROLE_前缀,有的用SCOPE_前缀,混着来就会造成"这接口明明有权限为什么403"的诡异问题。建议规范成统一格式,配置中心里单独维护一份权限清单。
4.5 常见问题速查表
| 现象 | 可能原因 | 解决方式 |
|---|---|---|
| 重启授权服务器后全部401 | JWK私钥未持久化,每次重启新生成 | 配置固定RSA密钥,实现持久化 |
| 部分接口能用,部分403 | scope与hasAuthority不匹配 | 检查JWT里的scope字段与接口权限配置 |
| 网关能访问,直连业务服务失败 | 业务服务未配置资源服务器 | 每个业务服务都要配置验签 |
| Token过期后前端反复跳登录 | Refresh Token未实现或失效 | 完善刷新流程,检查RefreshToken有效期 |
| 使用issuer-uri但公钥拉不到 | 授权服务器地址配置错误或网络隔离 | 检查配置与网络连通性,或手动配置公钥 |
| 权限一直不生效 | JwtAuthenticationConverter未自定义 | 自定义converter,设置正确的claim和前缀 |
5. 安全加固与性能优化建议
5.1 密钥管理与轮换策略
JWT整个安全模型建立在密钥安全之上,密钥一旦泄露,攻击者可以在不接触授权服务器的情况下,自己签发任意身份的Token。生产环境建议满足几条:签名密钥使用RSA或更高强度算法,至少2048位;私钥从环境变量或KMS获取,不落盘在代码库;定期轮换,轮换时先发布新公钥,保留旧公钥一段时间兼容存量Token。最重要的是,不要使用网上的示例密钥,也不要让所有环境共用同一把密钥。我在一个项目里见过测试环境和生产环境用同一个JWK,结果测试环境的密钥配置被外泄后,生产环境被迫紧急轮换,全员重新登录。
5.2 多服务间的Token透传问题
微服务间调用时,如果服务A要调用服务B,不能直接把用户Token透传下去,因为用户Token代表的是用户身份,服务间调用是另一层身份。正确做法是使用客户端凭证模式单独获取Client Token,再按需传递。透传用户Token导致的常见风险是越权:用户本来不该访问订单服务,但通过服务A的转发间接访问到了。服务间调用需要建立自己的授权边界,这是微服务权限设计中最难的部分。简单的内部信任网络可以靠白名单IP,但更稳妥的是每个服务都验证调用方身份。
5.3 性能开销要算清楚
JWT验签是CPU操作,尤其是RSA验签,在高并发下开销不容忽视。网关层每请求一次验签,业务服务又验一次,整体损耗是叠加的。优化手段包括:网关和业务服务各自加一层短时缓存,按jti+kid缓存验签结果;合理设置Token有效期,减少重复签发;使用更高效的签名算法。但别忘了,性能优化要以安全为前提,缓存验签结果时必须连同过期时间一起缓存,否则会出现Token已过期、缓存还认为有效的情况。
根据我在生产环境的实测,RSA256验签在普通机器上单次大概在几十微秒量级,流量高的服务的确需要关注。但多数业务系统瓶颈不在验签,而在数据库查询,所以不用过度焦虑,先把架构做对,再按指标优化。
6. 一些实操后的个人体会
整套架构跑通之后,我发现最难的从来不是代码配置,而是团队对这个模型的理解是否统一。Spring Security OAuth2 + JWT这套组合的每一环,都有对应的设计意图:OAuth2管的是"授权流程",JWT管的是"令牌格式",Spring Security管的是"过滤与拦截"。把这三者的边界理清楚了,配置过程会顺畅很多,排查问题也能快速定位到具体层面。
最后分享一个实际建议:新项目上手时,先把授权服务器和资源服务器拆成两个独立工程,哪怕最初只是demo级别。前期拆分的好处是让你强迫自己从边界思考问题,而不是把所有逻辑揉在一个应用里。等真正拆分时才发现要改的远不止配置,那才是成本最高的时候。按这个路径走,微服务认证授权这条路上你能少熬很多夜。