授权与认证是两件事
OAuth2 解决的是授权:某个客户端在用户同意下,能拿到一个访问令牌,去访问限定范围内的资源。它的产物是 access token 和 scope。OIDC 在 OAuth2 之上加了一层认证:通过 id_token 告诉你“这个人是谁”,并提供 userinfo 端点和发现文档。
搞混这两件事,就会写出“用 access token 去取用户信息并当成身份凭据”的代码。access token 的受众是资源服务器,不是客户端,它本来就可能是不可解析的随机串,也可能没有用户标识。
2026 年的默认选择很明确:新系统一律走 OIDC + 授权码模式 + PKCE,隐式流和密码模式都已明确不推荐。
授权码 + PKCE 的流程
授权码模式把令牌交换放在后端完成,令牌不会出现在浏览器地址栏。PKCE 在其上增加了一对 code_verifier / code_challenge,防止授权码被截获后被人拿去换令牌。
关键在于三个参数不能省:state 防 CSRF,nonce 防 ID Token 重放,redirect_uri 必须白名单精确匹配。前端是 SPA 时,最好采用 BFF 模式:令牌只存在后端,浏览器拿 HttpOnly Cookie,这样 XSS 也偷不到令牌。
Spring Security 6.5 的两种角色
同一套应用常常既是客户端(调别人)又是资源服务器(被别人调),Spring Security 对这两个角色的配置是分开的:
- 客户端:
spring-boot-starter-oauth2-client,负责发起登录、换令牌、保存会话; - 资源服务器:
spring-boot-starter-oauth2-resource-server,负责验签、解析 scope、做接口级授权。
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/shop
jwk-set-uri: https://auth.example.com/realms/shop/protocol/openid-connect/certs
issuer-uri 用于校验 iss 声明并做发现,jwk-set-uri 用于取公钥。只写前者,启动时会去访问发现端点,网络不通就起不来;两个都写,启动不依赖网络,运行期再拉公钥,更适合容器化部署。
令牌校验与权限映射
默认的 JwtAuthenticationConverter 会把 scope 声明拆成 SCOPE_xxx 权限。但不同 IdP 的声明格式差别很大:有的是空格分隔的字符串,有的是数组,有的是 scp,还有的把角色放在 roles 或 realm_access.roles 里。映射逻辑必须显式写清楚,否则线上会出现“令牌有效但 403”的排查困难。
@Configuration
@EnableWebSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain securityFilterChain(
HttpSecurity http,
JwtAuthenticationConverter jwtAuthenticationConverter) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health/**").permitAll()
.requestMatchers(HttpMethod.GET, "/api/orders/**").hasAuthority("SCOPE_order:read")
.requestMatchers(HttpMethod.POST, "/api/orders").hasAuthority("SCOPE_order:write")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)));
return http.build();
}
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Set<GrantedAuthority> authorities = new HashSet<>();
String scope = jwt.getClaimAsString("scope");
if (scope != null) {
Arrays.stream(scope.split(" "))
.filter(item -> !item.isBlank())
.forEach(item -> authorities.add(new SimpleGrantedAuthority("SCOPE_" + item)));
}
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles != null) {
roles.forEach(role -> authorities.add(new SimpleGrantedAuthority("ROLE_" + role)));
}
return authorities;
});
return converter;
}
}
服务之间调用时,用户令牌要不要继续往下传,是一个架构决策。透传(token relay)让下游能拿到用户身份,但令牌的受众不是你,跨服务扩散会放大泄露面;更稳妥的是用 client_credentials 换取服务身份,或者用 RFC 8693 的令牌交换,把用户身份收敛成短期、窄受众的新令牌。
@Component
public class TokenRelayInterceptor implements RequestInterceptor {
@Override
public void apply(RequestTemplate template) {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication instanceof JwtAuthenticationToken jwtAuth) {
template.header(HttpHeaders.AUTHORIZATION, "Bearer " + jwtAuth.getToken().getTokenValue());
}
}
}
常见坑
issuer-uri 末尾多一个斜杠。 这是最经典的 401:令牌里的 iss 是 https://auth.example.com/realms/shop,配置里写成带斜杠的版本,验签全部失败,报错是“Jwt issuer claim not valid”,看起来像令牌本身有问题。
只验签名不验受众。 没有校验 aud 的话,为别的系统签发的令牌也能访问你的接口,等于把授权边界交给了 IdP 的配置。
把 scope 当角色用。 scope 表示“客户端被授权做什么”,角色表示“用户是谁”。混用之后,一个拥有 order:write scope 的服务账号就能代表任意用户下单。
时间偏差没留余量。 令牌刚好在边界过期,或者签发方与资源服务器有几十秒时钟偏差,会出现间歇性 401,重启后又能用,非常难复现。校验器默认允许的 skew 通常是 60 秒,不要随意改成 0。
令牌存在 localStorage。 一次 XSS 就能把患者、订单、资金全部带走,而且用户无感。前端要么走 BFF,要么把令牌放在内存里,配合短期有效期和刷新令牌轮换。
只在网关验一次。 网关验过之后内部接口全放开,是内部横向移动的起点。零信任的做法是每个服务都验,或者至少验证由网关签发的内部凭证。
评论