Keycloak 文档提出了 3 种保护基于 Spring 的 REST APIS 的方法。
1,使用 Keycloak Spring Boot 适配器
2,使用 keycloak Spring 安全适配器
3,使用 OpenID Connect (OIDC)+ OAuth2
让我们看看如何使用 Keycloak OIDC 支持和 Spring OAuth2 库来保护 REST API。本文末尾解释了使用 Spring OAuth2 而不是 Keycloak Adapter 的好处。
让我们探索如何使用 Spring OAuth2 库设置 Keycloak 并与之交互。
第1步:开始使用 Keycloak
请参阅 Keycloak 入门文档以运行和设置 keycloak 管理员用户。
运行 Keycloak 后,使用http://localhost:8080/auth访问 keycloak 管理控制台
设置keycloak 用户名=admin,密码=admin。
第2步:创建开发领域
第3步:创建客户端(微服务)
Client ID : employee-service
Client Protocol : openid-connect
第4步:配置客户端
如果 Keycloak 在端口 8080 上运行,请确保您的微服务在另一个端口上运行。在示例中,微服务配置为在 8085 上运行。
Valid Redirect URIs : http://localhost:8085
Service Accounts Enabled : On
Authorization Enabled : On
注意:访问类型confidential支持使用client credentials grant以及authorization code grant来获取访问令牌。如果一个微服务需要调用另一个微服务,调用者将是“confidential”,而被调用者将是“bearer-only”
第5步:创建客户端角色
在客户端下创建一个角色。在这种情况下,角色 USER 是在员工服务下创建的。
第6步:创建一个映射器(Mapper)(在访问令牌中获取user_name)
Keycloak 访问令牌是JWT。它是一个JSON,该JSON中的每个字段都称为声明(claim)。默认情况下,登录用户名在访问令牌中名为“preferred_username”的声明中返回。Spring Security OAuth2 资源服务器需要名为“user_name”的声明中的用户名。因此,我们必须创建下面的映射器来将登录的用户名映射到名为 user_name 的新声明。
第7步:创建用户
第8步:将客户端角色映射到用户
为了提供对客户端(微服务)的访问,需要将相应的角色分配/映射到用户。
第9步:从 OpenID 配置端点获取配置
下面是 OpenID Connection 配置 URL,以获取有关所有安全端点的详细信息,
获取地址:http://localhost:8080/auth/realms/dev/.well-known/openid-configuration
要从响应中复制的重要 URL:
发行人(issuer):http://localhost:8080/auth/realms/dev
授权端点(authorization_endpoint): ${issuer}/protocol/openid-connect/auth
token端点(token_endpoint): ${issuer}/protocol/openid-connect/token
自我检查端点(token_introspection_endpoint): ${issuer}/protocol/openid-connect/token/introspect
用户信息端点(userinfo_endpoint): ${issuer}/protocol/openid-connect/userinfo
响应还包含支持的授权类型和范围
grant_types_supported: ["client_credentials", ...]
scopes_supported: ["openid", ...]
使用 Postman 获取访问令牌(用于测试)
选择授权类型为 OAuth 2.0,单击“获取新访问令牌”并输入以下详细信息。
确保在请求令牌时选择客户端身份验证作为“在正文中发送客户端凭据”。
回调 URL 是 Keycloak 中配置的重定向 URL。
客户端密码对您来说可能不同,从 keycloak 中的客户端配置复制一个。
您也可以使用https://jwt.io检查收到的令牌内容。
第10步:创建一个 Spring Boot 应用程序
Spring Boot
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.0.1.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security.oauth.boot</groupId>
<artifactId>spring-security-oauth2-autoconfigure</artifactId>
<version>2.0.1.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
第11步:配置 application.properties
一般安全属性
# 可以设置为 false 以在本地开发期间禁用安全
rest.security.enabled=true
rest.security.api-matcher=/api/**
rest.security.cors.allowed-origins=*
rest.security.cors.allowed-headers=*
rest.security.cors.allowed-methods=GET,POST,PUT,PATCH,DELETE,OPTIONS
rest.security.cors.max-age=3600
使用 OAuth2 资源服务器保护 REST 端点的属性
rest.security.issuer-uri=http://localhost:8080/auth/realms/dev
security.oauth2.resource.id=employee-service
security.oauth2.resource.token-info-uri=${rest.security.issuer-uri}/protocol/openid-connect/token/introspect
security.oauth2.resource.user-info-uri=${rest.security.issuer-uri}/protocol/openid-connect/userinfo
security.oauth2.resource.jwt.key-value=-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAhWOcKAVAwt+5FF/eE2hLaMVD5zQBBr+RLdc7HFUrlvU9Pm548rnD+zRTfOhnl5b6qMjtpLTRe3fG+8chjPwQriRyFKCzg7eYNxuR/2sK4okJbfQSZFs16TFhXtoQW5tWnzK6PqcB2Bpmy3x7QN78Hi04CjNrPz2BX8U+5BYMavYJANpp4XzPE8fZxlROmSSyNeyJdW30rJ/hsWZJ5nnxSZ685eT4IIUHM4g+sQQTZxnCUnazNXng5B5yZz/sh+9GOXDGT286fWdGbhGKU8oujjSJLOHYewFZX5Jw8aMrKKspL/6glRLSiV8FlEHbeRWxFffjZs/D+e9A56XuRJSQ9QIDAQAB\n-----END PUBLIC KEY-----
注意1: security.oauth2.resource.jwt.key-value 属性值可以从领域级别的公钥复制。这非常重要,这个属性是使用JwtAccessTokenCustomizer的,我们稍后会看到。
注意2:属性值会根据您的配置而有所不同,应注意使用正确的值。
调用另一个微服务的属性(服务帐户)
#如果这个微服务需要调用另一个
#secured micro-service
security.oauth2.client.client-id=employee-service
security.oauth2.client.client-secret=68977d81-c59b-49aa-aada-58da9a43a850
security.oauth2.client.user-authorization-uri=${rest.security.issuer-uri}/protocol/openid-connect/auth
security.oauth2.client.access-token-uri=${rest.security.issuer-uri}/protocol/openid-connect/token
security.oauth2.client.scope=openid
security.oauth2.client.grant-type=client_credentials
注意:用于进行安全服务帐户调用的OAuth2RestTemplate需要上述属性。
第12步:JWT 访问令牌定制器
为了让 Spring OAuth2 解析和设置 SecurityConextHolder,它需要来自 token 的角色或权限。此外,为了确定用户有权访问的客户端/应用程序/微服务列表,它需要来自令牌的客户端 ID 列表。这是唯一需要一些特殊处理的设置。
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.security.oauth2.resource.JwtAccessTokenConverterConfigurer;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.oauth2.provider.OAuth2Authentication;
import org.springframework.security.oauth2.provider.OAuth2Request;
import org.springframework.security.oauth2.provider.token.DefaultAccessTokenConverter;
import org.springframework.security.oauth2.provider.token.store.JwtAccessTokenConverter;
public class JwtAccessTokenCustomizer extends DefaultAccessTokenConverter
implements JwtAccessTokenConverterConfigurer {
private static final Logger LOG = LoggerFactory.getLogger(JwtAccessTokenCustomizer.class);
private static final String CLIENT_NAME_ELEMENT_IN_JWT = "resource_access";
private static final String ROLE_ELEMENT_IN_JWT = "roles";
private ObjectMapper mapper;
public JwtAccessTokenCustomizer(ObjectMapper mapper) {
this.mapper = mapper;
LOG.info("Initialized {}", JwtAccessTokenCustomizer.class.getSimpleName());
}
@Override
public void configure(JwtAccessTokenConverter converter) {
converter.setAccessTokenConverter(this);
LOG.info("Configured {}", JwtAccessTokenConverter.class.getSimpleName());
}
/**
* Spring oauth2 expects roles under authorities element in tokenMap,
* but keycloak provides it under resource_access. Hence extractAuthentication
* method is overriden to extract roles from resource_access.
*
* @return OAuth2Authentication with authorities for given application
*/
@Override
public OAuth2Authentication extractAuthentication(Map<String, ?> tokenMap) {
LOG.debug("Begin extractAuthentication: tokenMap = {}", tokenMap);
JsonNode token = mapper.convertValue(tokenMap, JsonNode.class);
Set<String> audienceList = extractClients(token); // extracting client names
List<GrantedAuthority> authorities = extractRoles(token); // extracting client roles
OAuth2Authentication authentication = super.extractAuthentication(tokenMap);
OAuth2Request oAuth2Request = authentication.getOAuth2Request();
OAuth2Request request =
new OAuth2Request(oAuth2Request.getRequestParameters(),
oAuth2Request.getClientId(),
authorities, true,
oAuth2Request.getScope(),
audienceList, null, null, null);
Authentication usernamePasswordAuthentication =
new UsernamePasswordAuthenticationToken(authentication.getPrincipal(),
"N/A", authorities);
LOG.debug("End extractAuthentication");
return new OAuth2Authentication(request, usernamePasswordAuthentication);
}
private List<GrantedAuthority> extractRoles(JsonNode jwt) {
LOG.debug("Begin extractRoles: jwt = {}", jwt);
Set<String> rolesWithPrefix = new HashSet<>();
jwt.path(CLIENT_NAME_ELEMENT_IN_JWT)
.elements()
.forEachRemaining(e -> e.path(ROLE_ELEMENT_IN_JWT)
.elements()
.forEachRemaining(r -> rolesWithPrefix.add("ROLE_" + r.asText())));
final List<GrantedAuthority> authorityList =
AuthorityUtils.createAuthorityList(rolesWithPrefix.toArray(new String[0]));
LOG.debug("End extractRoles: roles = {}", authorityList);
return authorityList;
}
private Set<String> extractClients(JsonNode jwt) {
LOG.debug("Begin extractClients: jwt = {}", jwt);
if (jwt.has(CLIENT_NAME_ELEMENT_IN_JWT)) {
JsonNode resourceAccessJsonNode = jwt.path(CLIENT_NAME_ELEMENT_IN_JWT);
final Set<String> clientNames = new HashSet<>();
resourceAccessJsonNode.fieldNames()
.forEachRemaining(clientNames::add);
LOG.debug("End extractClients: clients = {}", clientNames);
return clientNames;
} else {
throw new IllegalArgumentException("Expected element " +
CLIENT_NAME_ELEMENT_IN_JWT + " not found in token");
}
}
}
第13步:OAuth2 资源服务器配置器
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.autoconfigure.security.oauth2.resource.ResourceServerProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;
import org.springframework.security.config.annotation.method.configuration.EnableGlobalMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.oauth2.client.OAuth2RestTemplate;
import org.springframework.security.oauth2.client.resource.OAuth2ProtectedResourceDetails;
import org.springframework.security.oauth2.config.annotation.web.configuration.EnableResourceServer;
import org.springframework.security.oauth2.config.annotation.web.configuration.ResourceServerConfigurerAdapter;
import org.springframework.security.oauth2.config.annotation.web.configurers.ResourceServerSecurityConfigurer;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration
@EnableWebSecurity
@EnableResourceServer
@EnableGlobalMethodSecurity(prePostEnabled = true)
@ConditionalOnProperty(prefix = "rest.security", value = "enabled", havingValue = "true")
@Import({SecurityProperties.class})
public class SecurityConfigurer extends ResourceServerConfigurerAdapter {
@Autowired
private ResourceServerProperties resourceServerProperties;
@Autowired
private SecurityProperties securityProperties;
@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources.resourceId(resourceServerProperties.getResourceId());
}
@Override
public void configure(final HttpSecurity http) throws Exception {
http.cors()
.configurationSource(corsConfigurationSource())
.and()
.headers()
.frameOptions()
.disable()
.and()
.csrf()
.disable()
.authorizeRequests()
.antMatchers(securityProperties.getApiMatcher())
.authenticated();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
if (null != securityProperties.getCorsConfiguration()) {
source.registerCorsConfiguration("/**", securityProperties.getCorsConfiguration());
}
return source;
}
@Bean
public JwtAccessTokenCustomizer jwtAccessTokenCustomizer(ObjectMapper mapper) {
return new JwtAccessTokenCustomizer(mapper);
}
@Bean
public OAuth2RestTemplate oauth2RestTemplate(OAuth2ProtectedResourceDetails details) {
OAuth2RestTemplate oAuth2RestTemplate = new OAuth2RestTemplate(details);
//Prepare by getting access token once
oAuth2RestTemplate.getAccessToken();
return oAuth2RestTemplate;
}
}
注意:如果该微服务需要调用其他微服务,则需要 OAuth2RestTemplate。
第14步:保护 REST 端点
PreAuthorize注释用于保护具有适当角色的 REST 端点。参考下面的例子。
import org.arun.springoauth.config.SecurityContextUtils;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/v1/employees")
public class EmployeeRestController {
@GetMapping(path = "/username")
@PreAuthorize("hasAnyAuthority('ROLE_USER')")
public ResponseEntity<String> getAuthorizedUserName() {
return ResponseEntity.ok(SecurityContextUtils.getUserName());
}
@GetMapping(path = "/roles")
@PreAuthorize("hasAnyAuthority('ROLE_USER')")
public ResponseEntity<Set<String>> getAuthorizedUserRoles() {
return ResponseEntity.ok(SecurityContextUtils.getUserRoles());
}
}
第15步:如果不需要,请禁用基本身份验证
为了禁用默认安全性,可以排除SecurityAutoConfiguration和UserDetailsServiceAutoConfiguration。
@SpringBootApplication(exclude = {SecurityAutoConfiguration.class,
UserDetailsServiceAutoConfiguration.class})
public class Startup {
public static void main(String[] args) {
SpringApplication.run(Startup.class, args);
}
}
在 Keycloak 适配器上使用 Spring OAuth2 的好处:
1,大多数情况下,Keycloak Server 升级也需要升级 Keycloak 适配器。如果在 100 多个微服务中使用 Keycloak 适配器,那么所有这些微服务都需要升级以使用更新版本的 Keycloak 适配器。这需要对所有微服务进行回归测试,而且非常耗时。这可以通过使用在协议级别与 Keycloak 集成的 Spring OAuth2 来避免。由于这些协议不会经常更改,并且 Keycloak Server 升级将继续支持相应的协议版本,因此 Keycloak Server 升级时,微服务中不需要更改。
2,有时,Spring Boot 版本升级需要升级 Keycloak 和 Keycloak Spring Boot 适配器或 Keycloak Spring Security 适配器。如果我们使用 Spring OAuth2,则可以避免这种情况。
3,如果组织决定从 Keycloak 迁移到另一个 OAuth2 OpenID Authx Provider,则所有使用 Keycloak Spring Boot 适配器和 Keyclock Spring Security 的微服务都需要重构。使用 Spring OAuth2 + Spring Security 可以显着简化迁移。
4,Spring OAuth2 中可用的OAuth2RestTemplate类负责使用 OAuth2Context 根据需要刷新和缓存访问令牌。当需要在微服务之间安全交互时,这是一个很大的好处。