1. 为什么现在的SpringBoot项目需要自己搭OAuth2授权服务器
先说一下背景。今年我们团队在做一整套用户体系升级,核心需求是把原来散落在各个业务系统里的登录逻辑收拢,统一走一套授权登录机制。当时的方案摆在我面前的有几条路:直接接微信/支付宝这类第三方登录,或者内网自建统一登录入口。一开始我觉得接微信登录最省事,毕竟扫码就行,但实际梳理下来发现业务方要的是一个账号打通多个内部系统,而不是每个系统都去对接一遍第三方平台。换句话说,我们需要的是自己掌控的授权中心,让别人来对接我们,而不是我们去对接别人。
这也是OAuth2协议最典型的应用场景——它解决的不是"怎么登录",而是"怎么授权"。我举个通俗的例子:你去酒店前台办入住,前台给你一张房卡,这张房卡能开你房间的门,但不能开酒店金库的门。OAuth2做的就是这张房卡的签发和校验工作:让用户告诉授权服务器"这个第三方应用可以访问我的哪些信息,不可以访问哪些",授权服务器验证通过后发一个令牌,第三方应用拿着令牌去访问资源。
这个"令牌签发与校验"的过程,在SpringBoot生态里最正统的落地方案就是Spring Authorization Server。它是由Spring官方在2020年推出的OAuth2.0授权服务器实现,取代了原来那个已经进入维护期的spring-security-oauth2项目。我一开始最大的困惑就在这里:网上能搜到的大量教程还在教旧版的@EnableAuthorizationServer注解写法,但新版SpringBoot 2.7+里这个注解已经不能用了,导致很多照着老教程操作的同学一启动项目就直接报错。
我当时选择的方案是:Spring Authorization Server + Spring Security + JWT。这个组合可以完整覆盖授权码模式下的所有流程——用户登录、授权确认、换取令牌、校验令牌、获取用户信息。全文的代码示例基于SpringBoot 2.7.x和Spring Authorization Server 0.4.x,这套版本组合在2023年到2024年期间是最常见的稳定搭配,网上资料也相对好找。如果你用的是SpringBoot 3.x,需要对应升级到1.x版本的授权服务器依赖,但核心思路完全一致。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 动手前先想清楚的三个选型问题
2.1 授权码模式 vs 客户端模式,选哪个
搭建授权服务器的第一步不是写代码,而是想明白你服务的调用方是谁。OAuth2协议定义了多种授权模式,最常用的是两种:
- 授权码模式(Authorization Code):适用于有后端的Web应用,用户需要在浏览器里确认授权,拿到的授权码会通过后端换令牌。这种模式下令牌不会暴露给前端,安全性最高。
- 客户端模式(Client Credentials):适用于服务与服务之间的调用,没有用户参与,应用直接用自己的凭证去换令牌。
我项目里的主要场景是管理后台登录和第三方系统获取用户资料,属于典型的第一种。如果你要做的是对外开放API给合作伙伴调用,那大概率只需要客户端模式就够了。这个决策会直接影响后面的代码结构,因为两种模式对应着完全不同的端点请求流程和配置参数。我自己一开始图省事只配了授权码模式,后来接入一个内部服务做服务间认证时又补了客户端模式,来回改了好几轮,费了不少功夫。建议动手前先画一张简单的调用关系图,标清楚哪些系统需要用户授权、哪些系统是纯服务调用,再决定要支持哪些模式。
2.2 JWT还是Opaque令牌
令牌(Token)有两种形态:一种是JWT这种自包含的,把用户信息直接编码在令牌里,资源服务器拿到后本地解签就算校验完成,不需要再请求授权服务器;另一种是不透明令牌,就是一串随机字符串,资源服务器必须拿着它去授权服务器的校验接口换用户信息。
这两种各有优劣。JWT的优点是不用每次校验都走一次网络请求,性能好,而且令牌里可以直接携带用户id、角色、权限等业务字段,非常适合分布式场景;缺点是没有办法主动让令牌失效,只能等它自然过期。不透明令牌正好相反,可以随时吊销,但每一次校验都要多一跳网络开销。
我最终选了JWT,主要是考虑到内部系统的资源服务器数量不多,网络开销差异不大,而JWT的"开箱即用"特性让资源服务器在对接时省去了实现校验客户端的步骤。如果你做的是外部开放平台,用户量巨大而且希望随时能踢人下线,建议认真考虑一下不透明令牌。
2.3 用户信息存储怎么设计
授权服务器虽然只管发令牌,但它必须知道"你是谁",所以用户数据的存储设计绕不开。我见过不少项目在这一步出错:把授权服务器的用户表和业务系统的用户表完全隔离,没有做关联,结果授权服务器发完令牌,业务系统拿到用户id后一脸懵,不知道这个id对应的是谁。
正确的做法是让JWT里携带统一的用户ID,各业务系统通过这个ID去自己的库里查详情。我项目里的做法是授权服务器维护一份最基础的用户表,只存账号、密码、状态、手机号这些认证必要字段;业务系统通过JWT里的userId字段去关联自己的用户拓展信息。用户中心和各业务的用户表通过user_id关联,而不是复制一份全量用户数据到每个系统里。这个设计看起来简单,但在实际落地时能省掉大量的数据同步问题。
3. 授权服务器搭建:从空项目到能发Token的全过程
3.1 引入依赖和基础配置
先建立一个空的SpringBoot项目,然后引入核心依赖。这里有一个容易踩的坑:Spring Authorization Server的版本号和SpringBoot并不完全一致,0.4.x对应的是SpringBoot 2.7.x,1.x对应SpringBoot 3.x。我用的是2.7.x,所以依赖版本锁在0.4.0。
xml复制<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>0.4.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
然后是基础配置。这里我直接写一个授权服务器的核心配置类,把注册客户端、配置令牌生成器、配置端点都放在这里。很多人一上来就复制一大段官方示例代码,但完全不知道每个配置的作用,后面出问题的时候就无从下手。我建议按下面的顺序一点点加配置,每加一块就启动一次项目验证,不要一次性堆完。
java复制@Configuration
@EnableWebSecurity
public class AuthorizationServerConfig {
@Bean
@Order(1)
public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception {
OAuth2AuthorizationServerConfigurer authorizationServerConfigurer =
new OAuth2AuthorizationServerConfigurer();
http
.with(authorizationServerConfigurer,
(authorizationServer) -> authorizationServer
.authorizationServerSettings(settings())
)
.oauth2ResourceServer(OAuth2ResourceServerConfigurer::jwt)
.exceptionHandling((exceptions) -> exceptions
.defaultAuthenticationEntryPointFor(
new LoginUrlAuthenticationEntryPoint("/login"),
new MediaTypeRequestMatcher(MediaType.TEXT_HTML)
)
);
return http.build();
}
}
注意这里我用了@Order(1),含义是让授权服务器的过滤器链优先于默认的Spring Security过滤器链执行。因为一个项目里可能同时存在普通登录页面和授权服务器端点,如果不加Order,默认的Security配置会截获所有请求,导致授权端点无法正常访问。这是一个非常隐蔽的坑,很多人配置完发现/oauth2/authorize端点404,其实不是端点不存在,而是请求被更优先的过滤器链拦截了。
3.2 注册客户端和授权码模式参数
接下来是最重要的部分:RegisteredClient的配置。授权服务器要知道"谁可以来我这里申请令牌",这个"谁"就是客户端。客户端可以是你的管理后台前端,也可以是第三方业务系统。每一个需要接入的系统都需要先在授权服务器这边注册一个client_id和client_secret。
java复制@Bean
public RegisteredClientRepository registeredClientRepository() {
RegisteredClient registeredClient = RegisteredClient.withId(UUID.randomUUID().toString())
.clientId("admin-client")
.clientSecret("{noop}admin-secret")
.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC)
.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN)
.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS)
.redirectUri("http://localhost:8081/login/oauth2/code/gateway")
.scope("read")
.scope("write")
.tokenSettings(TokenSettings.builder()
.accessTokenFormat(OAuth2TokenFormat.SELF_CONTAINED)
.accessTokenTimeToLive(Duration.ofMinutes(30))
.refreshTokenTimeToLive(Duration.ofHours(12))
.build())
.build();
return new InMemoryRegisteredClientRepository(registeredClient);
}
这里几个关键参数我拆开解释一下:
clientSecret("{noop}admin-secret"):{noop}前缀表示明文存密码,仅用于开发环境。生产环境一定要用{bcrypt}加密,否则密码泄露等于客户端凭证直接暴露。clientAuthenticationMethod:决定了客户端在换取令牌时如何证明自己的身份。CLIENT_SECRET_BASIC表示客户端把client_id和client_secret放到HTTP Basic认证头里,这是最通用也是各语言SDK都支持的方式。authorizationGrantType:必须有AUTHORIZATION_CODE,同时建议加上REFRESH_TOKEN,否则access_token过期后用户又得重新登录一次,体验很差。如果还要做服务间调用,就再加上CLIENT_CREDENTIALS。redirectUri:这是授权码模式的重定向地址,也就是用户完成授权后浏览器要跳回哪里。这个地址必须和发起授权的应用的注册地址完全一致,否则授权服务器会直接拒绝,报invalid_redirect_uri错误。tokenSettings里我把accessToken格式设为SELF_CONTAINED,这就是JWT模式;过期时间设为30分钟,refresh_token设12个小时,这个时长组合在内部系统里用起来比较舒适,不至于一天要登录好几次。
如果你注册了多个客户端,就多new几个RegisteredClient对象,全部放到同一个InMemoryRegisteredClientRepository里。生产环境建议把客户端信息存到数据库里,实现自己的RegisteredClientRepository,这块后文会提。
3.3 用户认证和授权确认页面
授权服务器必须有用户登录功能。因为OAuth2授权码模式的第一步就是用户去授权服务器登录,确认身份。我这里用Spring Security提供的内存用户来做演示:
java复制@Bean
@Order(2)
public SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.anyRequest().authenticated()
)
.formLogin((form) -> form
.loginPage("/login")
.permitAll()
);
return http.build();
}
@Bean
public UserDetailsService userDetailsService() {
UserDetails user = User.withUsername("admin")
.password("{noop}123456")
.roles("ADMIN")
.build();
return new InMemoryUserDetailsManager(user);
}
第二个过滤器链处理的是普通页面访问,比如/login这个登录页。用户第一次访问授权端点时,会被引导到这里登录。登录成功后,授权服务器会显示一个授权确认页面,问用户"是否允许admin-client访问你的read、write权限?",用户点同意,授权码就签发下来了。
这个"确认页面"在OAuth2AuthorizationServerConfigurer里默认是自带的,但样式非常简陋。我后来在对接前端时被设计同事吐槽过这个页面太丑,于是自定义了一个confirm页面,通过.authorizationEndpoint((endpoint) -> endpoint.consentPage("/oauth2/consent"))来替换。如果你的项目是纯后端API对接,不需要用户面对页面,可以考虑直接让授权确认自动通过,但那就需要在配置里指定authorizationConsentService的行为,让每一次授权都默认同意。
3.4 JWT的签发与密钥管理
JWT令牌的签发关键在于OAuth2TokenCustomizer和JwtEncoder。默认情况下Spring Authorization Server签发JWT时会自动带上iss(签发方)、sub(用户主体)、iat(签发时间)、exp(过期时间)这些标准声明。但实际业务中我们经常需要自定义声明,最常见的就是把userId放进去。
java复制@Bean
public OAuth2TokenCustomizer<JwtEncodingContext> tokenCustomizer() {
return (context) -> {
if (context.getTokenType().getValue().equals(OAuth2TokenType.ACCESS_TOKEN.getValue())) {
Authentication principal = context.getPrincipal();
if (principal instanceof UsernamePasswordAuthenticationToken) {
UserDetails user = (UserDetails) principal.getPrincipal();
context.getClaims().claim("userId", user.getUsername());
context.getClaims().claim("tenant", "internal");
}
}
};
}
这里我加了一个tenant字段用于区分租户。因为当前项目是多租户架构,不同的租户访问的资源范围不同。资源服务器在解析JWT时可以读取这个字段,做数据权限的隔离。类似的思路也可以扩展出role、departmentId等字段,关键是要统一各系统的声明命名规范,不然A系统读userId,B系统读user_id,维护起来会非常痛苦。
密钥管理是安全重灾区。Spring Authorization Server启动后会自动生成一个RSA密钥对,但重启后会改变,导致之前签发的JWT在重启后全部失效,资源服务器全部返回401。这个问题在测试环境不明显,因为没人会长期保存token去测重启;但在生产环境做滚动发布时,旧pod还没销毁、新pod已经启动,新旧密钥并存可能导致部分请求校验失败。
我的解决方案是:配置固定的密钥对,并将公钥发布到一个公开端点。做法是在配置里手动注入一个JWKSource:
java复制@Bean
public JWKSource<SecurityContext> jwkSource() {
RSAKey rsaKey = generateRsaKey();
JWKSet jwkSet = new JWKSet(rsaKey);
return new ImmutableJWKSet<>(jwkSet);
}
private static RSAKey generateRsaKey() {
KeyPair keyPair = generateRsaKeyPair();
RSAPublicKey publicKey = (RSAPublicKey) keyPair.getPublic();
RSAPrivateKey privateKey = (RSAPrivateKey) keyPair.getPrivate();
return new RSAKey.Builder(publicKey)
.privateKey(privateKey)
.keyID(UUID.randomUUID().toString())
.build();
}
生产环境建议把密钥对的生成和存储放到配置中心或者独立的密钥管理服务,不要在yml文件里硬编码私钥。也不建议每个实例各自生成一次密钥,这样多实例部署时A实例签发的token到了B实例可能验签失败。正确做法是:所有授权服务器实例共享同一套密钥。等后面如果想把授权服务器做成集群,这个点的设计会直接影响可用性。
3.5 跑通第一个授权码流程
配置完成后,我最推荐的方式是把项目启动起来,用浏览器手动走一遍完整流程,不要急着写代码对接。我现在把关键请求展示一遍,方便大家对照自己的项目排查:
首先让浏览器访问授权端点:
code复制GET http://localhost:8080/oauth2/authorize?client_id=admin-client&response_type=code&redirect_uri=http://localhost:8081/login/oauth2/code/gateway&scope=read
这时候浏览器会跳转到登录页,输入admin/123456后进入授权确认页,同意之后浏览器地址栏会变成:
code复制http://localhost:8081/login/oauth2/code/gateway?code=xxxxxx
这个code就是授权码,再用HTTP工具模拟后端调用令牌端点:
code复制POST http://localhost:8080/oauth2/token
Content-Type: application/x-www-form-urlencoded
client_id=admin-client
client_secret=admin-secret
grant_type=authorization_code
redirect_uri=http://localhost:8081/login/oauth2/code/gateway
code=xxxxxx
如果一切正常,会返回一个JSON,里面包含access_token、refresh_token、token_type和expires_in字段。拿到access_token后,可以到:
code复制GET http://localhost:8080/userinfo
Authorization: Bearer {your-token}
去验证令牌是否有效并获取用户信息。Spring Authorization Server默认支持/userinfo端点,但需要配置OidcUserInfoEndpointConfigurer相关的映射,首次接触这一块的注意别漏了。
4. 资源服务器侧:JWT校验与用户权限的落地
授权服务器搭好只是第一步,更核心的是资源服务器怎么信任这个JWT。我在这部分花了不少时间调试,给读者讲讲关键逻辑。
4.1 最小化资源服务器配置
每一个需要接入统一登录的业务系统,都要在自己的SpringBoot工程里配置资源服务器角色。配置思路是:启动时从授权服务器拉取公钥,之后每次请求都拿这个公钥验签JWT。
java复制@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Value("${auth.jwk-set-uri}")
private String jwkSetUri;
@Bean
public SecurityFilterChain resourceServerFilterChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests((authorize) -> authorize
.requestMatchers("/public/**").permitAll()
.anyRequest().authenticated()
)
.oauth2ResourceServer((resourceServer) -> resourceServer
.jwt((jwt) -> jwt
.jwkSetUri(jwkSetUri)
)
);
return http.build();
}
}
这里的jwk-set-uri配置为:http://localhost:8080/oauth2/jwks。Spring Boot启动时,资源服务器会通过这个地址拉取授权服务器的公钥集合,之后每次校验JWT都会从本地缓存里找对应密钥。整个过程不需要在资源服务器里存任何密码或密钥,安全性非常高。
我原来的几个业务系统比较老,还是用拦截器里自己解析token的方式。老接口的token存储在不同Redis里,互为孤岛。这次统一迁到JWT后,相当于把"每个系统各自存储用户凭证"的模式彻底干掉了,资源服务器不需要回源校验,性能提升非常明显。
4.2 从JWT声明映射到Spring Security权限
JWT里默认的scope字段并不能直接映射到Spring Security的ROLE_权限,需要手动做一次转换。这里提供一个通用的做法,把JWT里的scope和自定义的roles声明转成GrantedAuthority:
java复制@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthoritiesConverter = new JwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter();
jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return jwtAuthenticationConverter;
}
这里要解释一下authorityPrefix。Spring Security默认认为以ROLE_开头的权限才是角色,比如ROLE_ADMIN。但JWT里的声明名往往是roles,值可能是admin、user。经过转换后,这些就变成了ROLE_admin,这样在Controller里就能直接用@PreAuthorize("hasRole('admin')")来控制接口权限了。
setAuthoritiesClaimName的值取决于你在授权服务器自定义Token Customizer里放的字段名。如果自定义Customizer里写的是context.getClaims().claim("roles", roles),这里就写"roles"。两边字段名必须完全一致,否则权限永远为空,接口全部403,排查起来很难受。
4.3 网关场景下的Token透传
现在我们系统里多了一个新角色:API网关。网关本身不做业务,但它是所有请求的入口。在OAuth2落地过程中,网关负责统一校验JWT、获取当前用户信息,然后把用户信息通过Header透传给下游服务。
我的做法是在网关过滤器里解析JWT,把userId和tenant放到请求头中:
java复制@Component
public class JwtUserHeaderFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String authHeader = exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
try {
Jwt jwt = jwtDecoder.decode(token);
ServerWebExchange mutatedExchange = exchange.mutate()
.request(r -> r.header("X-User-Id", jwt.getClaimAsString("userId"))
.header("X-Tenant-Id", jwt.getClaimAsString("tenant")))
.build();
return chain.filter(mutatedExchange);
} catch (JwtException e) {
// 令牌非法
}
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return -100;
}
}
这种方式的优点非常明显:下游服务不再需要自己解析JWT,直接从Header里取X-User-Id就行。对只处理业务数据的服务来说,省去了引入一堆JWT相关依赖的麻烦。需要注意X-User-Id这类头信息是下游信任的来源,网关必须保证进来的请求里不可能伪造。建议网关在过滤时把外部请求自带的同名Header清空再赋值,防止用户绕过网关直接打下游服务时伪造身份。
5. 联调中踩到的坑和最终解决记录
下面这部分是我把整套流程真正跑通时踩到的一些问题,按出现频率排了优先级,写出来给各位参考。
5.1 令牌端点400错误:clientAuthenticationMethod不匹配
现象: 用HTTP客户端调用/oauth2/token时返回400,错误信息是Client authentication failed。
排查链路: 我一开始以为是client_id或client_secret写错了,反复核对后发现的坑出在clientAuthenticationMethod上。我在客户端注册时用的是CLIENT_SECRET_BASIC,也就是要求client_id和client_secret放在Authorization Basic头里。但我测试脚本用的POST请求方式是client_id=xxx&client_secret=xxx放在form body里,这种方式对应的是CLIENT_SECRET_POST认证方法。
解决方案: 两种做法都行。要么把注册的clientAuthenticationMethod改成CLIENT_SECRET_POST,要么在调用令牌端点时把凭证放到Basic Auth头部。我最终选了CLIENT_SECRET_BASIC,因为这个是标准OAuth2客户端库默认的实现方式,兼容性更好。这里建议初次接触OAuth2的朋友,先确认自己用的是哪个认证方法,再看报错日志的具体提示,不要上来就怀疑token过期或者密钥问题。
5.2 授权码换Token时invalid_grant错误
现象: 授权码拿到后,紧接着POST到/oauth2/token,返回invalid_grant。
排查链路: 这个错误的隐藏原因很多。最常见的原因是授权码只能使用一次,我排查时第一个怀疑点就是这里。确认授权码第一次使用后就过期后,再检查redirect_uri参数是否与之前发起授权时一致,不一致也会报这个错误。还有一点容易被忽视:授权码有过期时间,默认好像是5分钟,超过时间再换就会被拒绝。
解决方案: 遇到invalid_grant,按顺序排查:授权码是否已使用、是否过期、redirect_uri是否完全一致(包括末尾分隔符和参数写法)。我自己最终的问题出在redirect_uri没有做URL编码,非ASCII字符或特殊符号导致了比对不通过。统一使用与注册客户端时完全相同的字符串,就正常了。
5.3 资源服务器拿到401:Issuer白名单校验失败
现象: 资源服务器配置好JWK Set URI后,访问被保护的接口时返回401,日志里包含issuer相关的错误。
排查链路: Spring Security在JWT校验中默认会校验iss声明。如果授权服务器签发JWT时用的iss是http://localhost:8080,资源服务器的jwkSetUri也配了这个地址,但Spring Security内部还会对issuer做一次校验。当我直接在配置里写jwkSetUri时,issuer的校验可能因为uri和iss声明中的值不一致而失败——比如声明里是http://127.0.0.1:8080,配置里写的却是http://localhost:8080,ookies、host解析不同也会出问题。
解决方案: 在资源服务器配置里显式指定jwt.issuerUri,保证和授权服务器签发JWT时的iss一致:
java复制.jwt((jwt) -> jwt
.jwkSetUri(jwkSetUri)
.issuerUri("http://localhost:8080")
)
5.4 JWT过期后前端处理链路
整个OAuth2流程里,令牌过期是绕不开的问题。我的做法是:前端在每个请求的拦截器里检测401响应,拿到401后立即调用刷新令牌接口,用refresh_token换新的access_token,然后再把原请求重新发送一次。
http复制POST /oauth2/token
Content-Type: application/x-www-form-urlencoded
client_id=admin-client
client_secret=admin-secret
grant_type=refresh_token
refresh_token=xxxxx
这个流程本身不复杂,但有一个细节要提醒:刷新令牌也是会过期的,我设置的是12个小时。如果刷新令牌也过期了,前端必须清空本地token并跳回登录页面让用户重新登录。这个"二级跳"的交互逻辑需要前后端都确认好,否则用户会出现"登录状态莫名其妙失效,点一下就跳登录页"的体验问题。
5.5 集群部署时JWT校验偶发失败
现象: 授权服务器部署成多实例之后,有一部分请求突然401,刷新页面又好了。
排查链路: 这个问题的根源在上面密钥管理部分已经提过——每个实例都生成了自己的RSA密钥对。A实例签发的token到了B实例,B实例用自己生成的公钥去验签,自然失败。Spring Authorization Server虽然会在启动时生成密钥,但并没有做集群间的同步,多实例场景下必须自己管理密钥。
解决方案: 把密钥对生成过程剥出来,统一由配置中心管理。一种做法是把私钥存到数据库或密钥管理系统,应用启动时从那里读取,保证所有实例拿到的是同一把钥匙。另一种做法是生成密钥后写入共享存储,比如Nacos配置中心,用配置热更新的方式注入。我在生产环境选择了后者,把密钥对以JSON格式放在Nacos里,应用启动时读取并构建JWKSource,实测下来稳定很多。
6. 一些容易被忽略的扩展细节
6.1 授权码模式和PKCE
如果你的客户端是纯前端应用,比如Vue或React SPA,没有后端来保管client_secret,需要考虑使用PKCE(Proof Key for Code Exchange)模式。它能在授权码模式下防止授权码被截获后重放。Spring Authorization Server从0.4.x开始原生支持PKCE,配置时在RegisteredClient里加一个ClientSettings:
java复制ClientSettings.builder()
.requireProofKey(true)
.build()
这样前端在发起授权请求时,需要先生成一个随机的code_verifier,然后用SHA256算出code_challenge一起传给授权端点。令牌换取的请求里再带上当初的code_verifier,授权服务器会校验两者匹配。这个机制本质上是把"客户端有密钥"的安全性靠"密钥随机且一次性"来补足,对SPA应用来说是必须的。
6.2 客户端信息入库
InMemoryRegisteredClientRepository只适合学习和单机部署。生产环境需要把客户端注册信息持久化,否则每新增一个接入方,都要改代码重启授权服务器。Spring Authorization Server官方提供了JdbcRegisteredClientRepository,对应数据库表结构在官方文档里有现成SQL脚本。我建议一开始就做好表设计,不要先内存后JPA两套逻辑来回改,因为这玩意儿的表结构有大量JSON字段,用JPA自动建表反而容易出问题。
6.3 与Spring Security方法级安全配合
在资源服务器的业务接口里,用方法级权限控制非常方便:
java复制@RestController
@RequestMapping("/api/order")
public class OrderController {
@GetMapping("/list")
@PreAuthorize("hasAuthority('read')")
public List<Order> list() {
// 业务逻辑
}
@PostMapping("/create")
@PreAuthorize("hasAuthority('write')")
public Order create(@RequestBody Order order) {
// 业务逻辑
}
}
对应的,在授权服务器给客户端分配scope时,你要想清楚read和write分别对应哪些接口的权限。这个设计越早和业务方对齐越好,因为后期改scope名意味着所有接入方都要跟着改自己的权限配置表。
6.4 常见问题速查表
我把自己联调阶段的主要问题整理成了一张表,方便大家排查时直接对照:
| 现象 | 常见原因 | 解决方向 |
|---|---|---|
访问/oauth2/authorize 404 |
过滤器链顺序错误 | 调整SecurityFilterChain的@Order |
令牌端点报Client authentication failed |
Basic认证和POST表单认证不匹配 | 统一clientAuthenticationMethod |
授权码换Token报invalid_grant |
授权码复用/过期/redirect_uri不一致 | 按顺序排查三个可能点 |
| JWT验签401 | issuer不一致或密钥不匹配 | 配置issuerUri,集群共用密钥 |
| 页面权限全部403 | JWT角色声明未正确转换 | 检查JwtAuthenticationConverter |
| 重启后旧Token全部失效 | 默认密钥对每次重启重生成 | 固定RSA密钥对,配合配置中心 |
7. 收尾:我对这套方案的总体评价
从最开始研究OAuth2协议,到最终把授权服务器和资源服务器全部跑通,并接入管理后台和两个业务系统,整体耗时差不多两周。其中真正写代码的时间并不多,大部分精力花在理解协议流程和排查各种配置细节上。
这套架构最明显的收益是登录逻辑彻底统一了。原来每个业务系统各自维护一套Session或Token逻辑,安全等级和实现方式五花八门。现在所有系统都信任同一个JWT签发方,权限模型也收敛到一套,安全审计方便了很多。缺点是引入的复杂度不小,对于只有两三个内部系统的团队来说,自己搭授权服务器确实有点重量级,这时候用一个轻量级认证框架或者云厂商的认证服务可能更省心。
如果你们也在规划做类似的统一登录改造,我最后的建议是:先走通一个最小闭环再铺开接入。不要一开始就想着把所有系统都接上,先让一个新业务系统完整跑一遍流程,验证授权码模式、JWT校验、权限控制这条链路没问题,再逐步迁移存量系统。在OAuth2这种涉及多端的协议上,小步快跑比一次到位要稳妥得多。
