先别急着抄依赖,OAuth2 这套东西在 Spring 生态里有个最大的坑:版本断层。我第一次搭授权服务器,照着网上的老教程敲@EnableAuthorizationServer,项目都起不来,折腾一下午才搞明白这个注解在 Spring Security 5 之后就被移除了,官方把整个 OAuth2 授权服务器单独拆成了一个新项目,叫 Spring Authorization Server,用起来完全是另一套写法。这篇我把 Spring Cloud Security + OAuth2 从概念到落地完整讲一遍,基于目前主流的 Spring Security 6 和 Spring Boot 3 来做,老项目的迁移路径、新项目的配置方式、还有我踩过的那些 401/403 和令牌验签问题都会写到,适合刚接触微服务安全、准备在网关和业务服务里接 OAuth2 的人,也适合之前在旧版授权服务器上栽过跟头的同学对照着找原因。
1. 内容整体设计与思路拆解
1.1 微服务场景下,为什么一定要引入 OAuth2
单体应用时期的安全模型特别简单,登录之后把 session 存在 Tomcat 里,浏览器带上 JSESSIONID,后端一查就知道你是谁。好使是好使,但微服务拆开之后就崩了:用户请求先被网关转发到订单服务,再转发到用户服务,每个服务都有自己的 session,用户的登录态根本没法跨服务共享。你当然可以把 session 抽到 Redis 里做成共享,但 session 本质上是服务端状态,每来一个请求都要查一次存储,在横向扩容和服务间调用场景下非常拖后腿。
OAuth2 解决这个问题的思路是把"认证"和"鉴权"拆开。认证集中交给授权服务器(Authorization Server),它负责确认"你是谁、你能拿到什么权限范围";业务服务只做资源服务器(Resource Server),拿到令牌之后验签、看 scope,确认有效就放行,完全不依赖共享存储。这样一来,订单服务、用户服务、库存服务只需要配置同一套 JWT 验签公钥,就能各自独立校验请求身份。Spring Cloud Security 在这里承担的职责更像是一个"骨架层",它和 Spring Security 配合,负责微服务之间的令牌中继、网关安全过滤、服务间调用时的身份传递。真正实现 OAuth2 协议的是 Spring Security 本身,但离开了 Spring Cloud Security 提供的网关和微服务集成能力,你依然要把令牌透传、下游鉴权这些脏活累活自己写一遍。
1.2 2025 年再入门,技术选型必须踩在 Spring Security 6 这条线上
很多人在选型上的困惑其实是历史包袱。旧方案是 spring-security-oauth2 和 spring-security-oauth2-autoconfigure 这两个依赖,里面的@EnableAuthorizationServer注解在 Spring Security 5 时代就标记为废弃,到 Spring Security 6 之后彻底移除,项目也已经停止维护。官方推荐的新方案是独立项目 Spring Authorization Server,Spring Boot 3 里对应的起步依赖是spring-boot-starter-oauth2-authorization-server。Spring Cloud Security 虽然名字里带 cloud,但它不提供 OAuth2 协议的实现,实际你落地的依赖还是 Spring Security + Spring Authorization Server,Cloud 层的职责主要是网关令牌中继、安全过滤器链这类分布式的活。所以整个技术栈建议:Spring Boot 3.x + Spring Cloud 2023.x + Spring Security 6.x + Spring Authorization Server 1.x,这个组合内部兼容性最好,踩坑最少。
需要特别提醒的是,网上一搜"Spring Boot OAuth2",搜出来的旧教程比例极高。判断一个教程能不能用的标准很简单:看它引入的是不是spring-security-oauth2-authorization-server,配置方式是不是通过SecurityFilterChain的 HTTP 安全配置来实现,而不再是通过自定义配置类加注解。认准这两点,你就不会被老代码误导。
1.3 授权码模式选定了,其他 grant type 先不用管
OAuth2 定义了多种授权方式(grant type),但入门阶段我只建议你把精力放在授权码模式(Authorization Code)上,其他像客户端凭证模式(Client Credentials)顺手了解就行。为什么?因为在 Spring Security 6 里,密码模式(Password Grant)因为安全问题已经被移除了,设备授权模式(Device Code)用得少,刷新令牌(Refresh Token)又是建立在授权码模式之上的进阶能力。授权码模式是所有 Web 应用登录场景的基石——用户点"使用某某登录",跳到认证页,输账号密码,授权后跳回来,总共两条腿走路,理解这一条链路,全流程基本就吃透了。
2. 核心概念:授权流程与令牌机制
2.1 四个角色对照代码模块,一眼定位
OAuth2 里有四个角色:资源所有者(Resource Owner)、客户端(Client)、授权服务器(Authorization Server)、资源服务器(Resource Server)。用生活中的例子来理解:资源所有者是你本人,授权服务器是小区物业的登记处,客户端是你家楼下的快递柜,资源服务器是物业给你配的储物间。你想让快递柜替你签收快递(让客户端替你访问资源),就必须先授权。这本入门里,四个角色对应到代码是清清楚楚的:
- 授权服务器:一个独立的 Spring Boot 服务,依赖
spring-boot-starter-oauth2-authorization-server,负责登录、授权、签发令牌。 - 客户端:如果你做的是前后端分离的 Web 应用,那你的前端页面角色就是客户端;如果你想试 API 联调,Postman 也可以充当客户端。
- 资源服务器:你的业务微服务,比如订单服务、用户服务,它们不负责认证,只负责校验令牌。
- 资源所有者:真实用户,最终登录的人。
这四个角色在实际工程里可以合并部署,比如授权服务器逻辑上独立,但物理上可以先合在某个服务里,等规模上来了再拆分。不过我不建议入门阶段就做合并,独立出一个授权服务,资源服务单独接,边界最清晰,排查问题时也能少死很多脑细胞。
2.2 授权码流程完整链路,每一步 HTTP 请求都看明白
我自己第一次跑通授权码流程时,其实对中间跳转的细节是模糊的,只知道"跳过去再跳回来"。后来抓了请求才彻底看明白,整个过程其实就四步:
第一步,用户访问客户端应用,客户端检测到未登录,构造一个跳转 URL 把用户带到授权服务器。这个 URL 大概是这样的:
http://localhost:9000/oauth2/authorize?response_type=code&client_id=web-client&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/web-client&scope=openid&state=random-state注意response_type=code表明这是授权码模式,state参数用于防 CSRF,客户端会在回调时校验它和发起时是否一致。
第二步,授权服务器弹出登录页,用户输入账号密码登录。Spring Authorization Server 默认提供登录表单,这一步不需要自己写页面。登录成功后,服务器会展示"是否授权给该应用"的确认页面,用户点确认后,授权服务器生成一个一次性授权码,通过 302 重定向回redirect_uri,并且把 code 和 state 拼在 URL 上。
第三步,客户端拿着这个 code,在后端直接调用授权服务器的令牌端点换令牌。这里的关键是不能再走浏览器跳转,必须由客户端服务端发起请求,防止授权码被截获。换令牌的请求长这样:
curl -X POST http://localhost:9000/oauth2/token \ -H "Authorization: Basic $(echo -n 'web-client:secret' | base64)" \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/web-client&code=ONLY-ONCE-CODE"Authorization: Basic头是客户端自己的凭证,redirect_uri必须和第一步申请时的完全一致,这一点是安全要求,少一个字符都不行。授权码是一次性的,换完令牌立即失效,重放直接报错。
第四步,客户端拿到access_token(和可选的refresh_token)后,后续请求在Authorization: Bearer <token>头里带上它去访问资源服务器。资源服务器拿令牌去授权服务器发布的 JWK 公钥验签,验过就算认证通过,然后解析 scope 做权限控制。
2.3 令牌选型:JWT 和 Opaque Token 到底用哪个
令牌有两种主流形态:JWT 和不透明令牌(Opaque Token)。JWT 长得像一串以点号分隔的三段乱码,三段分别是 Header、Payload、Signature,可以 base64 解码看内容,但签名部分没法伪造。不透明令牌就是一段随机字符串,本身不携带任何信息,必须由资源服务器调授权服务器的/oauth2/introspect端点去查询令牌状态,才能知道有效性和 scope。
JWT 的好处是无状态:资源服务器拿到令牌后本地验签即可,不需要网络调用来验证,响应快、对授权服务器依赖低。坏处是签发之后没法主动吊销,只能等它自然过期,你改用户角色也不会立刻反映在已签发令牌里。不透明令牌则相反,吊销灵动,但每次鉴权都要多一次网络消耗。
我的建议很直接:网关对下游服务转发时,用 JWT 做主令牌,因为内部网络带宽充足,验签成本低,无状态的优势在微服务体系里是压倒性的;如果业务场景有严格的会话管理需求、需要立即可吊销,比如管理员封禁某个用户,那就在对外的会话场景里改用不透明令牌,或者 JWT 缩短有效期。入门阶段直接在 JWT 上聚焦就够了,80% 的场景它都能覆盖。
3. 实操:从零搭一个授权服务器
3.1 依赖引入与工程骨架
我建议你新建两个工程来学习:一个是授权服务器auth-server,端口 9000;一个是资源服务器resource-server,端口 8080。做实验阶段不要把授权逻辑混进业务服务里,拎清了再合不迟。
授权服务器pom.xml里核心依赖只有两个,一个是 Web,一个是授权服务器:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-authorization-server</artifactId> </dependency>这个起步依赖会帮你把 Spring Security、OAuth2 核心、JOSE 支持(JWT 签名所需)全部带进来,不需要额外引入 Spring Security。注意用它引入的 Spring Security 版本是 6.x,和 Spring Boot 3.x 是对应的。如果你项目还挂着老的spring-security-oauth2,先去掉,两个东西的自动配置类会打架,报出来的错往往还看不懂。
3.2 授权服务器核心配置:四个 Bean 一个都不能少
新建一个配置类,命名为AuthorizationServerConfig。很多教程只贴代码不解释,这里我把每个 Bean 的意图讲清楚,你以后自己改配置才知道动哪里:
@Configuration public class AuthorizationServerConfig { @Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { http // 授权服务器的端点走的是 OAuth2 协议,不走普通表单登录 .securityMatcher("/oauth2/**", "/login/**") .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) // 授权服务器默认需要一个登录表单 .formLogin(withDefaults()) // 令牌端点是 POST 请求,关闭 CSRF 才能掉通过 .csrf(csrf -> csrf.ignoringRequestMatchers("/oauth2/token")) .oauth2ResourceServer(oauth2 -> oauth2.jwt(withDefaults())); return http.build(); } @Bean @Order(2) public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) .formLogin(withDefaults()); return http.build(); } @Bean public RegisteredClientRepository registeredClientRepository() { // 实验阶段用内存实现,生产环境换 JDBC RegisteredClient webClient = RegisteredClient.withId(UUID.randomUUID().toString()) .clientId("web-client") .clientSecret("{noop}secret") .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) .redirectUri("http://127.0.0.1:8080/login/oauth2/code/web-client") .scope("openid") .scope("read") .scope("write") .build(); return new InMemoryRegisteredClientRepository(webClient); } @Bean public JWKSource<SecurityContext> jwkSource() { // 生产环境一定要持久化 RSA 密钥,不然每次重启令牌全部失效 RSAKey rsaKey = generateRsaKey(); JWKSet jwkSet = new JWKSet(rsaKey); return new ImmutableJWKSet<>(jwkSet); } @Bean public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); } private static RSAKey generateRsaKey() { // 实验代码:生成一次性的 RSA 密钥对 KeyPair keyPair = KeyPairGenerator.getInstance("RSA") .generateKeyPair(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); return new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); } }配置类里最容易被忽略的是@Order。授权服务器有两个过滤器链,优先匹配/oauth2/**和/login/**的授权服务器链路,剩下所有请求才落到第二条默认链路。如果把优先级写反了,OAuth2 端点会被普通安全性逻辑拦截,登录流程会陷入死循环:授权服务器自己要求先登录,而登录页面又被安全拦截器拦着进不去。
RegisteredClientRepository定义了谁会来申请令牌。clientSecret我写的是{noop}secret,意思是明文密码,仅限本地实验。加了{noop}前缀是给 Spring Security 提示不加密,真正的生产环境必须用{bcrypt}配合加密字符串,后面的常见问题部分我会专门讲这个细节。
3.3 把授权服务器跑起来,走一遍真实的授权码流程
配置写完直接启动auth-server,端口 9000。注意默认端口是 8080,为了避免和资源服务器冲突,在application.yml里加上:
server: port: 9000接着手动模拟客户端发起授权。先在浏览器打开:
http://localhost:9000/oauth2/authorize?response_type=code&client_id=web-client&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/web-client&scope=read&state=abc123如果没有登录,会先跳到一个默认的登录页。Spring Authorization Server 内置了一套表单登录页,你可以随便输入一个注册过的用户。但注意,我们没有配置任何用户存储,所以这时候根本登录不进去。很多初学者第一次配置就在这里卡住,以为是自己写错了,其实是缺了用户信息。解决方法是加一个内存用户:
@Bean public UserDetailsService userDetailsService() { UserDetails user = User.withUsername("user") .password("{noop}password") .roles("USER") .build(); return new InMemoryUserDetailsManager(user); }{noop}同样表示明文,实验用。加完这个用户,启动后输入 user / password 就能过登录关。登录后页面会提示"是否授权给 web-client",点是确认,浏览器会带着 code 跳到redirect_uri。因为我们的资源服务器还没建,这个地址会 404,但没关系,你可以在跳转失败之前复制地址栏里的 code 参数,然后手动执行换令牌的 curl 命令。
实际执行中你会遇到一个很有迷惑性的报错:invalid_grant。第一反应是 code 过期,但其实更大可能是redirect_uri不匹配。Spring Authorization Server 在换取令牌时要求redirect_uri必须和注册的完全一致,包括协议、域名、端口、大小写。很多人在授权 URL 里写localhost,注册时写127.0.0.1,看起来一样其实完全不一样,这个我踩过一次,印象特别深刻。
3.4 解密令牌,确认签名和 scope 都对了
换到 access_token 之后,把它复制到 jwt.io 或直接用 Python 解码:token 是两段.分隔,把第一段头部和第二段负载做 base64 解码,就能看到类似这样的内容:
{ "sub": "user", "aud": "web-client", "nbf": 1710000000, "scope": [ "read" ], "iss": "http://localhost:9000", "exp": 1710003600, "iat": 1710000000 }aud是令牌的目标客户端,scope会影响资源服务器上的权限判断,iss是签发者标识,资源服务器校验时这个值必须能对上。exp是过期时间,默认是access_token5 分钟,refresh_token 更长。如果你要调整有效期,可以通过自定义OAuth2TokenSettings来配置:
RegisteredClient webClient = RegisteredClient.withId(...) .tokenSettings(TokenSettings.builder() .accessTokenTimeToLive(Duration.ofHours(2)) .refreshTokenTimeToLive(Duration.ofDays(7)) .build()) .build();需要提醒的是,iss字段默认会带上端口号。如果资源服务器和授权服务器走不同域名或者做了端口映射,issuer不匹配是高频排查点。生产环境建议在授权服务器里显式配置issuer,让所有环境保持稳定可用,否则每次换个域名部署,资源服务器那边的校验就崩了。
4. 实操:资源服务器接入与网关令牌中继
4.1 资源服务器最小配置,验签链路两步走
新建resource-server工程,端口 8080。依赖简单很多,只需要 Web 和 Spring Security 的 OAuth2 资源服务器支持:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency>配置文件里把授权服务器的 JWK 地址指出来:
server: port: 8080 spring: security: oauth2: resourceserver: jwt: jwk-set-uri: http://localhost:9000/oauth2/jwks issuer-uri: http://localhost:9000jwk-set-uri是授权服务器的公钥列表地址,资源服务器每次验签时如果本地没有缓存,会从这个地址拉公钥;issuer-uri用于校验 token 里的iss字段。接下来写安全配置:
@Configuration @EnableWebSecurity public class ResourceServerConfig { @Bean public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/public/**").permitAll() .requestMatchers("/api/hello").hasAuthority("SCOPE_read") .anyRequest().authenticated()) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(jwt -> jwt.jwtAuthenticationConverter(customJwtConverter()))); return http.build(); } private JwtAuthenticationConverter customJwtConverter() { // 默认把 scope 转成 SCOPE_ 前缀的权限,这个行为可以在这里定制 JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); authoritiesConverter.setAuthorityPrefix("ROLE_"); authoritiesConverter.setAuthoritiesClaimName("roles"); converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); return converter; } }默认的 JWT 到权限的转换逻辑是取scope字段,然后每一项前面拼上SCOPE_变成SCOPE_read、SCOPE_write。如果你在接口上用hasAuthority("SCOPE_read")判断,那就保持默认即可。如果你的授权服务器在签发令牌时把角色放到了自定义字段里,比如roles,那就要像我上面这样重写转换器,把来源字段指过去。
这里有一个新手最容易忽略的点:permitAll()不等于不校验。Spring Security 的资源服务器链路对所有请求都会先尝试解析 Authorization 头,如果请求带了无效令牌,配置了permitAll的路径也会返回 401。这不是 Bug,而是安全设计——permitAll只是不去做权限判断,令牌有效性还是要保证的。如果你真的有"匿名可访问但带错令牌也别报 401"的需求,那需要额外定制AuthenticationEntryPoint,这是另一个话题,入门阶段先别碰。
同一套授权服务器可以对接无数个资源服务器。每个资源服务只认授权服务器的公钥,所以只要授权服务器和资源服务器之间网络是通的,被拆成多少个微服务不影响整体逻辑。
4.2 Spring Cloud Gateway 的令牌中继,还是自己手写转发
微服务架构里网关是流量的总入口,很多人会在网关层做统一的 OAuth2 过滤。Spring Cloud Gateway 提供了TokenRelayGatewayFilterFactory,使用起来很简单:
spring: cloud: gateway: default-filters: - TokenRelayTokenRelay过滤器会自动把原始请求里的 Authorization 头透传给下游服务。看起来很方便,但你得有前提:网关本身配置了 OAuth2 客户端(spring.security.oauth2.client.*),并且有一个已经加载到OAuth2AuthorizedClient的会话。换句话说,如果你走的是"浏览器直接带 token 访问网关"的模式,TokenRelay反而不起作用,因为它依赖的是 OAuth2 客户端的授权对象,不是请求头。这是我在业务代码里反复踩过的一个认知误区。
所以我的建议是:Spring Cloud 微服务场景,网关层别过度设计,最朴素的方案反而最可靠——网关只做路由和全局过滤器,把 Authorization 头原样放行,由下游资源服务器各自验签。只有当你们团队确定要走"网关统一管理 OAuth2 客户端、页面跳转式登录"的 BFF 模式时,才值得上TokenRelay。如果只是 API 网关 + token 直传,手写一个十行的全局过滤器就够了:
@Component public class TokenForwardGlobalFilter implements GlobalFilter, Ordered { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { ServerHttpRequest request = exchange.getRequest(); String authorization = request.getHeaders().getFirst(HttpHeaders.AUTHORIZATION); if (authorization != null && authorization.startsWith("Bearer ")) { ServerHttpRequest mutated = request.mutate() .header(HttpHeaders.AUTHORIZATION, authorization) .build(); return chain.filter(exchange.mutate().request(mutated).build()); } return chain.filter(exchange); } @Override public int getOrder() { return -100; } }原理本质是换个请求给下游,改动量极小,排查起来也直观。如果你发现下游永远 401,多半不是过滤器问题,而是请求头根本没到下游,用这个过滤器先确认放行了再说。
4.3 scope 的权限模型设计:最小授权原则
授权服务器签发令牌时带上 scope,资源服务器按 scope 做权限判断。这套模型用一句话总结:scope 面向客户端,权限判断面向业务。一个客户端申请了什么 scope,就只能在对应的资源范围内活动。
我见过一个普遍不规范的做法:把所有接口都放在hasAuthority("SCOPE_read")之下,然后所有客户端申请readscope。这样等于没有权限管理,任何一个拿到 read 令牌的第三方都可以访问所有数据。规范的做法是把 scope 设计成粒度适中的业务权限,比如order.read、order.write、user.read,资源服务器按需判断:
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_order.read") .requestMatchers(HttpMethod.POST, "/api/orders/**").hasAuthority("SCOPE_order.write")如果客户端申请的 scope 没覆盖,资源服务器判断时就会因为缺少权限而拒绝。这个拒绝是 403,而不是 401,这两者的语义差别值得你留意:401 是"你是谁没确认"(token 无效或缺失),403 是"确认了但你没资格"(token 有效但 scope 不够)。排查问题时先分清这两种状态,能排除掉一半的干扰项。
5. 常见问题与排查技巧实录
5.1 所有接口都 401:公钥拉不到,还是令牌本身不过关
资源服务器 401 是最常见的现象,我建议按这个顺序排查:先确认 Authorization 头是否真的带到了资源服务器,网关转发时有没有被吃掉;再确认jwk-set-uri能不能从授权服务器拉到公钥,手动浏览器打开http://localhost:9000/oauth2/jwks看是否返回 JSON;然后检查授权服务器和资源服务器的issuer是否一致,重点看端口和域名;最后再看令牌有没有过期。这四步走完,八成的问题都能找到答案。
个我看过无数次的坑是:授权服务器改过端口,资源服务器配置里的issuer-uri还是旧的。iss是 JWT 里一个硬校验字段,资源服务器拿到令牌后会拿iss值和本地配置的issuer-uri做比对,不一致直接报Invalid claim: iss。通过 curl 解一个 token 出来看看iss到底是什么,往往一眼就能发现问题。
5.2 授权码能换,但资源服务器验签失败:同步时钟
JWT 验签失败还有一个隐性问题:时钟偏移。exp(过期时间)和nbf(生效时间)都是绝对时间戳,资源服务器校验时会和本地时间比较。微服务各自部署在不同的机器上,如果某台机器时间没同步,就会出现在授权服务器上刚换出来的令牌,到资源服务器一验就报"令牌尚未生效"或者"已过期"。这不是代码问题,是运维问题。解决方法是让所有服务统一走 NTP 时间同步。遇到这种"明明令牌才签发几秒却报过期"的情况,别急着怀疑代码,先date看一下两边时间差。
5.3 老项目迁移:没有后悔药,但迁移路径很清晰
如果你手上是一个还在用@EnableAuthorizationServer的老项目,好消息是还有一个明确的迁移路径。老依赖spring-security-oauth2已经 EOL,不再有任何安全更新,继续跑在公网上风险很高,我建议尽早迁到 Spring Authorization Server。
迁移的核心动作有三个:依赖换掉,spring-security-oauth2和spring-security-oauth2-autoconfigure整体移除,加spring-boot-starter-oauth2-authorization-server;配置方式从注解换成SecurityFilterChain+RegisteredClientRepository的编码风格;令牌存储如果需要持久化,把内存实现换成 JDBC 的JdbcRegisteredClientRepository和JdbcOAuth2AuthorizationService。旧客户端配置里的clientSecret如果是 BCrypt 密文,可以直接沿用密文值,把{bcrypt}前缀加上就行,不需要让客户重新改密码。这个细节能帮你省掉大量联调成本。
5.4 授权服务器登录页一直循环跳转:CSRF 与 Session 的奇怪博弈
授权码流程是依赖服务端会话(Session)的。用户登录后,Session 里记录登录态,确认授权后,跳转回客户端。如果你在授权服务器上配置了csrf.disable(),问题不大;但如果你用了@Order又配错了链路的匹配规则,登录后的 Session 没法被正确绑定,你会在登录页和授权页之间无限跳来跳去。
另一个高频问题是 CSRF token 校验失败。Spring Authorization Server 默认启用了 CSRF 防护,如果你用纯 API 或者 SPA 方式接入授权服务器,比如前端直接发起授权请求而拿不到 CSRF token,就会疯狂 401。对入门场景,正确的处理方式不是把 CSRF 关闭,而是用官方推荐的OAuth2AuthorizationServerConfiguration.applyDefaultSecurity(http)这种默认配置,它已经把令牌端点、JWKS 端点等 CSRF 放行规则都处理好了,你只需要在它基础上加自己的业务规则,千万别全链关掉,那是拿安全换一时爽,事后必还。
5.5 clientSecret 用明文到底行不行
实验代码里我用了{noop}secret,这是明文前缀,能跑但绝对别上生产。Spring Security 的PasswordEncoder机制支持多种前缀:{noop}明文、{bcrypt}BCrypt 加密、{sha256}SHA-256 等。如果你在RegisteredClient.clientSecret()里写了{bcrypt}密文,Spring 会按 BCrypt 去校验客户端请求中的明文密码。生产环境生成密文很简单,用BCryptPasswordEncoder或者在线工具算一个都行,但密文一旦入库就别再用{noop}。
给客户端配置密钥时还有一个更隐蔽的问题:很多团队会把clientSecret写死在代码或 YAML 里,一不小心就被提交进 Git 仓库。这不是本入门要展开的 DevOps 内容,但至少提醒一句:实验环境无所谓,生产环境一定要走配置中心或者密钥管理服务。
5.6 换了新版本,JWT 库冲突怎么办
Spring Authorization Server 底层用的是 Nimbus JOSE + JWT,版本由起步依赖统一管理。你如果再手动引入一个旧版本nimbus-jose-jwt,很容易出现NoClassDefFoundError或JWTClaimsSet相关的诡异异常。新项目不要手工加这个库,让父工程 BOM 管理即可。老项目从旧版 OAuth2 迁移时,也要把之前显式引用的 Nimbus 依赖删掉,让新版本自动带。这类冲突的特征是报错信息非常琐碎,指向某个内部方法找不到类,你看到这种红字,先检查依赖冲突,往往比查代码更快。
6. 基于个人实践的经验补充
最后再补充两个我在真实项目里特有体会的细节。
第一个是关于授权服务器的密钥持久化。本入门里generateRsaKey()每次启动都生成新密钥,意味着授权服务器重启后,所有客户端持有的令牌瞬间失效,日志清一色 401。这在开发阶段无所谓,但生产上每次发布都要全员重新登录,体验极差。正确的做法是在启动时从配置中心或环境变量里读取持久化的 RSA 私钥,或者用 JDK 自带的KeyStore把密钥对存下来。第二个是监控到位的重要性。授权服务器是全局认证入口,它的可用性直接决定所有业务服务的登入体验。至少要有两套环境的告警:/oauth2/authorize的响应延迟、/oauth2/jwks的可用性,这两条链路断了,业务端问都不用问肯定是登录失败。
另外想说的是,OAuth2 入门最大的障碍从来不是协议本身难懂,而是版本信息太乱。如果你在 Spring 生态里做微服务安全,建议以后查资料一律锁定 Spring Security 6 和 Spring Authorization Server 的官方文档,老博客高赞教程反而要警惕。这篇内容就是按这个思路整理的,照着搭一套出来,跑通授权码 + JWT 验签 + 网关转发,理解链路是怎么闭环的,之后再做单点登录、刷新令牌、持久化客户端,就是在这个骨架上一个一个加料的事。