ARTICLE DETAIL

资讯详情

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

微服务认证授权实战:Spring Security 6 + OAuth2 + JWT 完整方案

微服务认证授权实战:Spring Security 6 + OAuth2 + JWT 完整方案 从单体到微服务之后很多团队第一个被搞崩的不是业务而是登录。原来在单体应用里一套HttpSession躺平搞定的事拆成十几个服务后立刻变得尴尬Session在哪个服务里用户明明登录了另一个服务怎么不认识更别提还有第三方应用要接入、前端要拿Token、内部服务要互相调用这些需求。我最早踩这个坑是在一次电商中台重构里登录状态在订单服务和库存服务之间来回传最后实在受不了才引入了Spring Security OAuth2 JWT这套组合。这篇文章就围绕这套架构把方案选型、核心原理、实际操作和那些文档里不写的坑都讲一遍。先给个定位这篇内容适合已经熟悉Spring Boot基本用法、准备接触微服务认证授权的读者也适合已经在用Session登录但正被跨服务会话问题折磨的团队。读完以后你能搞明白OAuth2和JWT解决的是两个不同的问题能照着文章搭一套授权服务器加资源服务器的最小可用架构还能避开至少五个我实际踩过的坑。1. 整体设计与方案选型为什么是OAuth2 JWT1.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、角色、过期时间、可选的jtiJWT 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}照搬到生产环境就等于把客户端密钥明文暴露。授权服务器还需要配置JWKJSON 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 MonoVoid 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 常见问题速查表现象可能原因解决方式重启授权服务器后全部401JWK私钥未持久化每次重启新生成配置固定RSA密钥实现持久化部分接口能用部分403scope与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验签在高并发下开销不容忽视。网关层每请求一次验签业务服务又验一次整体损耗是叠加的。优化手段包括网关和业务服务各自加一层短时缓存按jtikid缓存验签结果合理设置Token有效期减少重复签发使用更高效的签名算法。但别忘了性能优化要以安全为前提缓存验签结果时必须连同过期时间一起缓存否则会出现Token已过期、缓存还认为有效的情况。根据我在生产环境的实测RSA256验签在普通机器上单次大概在几十微秒量级流量高的服务的确需要关注。但多数业务系统瓶颈不在验签而在数据库查询所以不用过度焦虑先把架构做对再按指标优化。6. 一些实操后的个人体会整套架构跑通之后我发现最难的从来不是代码配置而是团队对这个模型的理解是否统一。Spring Security OAuth2 JWT这套组合的每一环都有对应的设计意图OAuth2管的是授权流程JWT管的是令牌格式Spring Security管的是过滤与拦截。把这三者的边界理清楚了配置过程会顺畅很多排查问题也能快速定位到具体层面。最后分享一个实际建议新项目上手时先把授权服务器和资源服务器拆成两个独立工程哪怕最初只是demo级别。前期拆分的好处是让你强迫自己从边界思考问题而不是把所有逻辑揉在一个应用里。等真正拆分时才发现要改的远不止配置那才是成本最高的时候。按这个路径走微服务认证授权这条路上你能少熬很多夜。
返回列表