你不能在spring-boot 3中使用Keycloak适配器,原因你已经找到了,还有一些与传递依赖关系相关的其他原因。由于大多数Keycloak适配器在2022年初已被弃用,很可能不会发布更新来修复这个问题。
相反,使用spring-security 6库进行OAuth2。 不要慌张,使用spring-boot很容易实现。
接下来,我假设你对OAuth2的概念有很好的理解,并且清楚为什么需要配置OAuth2客户端或OAuth2资源服务器。 如果有疑问,请参考我的教程中的OAuth2基础知识部分。
我只会在这里详细介绍将servlet应用程序配置为资源服务器的方法,然后再介绍将其配置为客户端的方法,针对一个单独的Keycloak领域,以及使用和不使用我的Spring Boot starter
spring-addons-starter-oidc
。如果您不想使用"我的" starter,可以直接浏览您感兴趣的部分(但请准备编写更多的代码)。
另外,请参考
我的教程,了解不同的用例,例如:
- 接受由多个领域或实例(事先已知或在受信任的域中动态创建)发行的令牌
- 响应式应用程序(WebFlux),例如
spring-cloud-gateway
- 公开提供REST API和用于消费API的服务器端渲染UI的应用程序
- 高级访问控制规则
- BFF模式
- ...
1. OAuth2资源服务器
应用程序通过访问令牌保护了一个REST API,它被OAuth2 REST客户端所使用。以下是一些此类客户端的示例:
- 另一个配置为OAuth2客户端并使用
WebClient
,@FeignClient
,RestTemplate
或类似工具的Spring应用程序
- 像Postman这样的开发工具,能够获取OAuth2令牌并发出REST请求
- 基于Javascript的应用程序,配置为“public” OAuth2客户端,使用类似angular-auth-oidc-client的库
1.1. 使用spring-addons-starter-oidc
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-starter-oidc</artifactId>
<version>7.1.3</version>
</dependency>
origins: http://localhost:4200
issuer: http://localhost:8442/realms/master
com:
c4-soft:
springaddons:
oidc:
ops:
- iss: ${issuer}
username-claim: preferred_username
authorities:
- path: $.realm_access.roles
prefix: ROLE_
- path: $.resource_access.*.roles
resourceserver:
cors:
- path: /**
allowed-origin-patterns: ${origins}
permit-all:
- "/actuator/health/readiness"
- "/actuator/health/liveness"
- "/v3/api-docs/**"
上面的配置文件中的领域角色前缀仅用于说明目的,您可以将其删除。CORS配置也需要进行一些改进。
@Configuration
@EnableMethodSecurity
public static class WebSecurityConfig { }
没有更多需要配置资源服务器的细粒度CORS策略和权限映射了。多么美妙啊!
从`ops`属性是一个数组可以猜到,这个解决方案实际上与“静态”多租户兼容:您可以声明尽可能多的受信任的发行者,并且可以是异构的(使用不同的声明来表示用户名和权限)。
此外,这个解决方案与响应式应用程序兼容:`spring-addons-starter-oidc`将根据类路径上的内容检测到它,并自适应其安全自动配置。
1.2. 只需`spring-boot-starter-oauth2-resource-server`
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
</dependency>
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8442/realms/master
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public static class WebSecurityConfig {
@Bean
SecurityFilterChain filterChain(HttpSecurity http, Converter<Jwt, ? extends AbstractAuthenticationToken> jwtAuthenticationConverter) throws Exception {
http.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)));
http.cors(cors -> cors.configurationSource(corsConfigurationSource("http://localhost:4200")));
http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
http.csrf(csrf -> csrf.disable());
http.exceptionHandling(eh -> eh.authenticationEntryPoint((request, response, authException) -> {
response.addHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer realm=\"Restricted Content\"");
response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
}));
http.authorizeHttpRequests(accessManagement -> accessManagement
.requestMatchers("/actuator/health/readiness", "/actuator/health/liveness", "/v3/api-docs/**").permitAll()
.anyRequest().authenticated()
);
return http.build();
}
private UrlBasedCorsConfigurationSource corsConfigurationSource(String... origins) {
final var configuration = new CorsConfiguration();
configuration.setAllowedOrigins(Arrays.asList(origins));
configuration.setAllowedMethods(List.of("*"));
configuration.setAllowedHeaders(List.of("*"));
configuration.setExposedHeaders(List.of("*"));
final var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
@RequiredArgsConstructor
static class JwtGrantedAuthoritiesConverter implements Converter<Jwt, Collection<? extends GrantedAuthority>> {
@Override
@SuppressWarnings({ "rawtypes", "unchecked" })
public Collection<? extends GrantedAuthority> convert(Jwt jwt) {
return Stream.of("$.realm_access.roles", "$.resource_access.*.roles").flatMap(claimPaths -> {
Object claim;
try {
claim = JsonPath.read(jwt.getClaims(), claimPaths);
} catch (PathNotFoundException e) {
claim = null;
}
if (claim == null) {
return Stream.empty();
}
if (claim instanceof String claimStr) {
return Stream.of(claimStr.split(","));
}
if (claim instanceof String[] claimArr) {
return Stream.of(claimArr);
}
if (Collection.class.isAssignableFrom(claim.getClass())) {
final var iter = ((Collection) claim).iterator();
if (!iter.hasNext()) {
return Stream.empty();
}
final var firstItem = iter.next();
if (firstItem instanceof String) {
return (Stream<String>) ((Collection) claim).stream();
}
if (Collection.class.isAssignableFrom(firstItem.getClass())) {
return (Stream<String>) ((Collection) claim).stream().flatMap(colItem -> ((Collection) colItem).stream()).map(String.class::cast);
}
}
return Stream.empty();
})
.map(SimpleGrantedAuthority::new)
.map(GrantedAuthority.class::cast).toList();
}
}
@Component
@RequiredArgsConstructor
static class SpringAddonsJwtAuthenticationConverter implements Converter<Jwt, JwtAuthenticationToken> {
@Override
public JwtAuthenticationToken convert(Jwt jwt) {
final var authorities = new JwtGrantedAuthoritiesConverter().convert(jwt);
final String username = JsonPath.read(jwt.getClaims(), "preferred_username");
return new JwtAuthenticationToken(jwt, authorities, username);
}
}
}
除了比前面的解决方案冗长之外,这个解决方案也不够灵活:
- 不适用于多租户(多个Keycloak领域或实例)
- 硬编码的允许来源
- 硬编码的声明名称以获取权限
- 硬编码的"permitAll"路径匹配器
2. OAuth2客户端
应用程序公开了通过会话进行安全保护的任何类型的资源(而不是访问令牌)。它直接由浏览器(或任何其他能够维护会话的用户代理)消费,无需脚本语言或OAuth2客户端库(Spring在服务器上处理授权码流、注销和令牌存储)。常见用例包括:
- 使用服务器端渲染的UI的应用程序(使用Thymeleaf、JSF或其他工具)
- 作为OAuth2客户端配置的"spring-cloud-gateway",使用"TokenRelay"过滤器(将OAuth2令牌从浏览器隐藏,并在将请求转发到下游资源服务器之前,用访问令牌替换会话cookie)
请注意,
Back-Channel Logout
目前尚未由Spring实现。如果您需要它,请使用“my”启动器(或从中复制)。
2.1. 使用spring-addons-starter-oidc
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-client</artifactId>
</dependency>
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-addons-starter-oidc</artifactId>
<version>7.1.3</version>
</dependency>
issuer: http://localhost:8442/realms/master
client-id: spring-addons-confidential
client-secret: change-me
client-uri: http://localhost:8080
spring:
security:
oauth2:
client:
provider:
keycloak:
issuer-uri: ${issuer}
registration:
keycloak-login:
authorization-grant-type: authorization_code
client-name: My Keycloak instance
client-id: ${client-id}
client-secret: ${client-secret}
provider: keycloak
scope: openid,profile,email,offline_access
com:
c4-soft:
springaddons:
oidc:
ops:
- iss: ${issuer}
username-claim: preferred_username
authorities:
- path: $.realm_access.roles
- path: $.resource_access.*.roles
client:
client-uri: ${client-uri}
security-matchers: /**
permit-all:
- /
- /login/**
- /oauth2/**
csrf: cookie-accessible-from-js
post-login-redirect-path: /home
post-logout-redirect-path: /
back-channel-logout-enabled: true
@Configuration
@EnableMethodSecurity
public class WebSecurityConfig {
}
关于资源服务器,这个解决方案也适用于响应式应用。
客户端还可以选择性地支持多租户:允许用户同时登录多个OpenID提供者,在这些提供者上可能有不同的用户名(默认情况下是
subject
,在Keycloak中是一个UUID,并且每个领域都会发生变化)。
2.2. 只使用
spring-boot-starter-oauth2-client
。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
</dependency>
<dependency>
<groupId>com.jayway.jsonpath</groupId>
<artifactId>json-path</artifactId>
</dependency>
issuer: http://localhost:8442/realms/master
client-id: spring-addons-confidential
client-secret: change-me
spring:
security:
oauth2:
client:
provider:
keycloak:
issuer-uri: ${issuer}
registration:
keycloak-login:
authorization-grant-type: authorization_code
client-name: My Keycloak instance
client-id: ${client-id}
client-secret: ${client-secret}
provider: keycloak
scope: openid,profile,email,offline_access
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class WebSecurityConfig {
@Bean
SecurityFilterChain
clientSecurityFilterChain(HttpSecurity http, InMemoryClientRegistrationRepository clientRegistrationRepository)
throws Exception {
http.oauth2Login(withDefaults());
http.logout(logout -> {
logout.logoutSuccessHandler(new OidcClientInitiatedLogoutSuccessHandler(clientRegistrationRepository));
});
http.authorizeHttpRequests(ex -> ex
.requestMatchers("/", "/login/**", "/oauth2/**").permitAll()
.requestMatchers("/nice.html").hasAuthority("NICE")
.anyRequest().authenticated());
return http.build();
}
@Component
@RequiredArgsConstructor
static class GrantedAuthoritiesMapperImpl implements GrantedAuthoritiesMapper {
@Override
public Collection<? extends GrantedAuthority> mapAuthorities(Collection<? extends GrantedAuthority> authorities) {
Set<GrantedAuthority> mappedAuthorities = new HashSet<>();
authorities.forEach(authority -> {
if (OidcUserAuthority.class.isInstance(authority)) {
final var oidcUserAuthority = (OidcUserAuthority) authority;
final var issuer = oidcUserAuthority.getIdToken().getClaimAsURL(JwtClaimNames.ISS);
mappedAuthorities.addAll(extractAuthorities(oidcUserAuthority.getIdToken().getClaims()));
} else if (OAuth2UserAuthority.class.isInstance(authority)) {
try {
final var oauth2UserAuthority = (OAuth2UserAuthority) authority;
final var userAttributes = oauth2UserAuthority.getAttributes();
final var issuer = new URL(userAttributes.get(JwtClaimNames.ISS).toString());
mappedAuthorities.addAll(extractAuthorities(userAttributes));
} catch (MalformedURLException e) {
throw new RuntimeException(e);
}
}
});
return mappedAuthorities;
};
@SuppressWarnings({ "rawtypes", "unchecked" })
private static Collection<GrantedAuthority> extractAuthorities(Map<String, Object> claims) {
}
}
}
这个starter是一个标准的Spring Boot starter,它附带了额外的应用程序属性,用于自动配置默认的beans并提供给Spring Security。 需要注意的是,自动配置的@Beans
几乎都是@ConditionalOnMissingBean
,这使得您可以在您的配置中覆盖它。
它是开源的,您可以更改它为您预配置的所有内容(请参阅Javadoc、starter的README或者众多示例)。 在决定不信任它之前,您应该阅读starter的源代码,它并不是很大。从imports
资源开始,它定义了Spring Boot用于自动配置的加载内容。
在我看来(并且如上所示),Spring Boot对于OAuth2的自动配置可以进一步推进,以实现以下目标:
- 使OAuth2配置更具可移植性:通过可配置的权限转换器,从一个OIDC提供者切换到另一个只需要编辑属性即可(例如Keycloak、Auth0、Cognito、Azure AD等)。
- 简化在不同环境中的应用部署:CORS配置可以通过属性文件进行控制。
- 大幅减少Java代码的量(如果处于多租户场景中,情况会更加复杂)。
- 默认支持多个发行者。
- 减少配置错误的机会(例如,经常看到在客户端上禁用CSRF保护,或者在使用访问令牌保护的端点上浪费资源的示例配置)。