Spring Security OAuth2授权服务器搭建:JWT Token实战
发布时间:2026/10/16 4:14:08 锦皓数字建站

在开放接口越来越多之后我才意识到真正难的往往不是写业务接口而是怎么让别的系统安全地拿到访问令牌。这周我重新整理手头一个老单体项目决定把认证授权从业务代码里拆出去换成独立的授权服务器正好把这套基于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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-security/artifactId /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-oauth2-authorization-server/artifactId version1.1.1/version /dependency dependency groupIdorg.springframework.security/groupId artifactIdspring-security-oauth2-jose/artifactId /dependencyresource-server则只引入资源服务器的 starterdependency groupIdorg.springframework.boot/groupId artifactIdspring-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 变成 JWTToken 生成链路改造这是本文最核心的一章。很多人搭好授权服务器后发现发出来的access_token是一串随机字符串而不是预期的 JWT。原因在于默认的 Token 生成器生成的是 Opaque Token需要显式把JwtGenerator放进委托链。3.1 默认 Token 生成器是 Opaque为什么我要换在不提供任何额外 Bean 的情况下授权服务器内部使用的DelegatingOAuth2TokenGenerator只包含OAuth2AccessTokenGenerator和OAuth2RefreshTokenGenerator。前者生成的 access token 是一个随机字符串没有携带任何业务信息必须拿着 Token 回授权服务器查状态。换成 JWT 之后Token 本身自包含基本信息资源服务器能够离线验签减少一次远程调用。对比维度Opaque TokenJWT Access Token是否自包含用户信息否仅一个随机值是payload 里有 claims资源服务器验签成本需要调用授权服务器 introspection本地用公钥验签即可是否可立即吊销支持改存储状态即可难需要额外维护黑名单适用场景高安全要求、需要频繁吊销内部服务、网关统一鉴权我的原则是如果是对外开放的敏感接口宁可多一次 introspection 调用也要用 Opaque Token如果是内部微服务之间互相调用JWT 的效率优势非常明显。这套演示选 JWT也更贴近大多数教程读者的实际诉求。3.2 JWK 的构建与 JwtEncoder 装配想要签发 JWT必须有非对称密钥。授权服务器保留私钥用私钥签名资源服务器拿公钥验签。公钥放在/oauth2/jwks端点供所有人读取。下面这段代码会生成一对 RSA 密钥并构建JWKSourceBean public JWKSourceSecurityContext 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进入委托链。稳妥做法是显式声明一个OAuth2TokenGeneratorBean public OAuth2TokenGenerator? tokenGenerator(JWKSourceSecurityContext 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。同时可以注册一个OAuth2TokenCustomizerJwtEncodingContext往 Token 里塞自定义字段。日常最常用的是租户 ID、来源系统、audience 等Bean public OAuth2TokenCustomizerJwtEncodingContext 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(JWKSourceSecurityContext jwkSource) { return OAuth2AuthorizationServerConfiguration.jwtDecoder(jwkSource); }这一点是 Spring Authorization Server 自动配置给我的最大惊喜当 JWT 相关的 Bean 配齐后很多默认逻辑会自动切换到 JWT 模式不需要写大量分支判断。如果你碰到 “为什么已经配了 JWKtoken 还是随机串” 的怪问题优先检查三件事有没有JWKSourceBean、有没有JwtDecoderBean、tokenGenerator 委托链里是不是把JwtGenerator漏掉了。4. 接一个资源服务器把验证链路彻底跑通授权服务器只是签发端真正被调用的 API 在资源服务器上。这一章把两者连起来走一遍完整的授权码流程。4.1 资源服务器的 Security 配置新建resource-server服务端口放在 8080application.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_typecodeclient_idweb-clientredirect_urihttp://127.0.0.1:8080/login/oauth2/code/web-clientscopeopenid%20profilestatexyz浏览器会先要求登录输入demo-user / password后授权服务器会重定向到注册过的redirect_uri地址栏里会出现code参数。拿到 authorization code 后用命令行换 Tokencurl -X POST http://localhost:9000/oauth2/token \ -u web-client:secret \ -H Content-Type: application/x-www-form-urlencoded \ -d grant_typeauthorization_code \ -d code填写浏览器地址栏里的code \ -d redirect_urihttp://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 时返回 401Token 里缺少profilescope 时返回 403这两类状态码的区分在排查权限问题时非常有用。4.3 把 JWT 拆开看Header 与 Payload把拿到的 access_token 贴到任意 JWT 调试工具里会看到三段结构。Header 大致是{ alg: RS256, kid: 生成的随机keyID, typ: atjwt }typ标记atjwt表明这是 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 JWKSourceSecurityContext 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_challengeBase64Url(SHA256(verifier)) code_challenge_methodS256换取 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资源服务器一直 401JWKS 地址不可达或密钥不匹配检查 jwk-set-uri 与授权服务器 issuer最后再分享一个我自己的习惯线上会给授权服务器的客户端注册信息留一张单独的审计表记录每个 client 的申请时间、审批状态和回调地址变更历史而不是让任何人都能往里塞一条注册配置。这套方案我实际跑了一年多稳定下来之后几乎没再碰过授权相关代码。希望这篇完整的 Authorization Server 实战记录能帮你把从 Token 签发到验签的整条链路一次搭顺。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。