Spring Boot 安全防线:OAuth2 资源服务器与细粒度鉴权实战

一、认证不等于鉴权:微服务场景下的权限空洞

很多 Spring Boot 项目在安全上只做了"认证"——用户登录后拿到 JWT,后续请求携带 Token 即可访问所有接口。这在单体应用中勉强可行,但在微服务架构下会暴露严重的权限空洞:所有已认证用户拥有相同的访问范围,服务间调用缺乏身份传递,接口级别的操作权限完全缺失。

更常见的问题是 JWT 中塞入过多权限信息,导致 Token 体积膨胀到数 KB,每次请求都携带巨大的 Header。一旦权限变更,已签发的 Token 无法即时失效,只能等待过期。OAuth2 资源服务器模式提供了一种更合理的拆分——认证由授权服务器负责,资源服务器只做 Token 验证和鉴权,职责清晰且可独立演进。

二、OAuth2 资源服务器架构:Token 验证与权限加载

Spring Security 的 OAuth2 Resource Server 模块支持 JWT 和 Introspection 两种 Token 验证方式。JWT 方式本地验证签名,无需回调授权服务器,性能更优;Introspection 方式每次请求都向授权服务器校验 Token,支持即时撤销。

sequenceDiagram
    participant C as 客户端
    participant GW as API 网关
    participant RS as 资源服务器
    participant AS as 授权服务器
    participant DB as 权限数据库

    C->>AS: 登录获取 JWT
    AS-->>C: JWT (含 userId, scope)
    C->>GW: 携带 JWT 访问资源
    GW->>RS: 转发请求 + Token
    RS->>RS: 本地验证 JWT 签名
    RS->>DB: 根据 userId 查询细粒度权限
    DB-->>RS: 权限列表
    RS->>RS: 鉴权判断
    alt 权限不足
        RS-->>GW: 403 Forbidden
        GW-->>C: 权限不足
    else 权限通过
        RS-->>GW: 200 OK + 资源数据
        GW-->>C: 响应数据
    end

关键设计点在于"JWT 只携带身份标识,权限实时查询"。JWT 中只保留 sub(用户 ID)和 scope(OAuth2 标准范围),细粒度的接口权限由资源服务器从数据库或缓存中加载。这样既控制了 Token 体积,又支持权限的即时生效。

三、生产级代码实现:多层鉴权与自定义权限模型

3.1 资源服务器基础配置

@Configuration
@EnableWebSecurity
public class ResourceServerConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                // 公开端点
                .requestMatchers("/api/public/**").permitAll()
                // 健康检查
                .requestMatchers("/actuator/health").permitAll()
                // 其他请求需认证
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(jwt -> jwt
                    .jwtAuthenticationConverter(new CustomJwtAuthenticationConverter())
                )
                // Token 验证失败时的自定义响应
                .authenticationEntryPoint(new CustomAuthenticationEntryPoint())
                .accessDeniedHandler(new CustomAccessDeniedHandler())
            )
            // 禁用 CSRF(无状态 API)
            .csrf(CsrfConfigurer::disable)
            // 无状态会话
            .sessionManagement(session ->
                session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
            );

        return http.build();
    }
}

3.2 自定义 JWT 转换器:从 Token 到权限

public class CustomJwtAuthenticationConverter
        implements Converter<Jwt, AbstractAuthenticationToken> {

    private final PermissionService permissionService;

    @Override
    public AbstractAuthenticationToken convert(Jwt jwt) {
        // 从 JWT 中提取用户标识
        String userId = jwt.getSubject();
        String tenantId = jwt.getClaimAsString("tenant_id");

        // 构建基础权限集合(来自 JWT 的 scope)
        Collection<GrantedAuthority> authorities = extractScopes(jwt);

        // 加载细粒度权限(从缓存或数据库)
        Collection<GrantedAuthority> fineGrainedAuthorities =
            permissionService.loadPermissions(userId, tenantId);

        // 合并权限
        List<GrantedAuthority> allAuthorities = new ArrayList<>(authorities);
        allAuthorities.addAll(fineGrainedAuthorities);

        return new JwtAuthenticationToken(jwt, allAuthorities, userId);
    }

    private Collection<GrantedAuthority> extractScopes(Jwt jwt) {
        String scope = jwt.getClaimAsString("scope");
        if (scope == null || scope.isEmpty()) {
            return Collections.emptyList();
        }
        return Arrays.stream(scope.split(" "))
            .map(s -> new SimpleGrantedAuthority("SCOPE_" + s))
            .collect(Collectors.toList());
    }
}

3.3 方法级鉴权:自定义权限注解

@Target({ElementType.METHOD, ElementType.TYPE})
@Retention(RetentionPolicy.RUNTIME)
@PreAuthorize("hasPermission(#resourceId, #resourceType, #action)")
public @interface RequirePermission {
    String resourceType();
    String action();
}

// 权限评估器实现
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {

    private final PermissionRepository permissionRepo;

    @Override
    public boolean hasPermission(Authentication authentication,
                                  Object resourceId,
                                  Object permission) {
        if (authentication == null || !authentication.isAuthenticated()) {
            return false;
        }

        String userId = authentication.getName();
        // permission 格式: "resourceType:action"
        String[] parts = permission.toString().split(":");
        if (parts.length != 2) {
            return false;
        }

        String resourceType = parts[0];
        String action = parts[1];

        // 检查用户是否拥有该资源的操作权限
        return permissionRepo.hasPermission(userId,
            resourceId.toString(), resourceType, action);
    }

    @Override
    public boolean hasPermission(Authentication authentication,
                                  Serializable targetId,
                                  String targetType,
                                  Object permission) {
        return hasPermission(authentication, targetId,
            targetType + ":" + permission);
    }
}

3.4 服务间调用的身份传递

@Component
public class ServiceAuthenticationInterceptor implements ClientHttpRequestInterceptor {

    private final TokenRelayService tokenRelay;

    @Override
    public ClientHttpResponse intercept(HttpRequest request,
                                         byte[] body,
                                         ClientHttpRequestExecution execution)
            throws IOException {
        // 从当前上下文获取用户身份
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();

        if (auth instanceof JwtAuthenticationToken jwtAuth) {
            // 传递原始 JWT,下游服务可验证
            request.getHeaders().set("Authorization",
                "Bearer " + jwtAuth.getToken().getTokenValue());
            // 附加服务间调用标识
            request.getHeaders().set("X-Caller-Service", "order-service");
        } else {
            // 服务间内部调用,使用服务账号 Token
            String serviceToken = tokenRelay.getServiceToken();
            request.getHeaders().setBearerAuth(serviceToken);
        }

        return execution.execute(request, body);
    }
}

四、安全架构的隐性代价:性能、复杂度与密钥轮换

权限实时查询的性能开销:每次请求都查询数据库获取权限,在高并发场景下会成为瓶颈。建议采用两级缓存——本地 Caffeine 缓存(TTL 30 秒)+ Redis 缓存(TTL 5 分钟),权限变更时主动失效缓存。缓存的代价是权限生效延迟,需根据业务安全等级权衡 TTL。

JWT 密钥轮换的窗口风险:RSA 密钥需要定期轮换,但轮换期间旧 Token 可能仍被使用。Spring Security 支持配置多个验证密钥(jwkSetUri 自动处理),但自签发场景需手动维护密钥列表。建议设置 15 分钟的密钥重叠期,新旧密钥同时有效。

细粒度鉴权的维护成本:每个接口都配置权限注解,随着业务增长,权限矩阵会变得极其复杂。建议按资源类型而非接口粒度配置权限,通过 RBAC + ABAC 混合模型降低复杂度。角色覆盖 80% 的通用场景,属性规则覆盖 20% 的特殊场景。

服务间 Token 传递的信任边界:下游服务收到上游传递的 JWT 后,无法区分请求是来自用户还是服务。建议在 JWT 中增加 azp(authorized party)声明,或在 Header 中增加 X-Caller-Service 标识,下游服务据此决定是否信任该请求的权限范围。

五、总结

Spring Boot 安全架构的核心原则是"认证集中、鉴权分散"——授权服务器负责签发 Token,资源服务器负责验证和鉴权。本文方案的关键实现为:JWT 轻量化(只携带身份标识)、权限实时加载(缓存 + 数据库)、方法级鉴权(自定义注解 + PermissionEvaluator)、服务间身份传递(Token Relay)。落地时需重点关注三个配置:JWT 签名算法(建议 RS256,避免 HS256 的密钥分发问题)、权限缓存 TTL(建议 30 秒本地 + 5 分钟 Redis)、密钥轮换周期(建议 90 天,重叠期 15 分钟)。上线前务必进行权限矩阵的全面审计,确保无越权接口。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐