使用 Keycloak 公钥 (JWKS) 在本地进行 Spring Boot 安全令牌验证

发布: (2025年12月16日 GMT+8 16:46)
9 分钟阅读
原文: Dev.to

Source: Dev.to

抱歉,我无法直接访问外部链接获取文章内容。请您把需要翻译的文本粘贴到这里,我会按照您的要求将其翻译成简体中文并保留原有的格式、代码块和 URL。

角色

参与者角色
Keycloak(授权服务器 / 身份提供者)• 通过 OIDC/OAuth2 流程(授权码、客户端凭证等)对用户/客户端进行身份验证
• 颁发包含 isssubexpiatnbfaudscopepreferred_usernamerealm_access 等声明的 JWT 访问令牌
• 在域下的 JWKS 端点公开其签名密钥
Spring Boot API(资源服务器)• 暴露受保护的 REST 接口
• 使用 Spring Security 的 OAuth2 资源服务器支持来:
 - 从传入请求中提取 Authorization: Bearer <token>
 - 使用 Keycloak 的 JWKS 本地验证 JWT 签名和声明
 - 将声明/作用域映射为权限,并通过 @PreAuthorizehasRole 等进行访问控制
客户端(SPA、移动应用、B2B 等)• 通过相应的 OAuth2 流程从 Keycloak 获取令牌
• 调用 Spring API 时将访问令牌作为 Bearer 令牌发送

关键点: 令牌签发后,Spring 服务只需使用 Keycloak 的 公钥,而无需在每次请求时调用 Keycloak 来验证令牌。

令牌颁发流程(简体)

  1. 用户或后端客户端 登录(浏览器重定向 / OIDC 或客户端凭证)。

  2. Keycloak 验证凭证,构建用户的身份和角色,然后颁发 访问令牌(以及可选的刷新令牌)。

  3. 访问令牌是 JWT,使用 Keycloak 的私钥签名(通过 kid 标识)。

  4. Keycloak 在以下地址公开相应的 公钥

    https://<host>/realms/<realm>/.well-known/openid-configuration   (metadata)
    https://<host>/realms/<realm>/protocol/openid-connect/certs   (JWKS)
  5. 客户端现在持有一个自包含、已签名的 JWT。只要资源服务器知道公钥,就不需要进一步回调 Keycloak 进行验证。

配置 Spring Security 作为 OAuth2 资源服务器

选项 A – Issuer‑URI(推荐)

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm
  • Spring 会为发行者调用 /.well-known/openid-configuration
  • 从元数据中读取 jwks_uri
  • 使用 Keycloak JWKS 端点构建 JwtDecoder
  • 强制发行者验证(iss 声明必须与配置的发行者匹配)。

好处: 简单且稳健——如果 Keycloak 更改了 JWKS URL,但保持相同的发行者,元数据仍保持一致。

选项 B – JWKS‑Set‑URI(直接)

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs
  • 跳过发现,直接指向 JWKS 端点。
  • 通常与 issuer-uri 或自定义验证器一起使用,以仍然强制 iss

JWKS 缓存

Spring 默认的 JWT 支持(Nimbus) 缓存 密钥:

  • 从 Keycloak 获取 JWKS 一次(或在需要时)。
  • 将密钥 存储在内存中
  • 根据其缓存策略进行刷新(例如,在密钥轮换后出现新的 kid 时)。

结果:不对 Keycloak 进行每请求调用;仅在缓存过期或引入新密钥时偶尔获取。

请求处理流程(每个 API 调用)

  1. 传入的 HTTP 请求

    Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0...
  2. Bearer 令牌提取 – Spring Security 的过滤链提取令牌并将其传递给配置好的 JwtDecoder

  3. 令牌解析与签名验证

    • 解码 JWT 头部 → 读取 kid
    • 从 JWKS 缓存中查找匹配的公钥。
    • 验证签名(例如 RS256)。
    • 若验证失败,则拒绝认证。
  4. 声明(Claim)校验(标准检查)

    • exp – 未过期。
    • nbf – 当前有效。
    • iat – 可选的自定义校验。
    • iss – 必须与配置的发行者匹配。
    • aud – 可选,应该与你的 API 期望的受众/客户端 ID 匹配。
  5. 创建认证对象

    • 创建一个 Jwt 对象,并用从令牌声明中派生的权限包装成 JwtAuthenticationToken
    • 存入 SecurityContextHolder
  6. 在控制器/服务层进行授权

    @PreAuthorize("hasAuthority('SCOPE_my-scope')")
    @PreAuthorize("hasRole('admin')")   // after mapping

    决策完全基于已验证的令牌——不调用 Keycloak

  7. 错误处理 – 缺失/无效的令牌、签名错误、发行者不匹配或令牌过期会导致 401 Unauthorized403 Forbidden

Maven 依赖

<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.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-client</artifactId>
    <optional>true</optional>
</dependency>

示例 application.yml(部分)

server:
  port: 8080

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          # Choose one of the two options:
          issuer-uri: https://keycloak.example.com/realms/my-realm
          # jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs

TL;DR

  • Keycloak → 签发已签名的 JWT 并发布 JWKS。
  • Spring Boot(资源服务器) → 使用缓存的 JWKS 在本地验证 JWT(通过 issuer-urijwk-set-uri)。
  • 客户端 → 将 JWT 作为 Bearer 令牌发送。

不需要对每个请求都向 Keycloak 进行往返调用,同时仍可受益于密钥轮换、基于声明的安全以及 Spring 强大的授权注解。

port: 8080

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm

安全配置(Java)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/public/**").permitAll()
                .requestMatchers("/admin/**").hasRole("admin")
                .anyRequest().authenticated()
            )
            .oauth2ResourceServer(oauth2 -> oauth2
                .jwt(Customizer.withDefaults())
            );

        return http.build();
    }
}

将 Keycloak 角色映射到 Spring 权限

Keycloak 将角色放在诸如 realm_access.rolesresource_access.<client>.roles 的声明中。下面的转换器将这些角色映射为 Spring 的 ROLE_ 权限:

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.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;

import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;

@Configuration
public class JwtConverterConfig {

    @Bean
    public JwtAuthenticationConverter jwtAuthenticationConverter() {
        JwtGrantedAuthoritiesConverter defaultConverter = new JwtGrantedAuthoritiesConverter();

        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            Collection<GrantedAuthority> defaultAuthorities = defaultConverter.convert(jwt);

            Collection<GrantedAuthority> realmRoles = jwt.getClaimAsMap("realm_access") != null
                    ? ((Collection<String>) ((Map<?, ?>) jwt.getClaim("realm_access"))
                        .getOrDefault("roles", List.of()))
                        .stream()
                        .map(role -> new SimpleGrantedAuthority("ROLE_" + role))
                        .toList()
                    : List.of();

            return Stream.concat(defaultAuthorities.stream(), realmRoles.stream())
                    .toList();
        });

        return converter;
    }
}

将转换器接入安全配置:

.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)

现在,诸如 @PreAuthorize("hasRole('admin')") 的注解即可与 Keycloak 的领域角色 admin 配合使用。

附加 JWT 验证(受众、租户等)

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.*;

import java.util.List;

@Configuration
public class JwtDecoderConfig {

    private static final String EXPECTED_AUDIENCE = "my-api";

    @Bean
    public JwtDecoder jwtDecoder() {
        NimbusJwtDecoder jwtDecoder =
                JwtDecoders.fromIssuerLocation("https://keycloak.example.com/realms/my-realm");

        OAuth2TokenValidator<Jwt> withIssuer =
                JwtValidators.createDefaultWithIssuer("https://keycloak.example.com/realms/my-realm");

        OAuth2TokenValidator<Jwt> audienceValidator = jwt -> {
            List<String> audiences = jwt.getAudience();
            if (audiences != null && audiences.contains(EXPECTED_AUDIENCE)) {
                return OAuth2TokenValidatorResult.success();
            }
            OAuth2Error error = new OAuth2Error("invalid_token",
                    "The required audience is missing", null);
            return OAuth2TokenValidatorResult.failure(error);
        };

        jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator));
        return jwtDecoder;
    }
}

将解码器插入安全配置:

.oauth2ResourceServer(oauth2 -> oauth2
    .jwt(jwt -> jwt.decoder(jwtDecoder()))
)

JWKS 缓存与轮换检测

为了更频繁地获取 JWKS(例如每分钟一次),同时保持本地验证:

  1. 使用 NimbusJwtDecoderfromIssuerLocationwithJwkSetUri)。
  2. 使用缓存层(Spring Cache、Caffeine、Redis 等)自定义 JWKS 源。
  3. 为 JWKS 条目设置 TTL(例如 60 秒)。

这种方法使每个实例能够定期刷新 JWKS,快速检测密钥轮换,而不会影响请求级别的性能。

提示: 为了获得更好的性能并降低耦合,建议使用 JWT 验证(资源服务器模式)而不是令牌自省。

关键属性概述

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm
  • 使用基于角色的访问配置 SecurityFilterChain
  • 提供自定义的 JwtAuthenticationConverter,将 Keycloak realm 角色映射为 ROLE_ 权限。
  • 添加可选的 JwtDecoder Bean,用于受众(或其他声明)的校验。
  • 调整 JWKS 缓存 TTL,以便根据需要检测密钥轮换(例如,每 60 秒一次)。

Keycloak 集成技巧

  • jwk-set-uri – 配置此项,使 Spring 能够获取 Keycloak 的 JWKS。
  • 依赖 JWKS 缓存,以避免在每个请求都调用 Keycloak;根据密钥轮换敏感度与负载情况调节缓存 TTL。
  • 通过 JwtDecoder 验证器强制校验 issexpnbfaud 等声明(以及任何领域特定约束)。
  • 使用自定义 JwtAuthenticationConverter 将 Keycloak 的角色/声明映射到 Spring 权限,以保持授权逻辑简洁。
Back to Blog

相关文章

阅读更多 »