rad*_*tao 13 security automated-tests spring-boot bearer-token keycloak
在Spring Boot项目中,我们启用了Spring Security并使用承载令牌应用 Keycloak 身份验证,如以下文章中所述:
https://www.keycloak.org/docs/3.2/securing_apps/topics/oidc/java/spring-security-adapter.html
https://www.keycloak.org/docs/3.2/securing_apps/topics/oidc/java/spring-boot-adapter.html
但是我找不到任何关于如何进行自动化测试以便应用 Keycloak 配置的建议。
那么,如何在启用 Spring 安全性的情况下测试/模拟/验证 Keycloak 配置?一件非常烦人的事情:默认情况下,Spring 会激活csrf安全过滤器,但如何避免对其进行测试?
(注意:我们使用不记名令牌,因此@WithMockUser在这种情况下似乎不适用)
奖金的问题:基本上我们不希望核实每个控制器集成测试的安全性,所以是有可能从控制器集成测试分别验证(那些使用安全@SpringBootTest,@WebAppConfiguration,@AutoConfigureMockMvc等?
一种解决方案是使用WireMock对 keycloak 授权服务器进行存根。因此,您可以使用该库spring-cloud-contract-wiremock(参见https://cloud.spring.io/spring-cloud-contract/1.1.x/multi/multi__spring_cloud_contract_wiremock.html),它提供了一个简单的 Spring Boot 集成。您可以简单地按照描述添加依赖项。此外,我使用jose4j来创建模拟访问令牌,就像 Keycloak 和 JWT 一样。您所要做的就是为Keycloak OpenId 配置和JSON Web 密钥存储的端点存根,因为Keycloak 适配器只请求验证授权标头中的访问令牌。
一个最小的独立工作示例,但需要在一个地方进行自定义(请参阅重要说明),下面列出了一些解释:
KeycloakTest.java:
@ExtendWith(SpringExtension.class)
@WebMvcTest(KeycloakTest.TestController.class)
@EnableConfigurationProperties(KeycloakSpringBootProperties.class)
@ContextConfiguration(classes= {KeycloakTest.TestController.class, SecurityConfig.class, CustomKeycloakSpringBootConfigResolver.class})
@AutoConfigureMockMvc
@AutoConfigureWireMock(port = 0) //random port, that is wired into properties with key wiremock.server.port
@TestPropertySource(locations = "classpath:wiremock.properties")
public class KeycloakTest {
private static RsaJsonWebKey rsaJsonWebKey;
private static boolean testSetupIsCompleted = false;
@Value("${wiremock.server.baseUrl}")
private String keycloakBaseUrl;
@Value("${keycloak.realm}")
private String keycloakRealm;
@Autowired
private MockMvc mockMvc;
@BeforeEach
public void setUp() throws IOException, JoseException {
if(!testSetupIsCompleted) {
// Generate an RSA key pair, which will be used for signing and verification of the JWT, wrapped in a JWK
rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048);
rsaJsonWebKey.setKeyId("k1");
rsaJsonWebKey.setAlgorithm(AlgorithmIdentifiers.RSA_USING_SHA256);
rsaJsonWebKey.setUse("sig");
String openidConfig = "{\n" +
" \"issuer\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "\",\n" +
" \"authorization_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/auth\",\n" +
" \"token_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token\",\n" +
" \"token_introspection_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token/introspect\",\n" +
" \"userinfo_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/userinfo\",\n" +
" \"end_session_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/logout\",\n" +
" \"jwks_uri\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/certs\",\n" +
" \"check_session_iframe\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/login-status-iframe.html\",\n" +
" \"registration_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/clients-registrations/openid-connect\",\n" +
" \"introspection_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token/introspect\"\n" +
"}";
stubFor(WireMock.get(urlEqualTo(String.format("/auth/realms/%s/.well-known/openid-configuration", keycloakRealm)))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody(openidConfig)
)
);
stubFor(WireMock.get(urlEqualTo(String.format("/auth/realms/%s/protocol/openid-connect/certs", keycloakRealm)))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody(new JsonWebKeySet(rsaJsonWebKey).toJson())
)
);
testSetupIsCompleted = true;
}
}
@Test
public void When_access_token_is_in_header_Then_process_request_with_Ok() throws Exception {
ResultActions resultActions = this.mockMvc
.perform(get("/test")
.header("Authorization",String.format("Bearer %s", generateJWT(true)))
);
resultActions
.andDo(print())
.andExpect(status().isOk())
.andExpect(content().string("hello"));
}
@Test
public void When_access_token_is_missing_Then_redirect_to_login() throws Exception {
ResultActions resultActions = this.mockMvc
.perform(get("/test"));
resultActions
.andDo(print())
.andExpect(status().isFound())
.andExpect(redirectedUrl("/sso/login"));
}
private String generateJWT(boolean withTenantClaim) throws JoseException {
// Create the Claims, which will be the content of the JWT
JwtClaims claims = new JwtClaims();
claims.setJwtId(UUID.randomUUID().toString()); // a unique identifier for the token
claims.setExpirationTimeMinutesInTheFuture(10); // time when the token will expire (10 minutes from now)
claims.setNotBeforeMinutesInThePast(0); // time before which the token is not yet valid (2 minutes ago)
claims.setIssuedAtToNow(); // when the token was issued/created (now)
claims.setAudience("account"); // to whom this token is intended to be sent
claims.setIssuer(String.format("%s/auth/realms/%s",keycloakBaseUrl,keycloakRealm)); // who creates the token and signs it
claims.setSubject(UUID.randomUUID().toString()); // the subject/principal is whom the token is about
claims.setClaim("typ","Bearer"); // set type of token
claims.setClaim("azp","example-client-id"); // Authorized party (the party to which this token was issued)
claims.setClaim("auth_time", NumericDate.fromMilliseconds(Instant.now().minus(11, ChronoUnit.SECONDS).toEpochMilli()).getValue()); // time when authentication occured
claims.setClaim("session_state", UUID.randomUUID().toString()); // keycloak specific ???
claims.setClaim("acr", "0"); //Authentication context class
claims.setClaim("realm_access", Map.of("roles",List.of("offline_access","uma_authorization","user"))); //keycloak roles
claims.setClaim("resource_access", Map.of("account",
Map.of("roles", List.of("manage-account","manage-account-links","view-profile"))
)
); //keycloak roles
claims.setClaim("scope","profile email");
claims.setClaim("name", "John Doe"); // additional claims/attributes about the subject can be added
claims.setClaim("email_verified",true);
claims.setClaim("preferred_username", "doe.john");
claims.setClaim("given_name", "John");
claims.setClaim("family_name", "Doe");
// A JWT is a JWS and/or a JWE with JSON claims as the payload.
// In this example it is a JWS so we create a JsonWebSignature object.
JsonWebSignature jws = new JsonWebSignature();
// The payload of the JWS is JSON content of the JWT Claims
jws.setPayload(claims.toJson());
// The JWT is signed using the private key
jws.setKey(rsaJsonWebKey.getPrivateKey());
// Set the Key ID (kid) header because it's just the polite thing to do.
// We only have one key in this example but a using a Key ID helps
// facilitate a smooth key rollover process
jws.setKeyIdHeaderValue(rsaJsonWebKey.getKeyId());
// Set the signature algorithm on the JWT/JWS that will integrity protect the claims
jws.setAlgorithmHeaderValue(AlgorithmIdentifiers.RSA_USING_SHA256);
// set the type header
jws.setHeader("typ","JWT");
// Sign the JWS and produce the compact serialization or the complete JWT/JWS
// representation, which is a string consisting of three dot ('.') separated
// base64url-encoded parts in the form Header.Payload.Signature
return jws.getCompactSerialization();
}
@RestController
public static class TestController {
@GetMapping("/test")
public String test() {
return "hello";
}
}
}
Run Code Online (Sandbox Code Playgroud)
wiremock.properties:
wiremock.server.baseUrl=http://localhost:${wiremock.server.port}
keycloak.auth-server-url=${wiremock.server.baseUrl}/auth
Run Code Online (Sandbox Code Playgroud)
该注解@AutoConfigureWireMock(port = 0)将在随机端口启动 WireMock 服务器,该端口wiremock.server.port自动设置为该属性,因此它可用于相应地覆盖keycloak.auth-server-urlSpring Boot Keycloak 适配器的属性(请参阅wiremock.properties)
为了生成用作访问令牌的JWT,我使用jose4j创建了一个 RSA 密钥对,该密钥对被声明为测试类属性,因为我确实需要在测试设置期间与 WireMock 服务器一起初始化它。
wiremock.server.baseUrl=http://localhost:${wiremock.server.port}
keycloak.auth-server-url=${wiremock.server.baseUrl}/auth
Run Code Online (Sandbox Code Playgroud)
然后在测试设置期间对其进行初始化,如下所示:
private static RsaJsonWebKey rsaJsonWebKey;
Run Code Online (Sandbox Code Playgroud)
keyId的选择无关紧要。你可以选择任何你想要的,只要它被设置。所选择的算法和使用确实很重要,并且必须完全按照示例进行调整。
有了这个,Keycloak Stub的JSON Web Key Storage端点可以相应地设置如下:
rsaJsonWebKey = RsaJwkGenerator.generateJwk(2048);
rsaJsonWebKey.setKeyId("k1");
rsaJsonWebKey.setAlgorithm(AlgorithmIdentifiers.RSA_USING_SHA256);
rsaJsonWebKey.setUse("sig");
Run Code Online (Sandbox Code Playgroud)
除此之外,如前所述,需要为 keycloak 存根另一个端点。如果没有缓存,keycloak 适配器需要请求 openid 配置。对于最小的工作示例,所有端点都需要在配置中定义,从OpenId 配置端点返回:
stubFor(WireMock.get(urlEqualTo(String.format("/auth/realms/%s/protocol/openid-connect/certs", keycloakRealm)))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody(new JsonWebKeySet(rsaJsonWebKey).toJson())
)
);
Run Code Online (Sandbox Code Playgroud)
令牌的生成是在generateJWT()大量使用jose4j 的情况下实现的。这里要注意的最重要的一点是,必须使用与在wiremock 的测试设置期间初始化的相同的生成JWK的私钥。
String openidConfig = "{\n" +
" \"issuer\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "\",\n" +
" \"authorization_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/auth\",\n" +
" \"token_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token\",\n" +
" \"token_introspection_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token/introspect\",\n" +
" \"userinfo_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/userinfo\",\n" +
" \"end_session_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/logout\",\n" +
" \"jwks_uri\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/certs\",\n" +
" \"check_session_iframe\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/login-status-iframe.html\",\n" +
" \"registration_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/clients-registrations/openid-connect\",\n" +
" \"introspection_endpoint\": \"" + keycloakBaseUrl + "/auth/realms/" + keycloakRealm + "/protocol/openid-connect/token/introspect\"\n" +
"}";
stubFor(WireMock.get(urlEqualTo(String.format("/auth/realms/%s/.well-known/openid-configuration", keycloakRealm)))
.willReturn(aResponse()
.withHeader("Content-Type", "application/json")
.withBody(openidConfig)
)
);
Run Code Online (Sandbox Code Playgroud)
除此之外,代码主要改编自https://bitbucket.org/b_c/jose4j/wiki/JWT%20Examples 上的示例。
现在可以根据自己的特定测试设置调整或扩展声明。发布的代码片段中的最小示例代表了 Keycloak 生成的 JWT 的典型示例。
生成的 JWT 可以像往常一样在Authorization Header 中使用,以向 REST 端点发送请求:
jws.setKey(rsaJsonWebKey.getPrivateKey());
Run Code Online (Sandbox Code Playgroud)
为了表示一个独立的示例,测试类确实有一个简单的 Restcontroller 定义为内部类,用于测试。
ResultActions resultActions = this.mockMvc
.perform(get("/test")
.header("Authorization",String.format("Bearer %s", generateJWT(true)))
);
Run Code Online (Sandbox Code Playgroud)
我确实引入了TestController用于测试目的的自定义,因此必须定义自定义 ContextConfiguration 以将其加载到 a 中WebMvcTest,如下所示:
@RestController
public static class TestController {
@GetMapping("/test")
public String test() {
return "hello";
}
}
Run Code Online (Sandbox Code Playgroud)
除了 TestController 本身之外,还包含了一堆关于 Spring Security 和 Keycloak Adapter 的 Configuration BeansSecurityConfig.class并CustomKeycloakSpringBootConfigResolver.class使其工作。当然,这些需要由您自己的 Configuration 替换。为了完整起见,这些类也将在下面列出:
安全配置.java:
@ContextConfiguration(classes= {KeycloakTest.TestController.class, SecurityConfig.class, CustomKeycloakSpringBootConfigResolver.class})
Run Code Online (Sandbox Code Playgroud)
CustomKeycloakSpringBootConfigResolver.java:
@Configuration
@EnableWebSecurity
@ComponentScan(basePackageClasses = KeycloakSecurityComponents.class)
public class SecurityConfig extends KeycloakWebSecurityConfigurerAdapter {
@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) {
SimpleAuthorityMapper grantedAuthorityMapper = new SimpleAuthorityMapper();
grantedAuthorityMapper.setPrefix("ROLE_");
KeycloakAuthenticationProvider keycloakAuthenticationProvider = keycloakAuthenticationProvider();
keycloakAuthenticationProvider.setGrantedAuthoritiesMapper(grantedAuthorityMapper);
auth.authenticationProvider(keycloakAuthenticationProvider);
}
/*
* Workaround for reading the properties for the keycloak adapter (see https://stackoverflow.com/questions/57787768/issues-running-example-keycloak-spring-boot-app)
*/
@Bean
@Primary
public KeycloakConfigResolver keycloakConfigResolver(KeycloakSpringBootProperties properties) {
return new CustomKeycloakSpringBootConfigResolver(properties);
}
@Bean
@Override
protected SessionAuthenticationStrategy sessionAuthenticationStrategy() {
return new RegisterSessionAuthenticationStrategy(new SessionRegistryImpl());
}
@Bean
@Override
@ConditionalOnMissingBean(HttpSessionManager.class)
protected HttpSessionManager httpSessionManager() {
return new HttpSessionManager();
}
@Override
protected void configure(HttpSecurity http) throws Exception {
super.configure(http);
http
.authorizeRequests()
.antMatchers("/**").hasRole("user")
.anyRequest().authenticated()
.and().csrf().disable();
}
}
Run Code Online (Sandbox Code Playgroud)
仅适用于“奖金”问题的部分答案(@Component单元测试):我刚刚编写了一组库来简化安全 Spring 应用程序的单元测试。我只运行这样的测试和 e2e 测试(包括富客户端前端和实际授权服务器)。
它包括一个@WithMockKeycloackAuth注释,以及 Keycloak 专用MockMvc请求后处理器
示例用法:
@RunWith(SpringRunner.class)
@WebMvcTest(GreetingController.class)
@ContextConfiguration(classes = GreetingApp.class)
@ComponentScan(basePackageClasses = { KeycloakSecurityComponents.class, KeycloakSpringBootConfigResolver.class })
public class GreetingControllerTests extends ServletUnitTestingSupport {
@MockBean
MessageService messageService;
@Test
@WithMockKeycloackAuth("TESTER")
public void whenUserIsNotGrantedWithAuthorizedPersonelThenSecretRouteIsNotAccessible() throws Exception {
mockMvc().get("/secured-route").andExpect(status().isForbidden());
}
@Test
@WithMockKeycloackAuth("AUTHORIZED_PERSONNEL")
public void whenUserIsGrantedWithAuthorizedPersonelThenSecretRouteIsAccessible() throws Exception {
mockMvc().get("/secured-route").andExpect(content().string(is("secret route")));
}
@Test
@WithMockKeycloakAuth(
authorities = { "USER", "AUTHORIZED_PERSONNEL" },
id = @IdTokenClaims(sub = "42"),
oidc = @OidcStandardClaims(
email = "ch4mp@c4-soft.com",
emailVerified = true,
nickName = "Tonton-Pirate",
preferredUsername = "ch4mpy"),
otherClaims = @ClaimSet(stringClaims = @StringClaim(name = "foo", value = "bar")))
public void whenAuthenticatedWithKeycloakAuthenticationTokenThenCanGreet() throws Exception {
mockMvc().get("/greet")
.andExpect(status().isOk())
.andExpect(content().string(startsWith("Hello ch4mpy! You are granted with ")))
.andExpect(content().string(containsString("AUTHORIZED_PERSONNEL")))
.andExpect(content().string(containsString("USER")));
}
Run Code Online (Sandbox Code Playgroud)
maven-central 提供了不同的库,根据您的用例选择以下之一(单独@WithMockKeycloakAuth或更多工具,如 MockMvc fluent API):
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-security-oauth2-test-addons</artifactId>
<version>2.4.1</version>
<scope>test</scope>
</dependency>
Run Code Online (Sandbox Code Playgroud)
或者
<dependency>
<groupId>com.c4-soft.springaddons</groupId>
<artifactId>spring-security-oauth2-test-webmvc-addons</artifactId>
<version>2.4.1</version>
<scope>test</scope>
</dependency>
Run Code Online (Sandbox Code Playgroud)
我从事 activiti 项目,我们一直在使用 keycloak 和 spring boot 并遇到相同的问题。有一个名为KeycloakSecurityContextClientRequestInterceptor 的 keycloak 测试帮助程序类,我们对其进行了一些定制。它引用用于测试的领域和用户。我们在使用 keycloak 的测试中设置这些属性。这也可以用于在一组测试期间切换用户。
对于我们不想使用 keycloak 的测试,到目前为止,我们遵循的做法是将它们保留在项目中的不同级别,因此保留在不同的子模块中。这让我们可以将 keycloak maven 依赖项保留在该层之外,这样 keycloak 就不会在它们上启用。
| 归档时间: |
|
| 查看次数: |
14017 次 |
| 最近记录: |