一、先明确边界:Resource Server 不负责登录

一个常见的 OAuth2 系统包含三个角色:

  • Authorization Server:登录用户、签发 Access Token、维护密钥;
  • Client:获取令牌并调用后端 API;
  • Resource Server:验证令牌,只向满足权限要求的请求开放资源。

业务服务属于 Resource Server。它不应该拿到用户密码,也不应该自己用一个共享字符串随意签发 JWT。它的职责是回答两个问题:

  1. 这个令牌是否可信、是否仍然有效?
  2. 令牌代表的主体是否有权执行当前操作?

JWT 只是令牌格式,不等于完整安全方案。Base64 解码任何人都能做,安全性来自签名验证和 Claim 校验。


二、请求进入 Spring Security 后发生了什么

请求携带令牌:

GET /api/orders/1001 HTTP/1.1
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...

核心链路如下:

BearerTokenAuthenticationFilter
        ↓
JwtAuthenticationProvider
        ↓
JwtDecoder:验签并校验 iss、exp、nbf、aud
        ↓
JwtAuthenticationConverter:把 Claim 转成 GrantedAuthority
        ↓
SecurityContextHolder
        ↓
URL / 方法级授权

Spring Security 默认把 scopescp 中的值映射成带 SCOPE_ 前缀的权限。例如:

{
  "sub": "user-10086",
  "scope": "orders.read orders.write"
}

会得到:

SCOPE_orders.read
SCOPE_orders.write

因此 hasAuthority("SCOPE_orders.read")hasRole("orders.read") 不是一回事。hasRole("ADMIN") 会自动检查 ROLE_ADMIN


三、引入 Resource Server 依赖

Maven 项目只需要让 Spring Boot 管理版本:

<dependencies>
    <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.boot</groupId>
        <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.security</groupId>
        <artifactId>spring-security-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>

Resource Server 的 Bearer Token 支持位于 spring-security-oauth2-resource-server,JWT 解码和 JOSE 支持位于 spring-security-oauth2-jose;使用 Boot Starter 时会统一引入。


四、用 issuer-uri 完成最小配置

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://id.example.com/realms/demo
          audiences: order-service

issuer-uri 必须与令牌中的 iss 完全一致。身份服务还应暴露 OIDC Discovery 或 OAuth2 Authorization Server Metadata,Spring Security 会由此发现 JWK Set 地址,并使用公开密钥验证签名。

audiences 用于校验 aud。只检查签名和签发者仍不充分:一个签给库存服务的令牌,不应被订单服务接受。

如果身份服务不提供 Discovery,可以显式配置:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://id.example.com/realms/demo
          jwk-set-uri: https://id.example.com/realms/demo/protocol/openid-connect/certs
          audiences: order-service

同时配置 issuer-urijwk-set-uri 的好处是:服务不必依赖 Discovery 获取密钥地址,但仍会校验 iss

不要关闭签名校验,也不要根据 JWT Header 中未经信任的 alg 任意选择算法。算法白名单和密钥轮换应由服务端配置控制。


五、配置接口授权规则

下面的配置使用无状态会话,并把健康检查、读订单、写订单和管理接口分开授权:

package com.example.order.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(
            HttpSecurity http,
            JwtAuthenticationConverter jwtAuthenticationConverter) throws Exception {

        return http
                .csrf(csrf -> csrf.disable())
                .sessionManagement(session -> session
                        .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(authorize -> authorize
                        .requestMatchers("/actuator/health").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/orders/**")
                            .hasAnyAuthority("SCOPE_orders.read", "ROLE_ADMIN")
                        .requestMatchers(HttpMethod.POST, "/api/orders/**")
                            .hasAuthority("SCOPE_orders.write")
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated())
                .oauth2ResourceServer(oauth2 -> oauth2
                        .jwt(jwt -> jwt
                                .jwtAuthenticationConverter(jwtAuthenticationConverter))
                        .authenticationEntryPoint(new JsonAuthenticationEntryPoint())
                        .accessDeniedHandler(new JsonAccessDeniedHandler()))
                .build();
    }
}

对只接受 Bearer Token 的 API,STATELESS 可以避免服务端创建登录会话。是否关闭 CSRF 要看认证方式:若 API 只从 Authorization Header 接受令牌且不依赖浏览器 Cookie,通常可以关闭;如果认证信息来自 Cookie,则不能照抄此配置。

规则应从具体到宽泛排列。最终保留 anyRequest().authenticated(),避免新接口因为漏配而意外公开。


六、同时映射 scope 与角色

不同身份平台的 Claim 结构不完全一致。假设令牌如下:

{
  "sub": "42",
  "preferred_username": "alice",
  "scope": "orders.read",
  "roles": ["ADMIN", "OPS"]
}

默认转换器只处理 scope。下面的配置保留默认 scope 映射,同时把 roles 转换为 ROLE_ 权限:

package com.example.order.security;

import java.util.ArrayList;
import java.util.Collection;
import java.util.List;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;

@Configuration
public class JwtAuthorityConfig {

    @Bean
    JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter scopeConverter =
                new JwtGrantedAuthoritiesConverter();

        JwtAuthenticationConverter authenticationConverter =
                new JwtAuthenticationConverter();

        authenticationConverter.setPrincipalClaimName("preferred_username");
        authenticationConverter.setJwtGrantedAuthoritiesConverter(jwt -> {
            Collection<GrantedAuthority> authorities = new ArrayList<>();

            Collection<GrantedAuthority> scopeAuthorities =
                    scopeConverter.convert(jwt);
            if (scopeAuthorities != null) {
                authorities.addAll(scopeAuthorities);
            }

            List<String> roles = jwt.getClaimAsStringList("roles");
            if (roles != null) {
                roles.stream()
                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                        .forEach(authorities::add);
            }
            return authorities;
        });

        return authenticationConverter;
    }
}

如果平台把角色放在 realm_access.roles 之类的嵌套结构中,应编写单独的 Claim 读取逻辑。不要把所有 JWT Claim 都直接转成权限,更不能信任客户端可以自行修改的业务 Header。

角色命名还要统一:令牌中存 ADMIN,服务内映射为 ROLE_ADMIN;不要让一部分接口检查 ADMIN,另一部分检查 ROLE_ADMIN


七、在业务方法上做细粒度授权

URL 规则适合保护入口,方法授权更接近业务语义:

package com.example.order.application;

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
public class OrderApplicationService {

    @PreAuthorize("hasAuthority('SCOPE_orders.read') or hasRole('ADMIN')")
    public OrderView findById(long orderId) {
        return loadOrder(orderId);
    }

    @PreAuthorize("hasAuthority('SCOPE_orders.cancel') and #operatorId == authentication.name")
    public void cancel(long orderId, String operatorId) {
        // 校验订单状态并执行取消
    }
}

@EnableMethodSecurity 会启用 @PreAuthorize。不过,不建议把复杂的数据权限全部写成很长的 SpEL。更易维护的方式是委托给授权组件:

@PreAuthorize("@orderAuthorization.canRead(#orderId, authentication)")
public OrderView findById(long orderId) {
    return loadOrder(orderId);
}

这样授权逻辑可以独立测试,也能清楚处理租户、资源归属和管理员例外。


八、正确区分 401 与 403

  • 401 Unauthorized:没有令牌,或令牌无法通过认证;
  • 403 Forbidden:令牌有效,身份已经确认,但权限不足。

统一 JSON 响应方便前端和网关识别:

package com.example.order.security;

import java.io.IOException;

import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.AuthenticationEntryPoint;

public class JsonAuthenticationEntryPoint implements AuthenticationEntryPoint {

    @Override
    public void commence(HttpServletRequest request,
                         HttpServletResponse response,
                         AuthenticationException exception) throws IOException {
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(
                "{\"code\":\"UNAUTHORIZED\",\"message\":\"访问令牌无效或已过期\"}");
    }
}
package com.example.order.security;

import java.io.IOException;

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;

public class JsonAccessDeniedHandler implements AccessDeniedHandler {

    @Override
    public void handle(HttpServletRequest request,
                       HttpServletResponse response,
                       AccessDeniedException exception) throws IOException {
        response.setStatus(HttpServletResponse.SC_FORBIDDEN);
        response.setContentType(MediaType.APPLICATION_JSON_VALUE);
        response.setCharacterEncoding("UTF-8");
        response.getWriter().write(
                "{\"code\":\"FORBIDDEN\",\"message\":\"当前身份没有操作权限\"}");
    }
}

生产环境不要把签名失败、密钥详情或内部异常堆栈返回给客户端。详细原因写入受控日志,响应只保留稳定错误码。


九、用 MockMvc 测试权限矩阵

安全配置需要自动化测试,不能只在浏览器里手工贴 Token:

package com.example.order.api;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.web.servlet.MockMvc;

@SpringBootTest
@AutoConfigureMockMvc
class OrderSecurityTest {

    @Autowired
    MockMvc mvc;

    @Test
    void noTokenShouldReturn401() throws Exception {
        mvc.perform(get("/api/orders/1001"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    void readScopeShouldBeAllowed() throws Exception {
        mvc.perform(get("/api/orders/1001")
                        .with(jwt()
                                .jwt(jwt -> jwt.subject("alice"))
                                .authorities(new SimpleGrantedAuthority(
                                        "SCOPE_orders.read"))))
                .andExpect(status().isOk());
    }

    @Test
    void unrelatedScopeShouldReturn403() throws Exception {
        mvc.perform(get("/api/orders/1001")
                        .with(jwt().authorities(
                                new SimpleGrantedAuthority("SCOPE_profile.read"))))
                .andExpect(status().isForbidden());
    }
}

这类测试不需要启动真实身份服务。还应增加角色映射、租户隔离、资源归属、过期令牌和错误 aud 的测试;其中验签与 Claim Validator 可以针对 JwtDecoder 单独做集成测试。


十、常见故障定位

现象 优先检查
所有请求都返回 401 issuer-uri 是否与 iss 完全一致,服务能否访问 Discovery/JWK Set
令牌可解析但验签失败 kid 是否存在于当前 JWK Set,算法是否在白名单,密钥是否刚轮换
明明有角色却返回 403 Claim 路径是否正确,ROLE_/SCOPE_ 前缀是否匹配
同一个令牌在别的服务可用 检查当前服务的 aud,不要只校验签名
偶发提示未生效或已过期 检查机器时间同步;仅为合理时钟偏差设置容忍值
方法注解不生效 是否启用 @EnableMethodSecurity,调用是否经过 Spring 代理

排查时可以安全记录 isssubaudkid 和鉴权结果,但不要记录完整 Bearer Token。完整令牌进入日志相当于把临时凭证复制到更多系统。


十一、生产落地清单

  • 使用非对称签名和 JWK Set,支持密钥轮换;
  • 同时校验签名、issaudexpnbf
  • Access Token 短时有效,撤销需求强时评估不透明令牌或撤销机制;
  • scope 表达客户端被授予的能力,角色表达组织内身份,两者不要混为一谈;
  • URL 层默认认证,业务层补充资源归属和租户隔离;
  • 401 与 403 分开处理,错误响应不泄露内部细节;
  • 不记录完整令牌,不把敏感个人信息放入 JWT;
  • 为每一条权限规则建立允许与拒绝两类测试;
  • 网关鉴权不能替代服务自身鉴权,内部网络也不是安全边界。

总结

Spring Security 已经解决了 JWT 验签、标准 Claim 校验、JWK 密钥轮换和认证上下文建立等基础问题。业务系统真正需要设计的是权限模型:外部 Claim 如何转换为稳定的内部权限,哪些规则放在 URL 层,哪些规则必须结合资源归属在方法层判断。

一套可靠的 Resource Server 方案可以归纳为:可信来源、严格校验、明确映射、默认拒绝、测试覆盖。做到这五点,JWT 才是安全边界的一部分,而不只是一个能被解析的字符串。

参考资料

Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐