使用 Keycloak 公钥 (JWKS) 在本地进行 Spring Boot 安全令牌验证
Source: Dev.to
抱歉,我无法直接访问外部链接获取文章内容。请您把需要翻译的文本粘贴到这里,我会按照您的要求将其翻译成简体中文并保留原有的格式、代码块和 URL。
角色
| 参与者 | 角色 |
|---|---|
| Keycloak(授权服务器 / 身份提供者) | • 通过 OIDC/OAuth2 流程(授权码、客户端凭证等)对用户/客户端进行身份验证 • 颁发包含 iss、sub、exp、iat、nbf、aud、scope、preferred_username、realm_access 等声明的 JWT 访问令牌• 在域下的 JWKS 端点公开其签名密钥 |
| Spring Boot API(资源服务器) | • 暴露受保护的 REST 接口 • 使用 Spring Security 的 OAuth2 资源服务器支持来: - 从传入请求中提取 Authorization: Bearer <token>- 使用 Keycloak 的 JWKS 本地验证 JWT 签名和声明 - 将声明/作用域映射为权限,并通过 @PreAuthorize、hasRole 等进行访问控制 |
| 客户端(SPA、移动应用、B2B 等) | • 通过相应的 OAuth2 流程从 Keycloak 获取令牌 • 调用 Spring API 时将访问令牌作为 Bearer 令牌发送 |
关键点: 令牌签发后,Spring 服务只需使用 Keycloak 的 公钥,而无需在每次请求时调用 Keycloak 来验证令牌。
令牌颁发流程(简体)
-
用户或后端客户端 登录(浏览器重定向 / OIDC 或客户端凭证)。
-
Keycloak 验证凭证,构建用户的身份和角色,然后颁发 访问令牌(以及可选的刷新令牌)。
-
访问令牌是 JWT,使用 Keycloak 的私钥签名(通过
kid标识)。 -
Keycloak 在以下地址公开相应的 公钥:
https://<host>/realms/<realm>/.well-known/openid-configuration (metadata) https://<host>/realms/<realm>/protocol/openid-connect/certs (JWKS) -
客户端现在持有一个自包含、已签名的 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 调用)
-
传入的 HTTP 请求
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0... -
Bearer 令牌提取 – Spring Security 的过滤链提取令牌并将其传递给配置好的
JwtDecoder。 -
令牌解析与签名验证
- 解码 JWT 头部 → 读取
kid。 - 从 JWKS 缓存中查找匹配的公钥。
- 验证签名(例如 RS256)。
- 若验证失败,则拒绝认证。
- 解码 JWT 头部 → 读取
-
声明(Claim)校验(标准检查)
exp– 未过期。nbf– 当前有效。iat– 可选的自定义校验。iss– 必须与配置的发行者匹配。aud– 可选,应该与你的 API 期望的受众/客户端 ID 匹配。
-
创建认证对象
- 创建一个
Jwt对象,并用从令牌声明中派生的权限包装成JwtAuthenticationToken。 - 存入
SecurityContextHolder。
- 创建一个
-
在控制器/服务层进行授权
@PreAuthorize("hasAuthority('SCOPE_my-scope')") @PreAuthorize("hasRole('admin')") // after mapping决策完全基于已验证的令牌——不调用 Keycloak。
-
错误处理 – 缺失/无效的令牌、签名错误、发行者不匹配或令牌过期会导致
401 Unauthorized或403 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-uri或jwk-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.roles 和 resource_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(例如每分钟一次),同时保持本地验证:
- 使用
NimbusJwtDecoder(fromIssuerLocation或withJwkSetUri)。 - 使用缓存层(Spring Cache、Caffeine、Redis 等)自定义 JWKS 源。
- 为 JWKS 条目设置 TTL(例如 60 秒)。
这种方法使每个实例能够定期刷新 JWKS,快速检测密钥轮换,而不会影响请求级别的性能。
提示: 为了获得更好的性能并降低耦合,建议使用 JWT 验证(资源服务器模式)而不是令牌自省。
关键属性概述
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/my-realm
- 使用基于角色的访问配置
SecurityFilterChain。 - 提供自定义的
JwtAuthenticationConverter,将 Keycloak realm 角色映射为ROLE_权限。 - 添加可选的
JwtDecoderBean,用于受众(或其他声明)的校验。 - 调整 JWKS 缓存 TTL,以便根据需要检测密钥轮换(例如,每 60 秒一次)。
Keycloak 集成技巧
jwk-set-uri– 配置此项,使 Spring 能够获取 Keycloak 的 JWKS。- 依赖 JWKS 缓存,以避免在每个请求都调用 Keycloak;根据密钥轮换敏感度与负载情况调节缓存 TTL。
- 通过
JwtDecoder验证器强制校验iss、exp、nbf、aud等声明(以及任何领域特定约束)。 - 使用自定义
JwtAuthenticationConverter将 Keycloak 的角色/声明映射到 Spring 权限,以保持授权逻辑简洁。