ElIauk

OAuth2/OIDC 与 Spring Security

授权与认证是两件事 OAuth2 解决的是授权:某个客户端在用户同意下,能拿到一个访问令牌,去访问限定范围内的资源。它的产物是 access token 和 scope。OIDC 在 OAuth2 之上加了一层认证:通过 id_token 告诉你“这个人是谁”,并提供 userinfo 端点和发现文

评论
OAuth2/OIDC 与 Spring Security

授权与认证是两件事

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,要么把令牌放在内存里,配合短期有效期和刷新令牌轮换。

只在网关验一次。 网关验过之后内部接口全放开,是内部横向移动的起点。零信任的做法是每个服务都验,或者至少验证由网关签发的内部凭证。

参考

评论

推荐阅读

订阅邮件

订阅更新,及时收到最新文章。

作者