在开放接口越来越多之后我才意识到,真正难的往往不是写业务接口,而是怎么让别的系统安全地拿到访问令牌。这周我重新整理手头一个老单体项目,决定把认证授权从业务代码里拆出去,换成独立的授权服务器,正好把这套基于JWT的方案完整走了一遍。这篇实战记录围绕 Spring Boot 2.x 与 Spring Security 搭建 OAuth 2.0 Authorization Server,重点讲客户端注册、Token 生成链路、资源服务器验签,以及容易被官方文档略过的几个坑。想搞 SSO、API 鉴权,或者正在拆单体的人,应该都能从里面找到直接能用的东西。
1. 版本组合与项目骨架:先别急着写代码
1.1 Spring Security 7 与 Spring Boot 2.x 的取舍
很多人看到标题里的 “Spring Security 7” 会下意识去翻 Maven 仓库,结果发现 7.x 那一套新架构的依赖要求并不支持 Boot 2.x。这里先把版本关系理清楚:Spring Authorization Server 在 1.x 时代对应的安全框架基线是 Spring Security 5.8,依赖 Spring Boot 2.7 是可以稳定运行的;Security 6.x 对应 Boot 3.x;后续新版本才逐步把授权服务器能力固化进更高版本的安全框架。
实操指南是:如果你的存量项目还锁在 Boot 2.x,不要硬上 Security 7 的二进制依赖,选 Boot 2.7.x + Security 5.8.x + spring-security-oauth2-authorization-server 1.1.x 是最稳的路。新项目则可以整体切 Boot 3.x + Security 6.x,核心 API 差距不大,本文这套模型照样成立。标题里的 “7” 更多是系列化课程或文档的编号,不代表你当前项目必须用 7.0 的那个版本号。真正重要的是你按哪一版 API 写代码,以及 Token 链路用了什么组件。
1.2 依赖引入与工程拆分
授权服务器本质是一个独立的 Spring Boot 应用,不该和你的业务接口混在一起。我一般会拆成两个 Maven 模块:auth-server负责登录、发 Token、暴露公钥;resource-server负责校验 Token、保护 API。两个服务分开的好处是密钥边界清晰,资源服务器出问题时不会把授权服务拖垮,业务扩容也不需要连带重启认证模块。
auth-server的 pom 里最少需要这几个依赖:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-security</artifactId> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-authorization-server</artifactId> <version>1.1.1</version> </dependency> <dependency> <groupId>org.springframework.security</groupId> <artifactId>spring-security-oauth2-jose</artifactId> </dependency>resource-server则只引入资源服务器的 starter:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-oauth2-resource-server</artifactId> </dependency>这里有个容易踩的坑:resource-server千万不要引入authorization-server依赖,那会把一堆不需要的端点也带进业务进程。它只需要 JOSE 相关的解码类和 JWT 校验能力,starter 会自动拉。
2. 授权服务器的核心配置:客户端注册、端点与过滤器链
授权服务器要做的事情可以归纳成四件:登记客户端、让用户登录、签发 Token、暴露公钥。Spring Authorization Server 在这件事上提供了自动配置,但我们通常要自己接管一部分,才能理解每个端点背后发生了什么。
2.1 RegisteredClientRepository:把客户端信息先定下来
最关键的对象是RegisteredClient,它描述了一个第三方应用能够使用的授权方式、回调地址、权限范围。先用最简单的内存实现跑通流程:
@Bean public RegisteredClientRepository registeredClientRepository() { 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("profile") .clientSettings(ClientSettings.builder().requireProofKey(false).build()) .tokenSettings(TokenSettings.builder() .accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED) .accessTokenTimeToLive(Duration.ofMinutes(30)) .refreshTokenTimeToLive(Duration.ofDays(7)) .build()) .build(); return new InMemoryRegisteredClientRepository(webClient); }几个选型细节值得说明:clientSecret前面加{noop}是因为我没有额外引入密码编码器,Spring Security 会直接按明文校验,生产环境请替换成{bcrypt}对应的加密值;requireProofKey(false)方便演示时先跑通授权码主流程,实际上 OAuth 2.1 已经把 PKCE 定为必选项,后面的章节会专门讲;openidscope 决定了授权服务器会不会额外签发 ID Token,否则你只能拿到 access_token。
2.2 SecurityFilterChain 与自动配置之间的关系
Spring Authorization Server 会默认注册一批端点,包括/oauth2/authorize、/oauth2/token、/oauth2/jwks、/.well-known/oauth-authorization-server。如果完全依赖自动配置,你只要提供RegisteredClientRepository和密钥源就够了,但实际项目里通常要自定义登录页、限制可访问端点,所以我会显式声明一条优先级最高的过滤器链:
@Bean @Order(1) public SecurityFilterChain authorizationServerSecurityFilterChain( HttpSecurity http, RegisteredClientRepository registeredClientRepository, AuthorizationServerSettings authorizationServerSettings) throws Exception { OAuth2AuthorizationServerConfigurer authorizationServerConfigurer = new OAuth2AuthorizationServerConfigurer(); http .securityMatcher(authorizationServerConfigurer.getEndpointsMatcher()) .with(authorizationServerConfigurer, authorizationServer -> authorizationServer .registeredClientRepository(registeredClientRepository) .authorizationServerSettings(authorizationServerSettings)) .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) .formLogin(); return http.build(); }这段配置的核心是securityMatcher:只有当请求落在授权服务器端点路径上时,才走这条过滤器链;其他请求不会进入。formLogin()让用户可以通过表单完成登录身份确认,这是授权码流程里必不可少的一步。如果你同时还有自己的管理后台接口,记得再写一条@Order(2)的过滤器链,否则请求会被这条链全部拦掉。
2.3 AuthorizationServerSettings 与 issuer 的影响
AuthorizationServerSettings表面只是配置了一个issuer,实际上影响深远。它会被写进 JWT 的issclaim,也会出现在/.well-known/openid-configuration的发现文档里。资源服务器如果按 issuer 校验 Token,就得保证这个值从公网到内网都能被正确访问到。
@Bean public AuthorizationServerSettings authorizationServerSettings() { return AuthorizationServerSettings.builder() .issuer("http://localhost:9000") .build(); }如果授权服务器部署在网关后面,我建议issuer配置成对外域名,而不是内网 IP,否则客户端拿到的 Token 在跨环境调用时会被判定为不可信来源。
3. 把 Access Token 变成 JWT:Token 生成链路改造
这是本文最核心的一章。很多人搭好授权服务器后,发现发出来的access_token是一串随机字符串,而不是预期的 JWT。原因在于默认的 Token 生成器生成的是 Opaque Token,需要显式把JwtGenerator放进委托链。
3.1 默认 Token 生成器是 Opaque,为什么我要换
在不提供任何额外 Bean 的情况下,授权服务器内部使用的DelegatingOAuth2TokenGenerator只包含OAuth2AccessTokenGenerator和OAuth2RefreshTokenGenerator。前者生成的 access token 是一个随机字符串,没有携带任何业务信息,必须拿着 Token 回授权服务器查状态。换成 JWT 之后,Token 本身自包含基本信息,资源服务器能够离线验签,减少一次远程调用。
| 对比维度 | Opaque Token | JWT Access Token |
|---|---|---|
| 是否自包含用户信息 | 否,仅一个随机值 | 是,payload 里有 claims |
| 资源服务器验签成本 | 需要调用授权服务器 introspection | 本地用公钥验签即可 |
| 是否可立即吊销 | 支持,改存储状态即可 | 难,需要额外维护黑名单 |
| 适用场景 | 高安全要求、需要频繁吊销 | 内部服务、网关统一鉴权 |
我的原则是:如果是对外开放的敏感接口,宁可多一次 introspection 调用也要用 Opaque Token;如果是内部微服务之间互相调用,JWT 的效率优势非常明显。这套演示选 JWT,也更贴近大多数教程读者的实际诉求。
3.2 JWK 的构建与 JwtEncoder 装配
想要签发 JWT,必须有非对称密钥。授权服务器保留私钥,用私钥签名;资源服务器拿公钥验签。公钥放在/oauth2/jwks端点供所有人读取。下面这段代码会生成一对 RSA 密钥并构建JWKSource:
@Bean public JWKSource<SecurityContext> jwkSource() { KeyPair keyPair = generateRsaKey(); RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic(); RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate(); RSAKey rsaKey = new RSAKey.Builder(publicKey) .privateKey(privateKey) .keyID(UUID.randomUUID().toString()) .build(); JWKSet jwkSet = new JWKSet(rsaKey); return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); } private static KeyPair generateRsaKey() { try { KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA"); keyPairGenerator.initialize(2048); return keyPairGenerator.generateKeyPair(); } catch (Exception e) { throw new IllegalStateException("RSA key generation failed", e); } }要注意的是keyID不要省略,它对应 JWT Header 里的kid。换密钥时如果新旧 key 的kid相同,客户端和资源服务器就会拿旧公钥去验新签名的 Token,导致大规模验签失败。每个密钥都应该有唯一标识。
3.3 Token 生成器委托链与自定义 Claims
有了JWKSource还不够,我见过有人只加了JWKSourceBean,发现 access_token 依然是 Opaque,就是因为没有让JwtGenerator进入委托链。稳妥做法是显式声明一个OAuth2TokenGenerator:
@Bean public OAuth2TokenGenerator<?> tokenGenerator(JWKSource<SecurityContext> jwkSource) { JwtEncoder jwtEncoder = new NimbusJwtEncoder(jwkSource); JwtGenerator jwtGenerator = new JwtGenerator(jwtEncoder); OAuth2AccessTokenGenerator accessTokenGenerator = new OAuth2AccessTokenGenerator(); OAuth2RefreshTokenGenerator refreshTokenGenerator = new OAuth2RefreshTokenGenerator(); return new DelegatingOAuth2TokenGenerator( jwtGenerator, accessTokenGenerator, refreshTokenGenerator ); }这个委托链的顺序很关键:JwtGenerator在最前面,因为它需要处理JwtEncodingContext;后面的OAuth2AccessTokenGenerator负责那些没有 JWT 化场景的 Token 类型。只要委托链里有它,授权码流程签发的 access token 就会自动变成 JWT。
同时可以注册一个OAuth2TokenCustomizer<JwtEncodingContext>,往 Token 里塞自定义字段。日常最常用的是租户 ID、来源系统、audience 等:
@Bean public OAuth2TokenCustomizer<JwtEncodingContext> jwtTokenCustomizer() { return context -> { if (OAuth2TokenType.ACCESS_TOKEN.equals(context.getTokenType())) { context.getClaims().claim("tenant", "demo"); context.getClaims().claim("aud", "resource-server"); } }; }这里的context.getClaims()是对 JWT payload 的构建器,最终会被交给NimbusJwtEncoder签名。凡是需要跨服务传递的轻量业务信息,都可以在这里塞进去,但不要塞敏感数据,比如手机号、身份证号,因为 JWT 默认只是 Base64 编码,不是加密。
3.4 JwtDecoder:授权服务器自己也要能验签
授权服务器虽然不直接校验自己签发的 access token,但 OIDC 场景下验证 ID Token、以及后续做 Token 撤销检查时都需要一个JwtDecoderBean。这个 Bean 还能帮助框架自动识别出“你用的是 JWT”这一事实:
@Bean public JwtDecoder jwtDecoder(JWKSource<SecurityContext> jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); }这一点是 Spring Authorization Server 自动配置给我的最大惊喜:当 JWT 相关的 Bean 配齐后,很多默认逻辑会自动切换到 JWT 模式,不需要写大量分支判断。如果你碰到 “为什么已经配了 JWK,token 还是随机串” 的怪问题,优先检查三件事:有没有JWKSourceBean、有没有JwtDecoderBean、tokenGenerator 委托链里是不是把JwtGenerator漏掉了。
4. 接一个资源服务器,把验证链路彻底跑通
授权服务器只是签发端,真正被调用的 API 在资源服务器上。这一章把两者连起来,走一遍完整的授权码流程。
4.1 资源服务器的 Security 配置
新建resource-server服务,端口放在 8080,application.yml里配置远程 JWKS 地址:
server: port: 8080 spring: security: oauth2: resourceserver: jwt: jwk-set-uri: http://localhost:9000/oauth2/jwksjwk-set-uri是资源服务器获取公钥的地址。Spring 会定时拉取并缓存公钥集合,不影响每次请求的性能。接着声明过滤器链:
@Configuration @EnableWebSecurity @EnableMethodSecurity public class ResourceServerConfig { @Bean public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception { http .authorizeHttpRequests(auth -> auth .requestMatchers("/public/**").permitAll() .requestMatchers("/api/**").hasAuthority("SCOPE_profile")) .oauth2ResourceServer(oauth2 -> oauth2 .jwt(Customizer.withDefaults())); return http.build(); } }注意hasAuthority("SCOPE_profile")里的前缀SCOPE_是 Spring Security 的默认约定,它把 OAuth 2.0 的 scope 映射成了权限表达式。如果你的 scopes 里定义了profile,请求必须携带包含profilescope 的 access token 才能访问/api/**。
4.2 授权码流程的完整落点测试
为了能跑通授权码模式,先在授权服务器里准备一个内存用户:
@Bean public UserDetailsService userDetailsService() { return new InMemoryUserDetailsManager( User.withUsername("demo-user") .password("{noop}password") .roles("USER") .build() ); }然后用浏览器打开下面的地址,模拟第三方应用跳转到授权服务器:
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%20profile&state=xyz浏览器会先要求登录,输入demo-user / password后,授权服务器会重定向到注册过的redirect_uri,地址栏里会出现code参数。拿到 authorization code 后,用命令行换 Token:
curl -X POST http://localhost:9000/oauth2/token \ -u web-client:secret \ -H "Content-Type: application/x-www-form-urlencoded" \ -d "grant_type=authorization_code" \ -d "code=填写浏览器地址栏里的code" \ -d "redirect_uri=http://127.0.0.1:8080/login/oauth2/code/web-client"响应里的access_token已经是一段 JWT,拿它访问受保护接口:
curl -H "Authorization: Bearer 填写access_token" http://localhost:8080/api/profile正确的返回会携带当前用户信息;没有 Token 时返回 401;Token 里缺少profilescope 时返回 403,这两类状态码的区分在排查权限问题时非常有用。
4.3 把 JWT 拆开看:Header 与 Payload
把拿到的 access_token 贴到任意 JWT 调试工具里,会看到三段结构。Header 大致是:
{ "alg": "RS256", "kid": "生成的随机keyID", "typ": "at+jwt" }typ标记at+jwt表明这是 OAuth 2.0 access token,不是普通的 ID Token。Payload 里能看到iss、sub、aud、scope、exp、iat、jti这些标准字段,也有我们在自定义 Customizer 里加入的tenant和aud。
这里有一个经常被忽略的点:资源服务器默认不会因为aud不匹配就拒绝 Token。如果要严格限定 Token 只能用于某个服务,需要额外写一个JwtAuthenticationConverter去校验 audience,否则你的 Token 拿到别的服务上一样能通过签名验证,这在资源较多的系统里是个隐患。
5. 上线前绕不开的坑:密钥、PKCE、路由与调试
localhost 上的 Demo 跑通只是第一步,真正要推向生产,下面这几个问题才是大头。
5.1 密钥持久化与轮换思维
上面的generateRsaKey()每次应用重启都会生成新的密钥对。这意味着问题来了:授权服务器重启后,之前签发的所有 JWT 再用旧公钥去验签就会全部失败。本地测试无所谓,但线上必须让私钥固定下来。
生产环境至少有三种做法:把私钥导出成 PEM 文件放到受保护的目录;把私钥交给专门的密钥管理系统;每次启动从数据库读取 JWK Set。我倾向于第二种,因为密钥不落在进程文件系统里,轮换时可追溯。文件方式虽然糙,但小团队内部系统也够用:
@Bean public JWKSource<SecurityContext> jwkSource() throws Exception { // 从 classpath 读取 pem 文件,构造 RSAKey 后再包成 JWKSet try (InputStream is = new ClassPathResource("jwt-keys.pem").getInputStream()) { JWKSet jwkSet = JWKSet.load(is); return (jwkSelector, securityContext) -> jwkSelector.select(jwkSet); } }密钥轮换时记住一个原则:新密钥发布后的 24 小时内不要立即删除旧密钥,让已经被签发的 Token 还能被旧公钥验证,避免线上出现集中 401。
5.2 PKCE 不是可选项
我在演示配置里把requireProofKey(false)关掉了,但真实的新项目绝对不建议这样做。OAuth 2.1 已经把 PKCE 作为授权码模式的默认要求,尤其浏览器端、移动端这种不能安全保存 client secret 的场景,code_challenge和code_verifier是防止授权码被截获重放的关键。
启用时,授权请求要带上:
code_challenge=Base64Url(SHA256(verifier)) code_challenge_method=S256换取 Token 时再带上:
code_verifier=原始verifier值如果没带或算法不匹配,会直接报类似invalid_grant或PKCE verification failed的错误。
5.3 地址匹配与客户端配置的边界
redirect_uri的匹配比大多数人想得严格。http://localhost:8080和http://127.0.0.1:8080在授权服务器眼里是两个完全不同的地址,更不用说大小写、尾部斜杠、默认端口省略这些细节。这类问题排查起来最浪费时间,因为报错信息通常只有一句 “Invalid redirect_uri in authorization request”。所以客户端注册时尽量从生产配置文件里复制完整地址,不要手敲。
5.4 日志与调试三步法
如果流程没跑通,先打开调试日志:
logging: level: org.springframework.security: DEBUG org.springframework.security.oauth2: TRACE然后按顺序排查:先看授权请求有没有落到授权服务器的过滤器链,通过日志里的匹配路径判断;再看 Token 端点收到的 grant_type 和 code 是否正常;最后看资源服务器拉 JWKS 的地址通不通、Token 解码时有没有解析出权限。这三个环节几乎覆盖了 90% 的问题。
我把上线前遇到的典型问题整理成一张表,方便快速定位:
| 现象 | 根本原因 | 处理方式 |
|---|---|---|
| 授权请求返回 Invalid redirect_uri | 注册地址和实际地址不完全一致 | 从配置里复制完整 redirect_uri |
| 换 Token 时提示 PKCE verification failed | 开启 PKCE 后缺 code_verifier | 重新生成并按 S256 规则传参 |
| 提示 OAuth 2.0 Authorization Server requires authentication | 用户未登录就访问授权端点 | 配置 formLogin 或自定义登录页 |
| access_token 是随机字符串 | JwtGenerator 没进 tokenGenerator 委托链 | 显式声明 OAuth2TokenGenerator Bean |
| 资源服务器一直 401 | JWKS 地址不可达或密钥不匹配 | 检查 jwk-set-uri 与授权服务器 issuer |
最后再分享一个我自己的习惯:线上会给授权服务器的客户端注册信息留一张单独的审计表,记录每个 client 的申请时间、审批状态和回调地址变更历史,而不是让任何人都能往里塞一条注册配置。这套方案我实际跑了一年多,稳定下来之后几乎没再碰过授权相关代码。希望这篇完整的 Authorization Server 实战记录,能帮你把从 Token 签发到验签的整条链路一次搭顺。