merge: Spring resource server role mapping
This commit is contained in:
@@ -27,4 +27,9 @@ public class ApiController {
|
|||||||
response.put("audience", jwt.getAudience());
|
response.put("audience", jwt.getAudience());
|
||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@GetMapping("/admin")
|
||||||
|
public Map<String, String> adminEndpoint() {
|
||||||
|
return Map.of("status", "ok", "authorization", "admin-role");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package com.example.keycloakpattern;
|
||||||
|
|
||||||
|
import java.util.Collection;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.springframework.core.convert.converter.Converter;
|
||||||
|
import org.springframework.security.core.GrantedAuthority;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
|
||||||
|
final class KeycloakRealmRoleConverter
|
||||||
|
implements Converter<Jwt, Collection<GrantedAuthority>> {
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public Collection<GrantedAuthority> convert(Jwt jwt) {
|
||||||
|
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
|
||||||
|
if (realmAccess == null || !(realmAccess.get("roles") instanceof Collection<?> roles)) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
return roles.stream()
|
||||||
|
.filter(String.class::isInstance)
|
||||||
|
.map(String.class::cast)
|
||||||
|
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
|
||||||
|
.map(GrantedAuthority.class::cast)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
import org.springframework.security.config.Customizer;
|
import org.springframework.security.config.Customizer;
|
||||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
|
||||||
import org.springframework.security.web.SecurityFilterChain;
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
import org.springframework.web.cors.CorsConfiguration;
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
import org.springframework.web.cors.CorsConfigurationSource;
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
@@ -25,12 +26,21 @@ public class SecurityConfig {
|
|||||||
.authorizeHttpRequests(authorize -> authorize
|
.authorizeHttpRequests(authorize -> authorize
|
||||||
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
|
.requestMatchers("/actuator/health", "/actuator/health/**", "/api/public")
|
||||||
.permitAll()
|
.permitAll()
|
||||||
|
.requestMatchers("/api/admin")
|
||||||
|
.hasRole("admin-role")
|
||||||
.anyRequest()
|
.anyRequest()
|
||||||
.authenticated())
|
.authenticated())
|
||||||
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()))
|
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
|
||||||
|
jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private JwtAuthenticationConverter jwtAuthenticationConverter() {
|
||||||
|
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
|
||||||
|
converter.setJwtGrantedAuthoritiesConverter(new KeycloakRealmRoleConverter());
|
||||||
|
return converter;
|
||||||
|
}
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
CorsConfigurationSource corsConfigurationSource() {
|
CorsConfigurationSource corsConfigurationSource() {
|
||||||
CorsConfiguration configuration = new CorsConfiguration();
|
CorsConfiguration configuration = new CorsConfiguration();
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import org.springframework.beans.factory.annotation.Autowired;
|
|||||||
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
@AutoConfigureMockMvc
|
@AutoConfigureMockMvc
|
||||||
@@ -40,4 +41,19 @@ class ApiSecurityTest {
|
|||||||
.andExpect(jsonPath("$.subject").value("test-subject"))
|
.andExpect(jsonPath("$.subject").value("test-subject"))
|
||||||
.andExpect(jsonPath("$.username").value("regular-user"));
|
.andExpect(jsonPath("$.username").value("regular-user"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void regularUserCannotCallAdminEndpoint() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/admin").with(jwt()
|
||||||
|
.authorities(new SimpleGrantedAuthority("ROLE_user-role"))))
|
||||||
|
.andExpect(status().isForbidden());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void adminRoleCanCallAdminEndpoint() throws Exception {
|
||||||
|
mockMvc.perform(get("/api/admin").with(jwt()
|
||||||
|
.authorities(new SimpleGrantedAuthority("ROLE_admin-role"))))
|
||||||
|
.andExpect(status().isOk())
|
||||||
|
.andExpect(jsonPath("$.authorization").value("admin-role"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.example.keycloakpattern;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
|
||||||
|
class KeycloakRealmRoleConverterTest {
|
||||||
|
|
||||||
|
private final KeycloakRealmRoleConverter converter =
|
||||||
|
new KeycloakRealmRoleConverter();
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void mapsRealmRolesWithExactlyOneRolePrefix() {
|
||||||
|
Jwt jwt = new Jwt(
|
||||||
|
"token",
|
||||||
|
Instant.now(),
|
||||||
|
Instant.now().plusSeconds(60),
|
||||||
|
Map.of("alg", "none"),
|
||||||
|
Map.of("sub", "subject", "realm_access", Map.of(
|
||||||
|
"roles", List.of("admin-role", "user-role")
|
||||||
|
))
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(converter.convert(jwt))
|
||||||
|
.extracting("authority")
|
||||||
|
.containsExactly("ROLE_admin-role", "ROLE_user-role");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void missingRealmAccessProducesNoAuthorities() {
|
||||||
|
Jwt jwt = new Jwt(
|
||||||
|
"token",
|
||||||
|
Instant.now(),
|
||||||
|
Instant.now().plusSeconds(60),
|
||||||
|
Map.of("alg", "none"),
|
||||||
|
Map.of("sub", "subject")
|
||||||
|
);
|
||||||
|
|
||||||
|
assertThat(converter.convert(jwt)).isEmpty();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
# Keycloak realm roles to Spring authorization
|
||||||
|
|
||||||
|
`realm_access.roles`의 각 문자열을 `ROLE_` prefix가 붙은 Spring authority로
|
||||||
|
변환한다. `/api/admin`은 `hasRole("admin-role")` 계약이므로 최종 authority는
|
||||||
|
`ROLE_admin-role`이다. `hasRole("ROLE_admin-role")`로 쓰면 prefix가 중복된다.
|
||||||
|
|
||||||
|
검증은 세 층으로 구성된다.
|
||||||
|
|
||||||
|
- converter 단위 테스트: role claim과 claim 부재
|
||||||
|
- MockMvc: regular 403, admin 200
|
||||||
|
- 실제 Authorization Code + PKCE login: Keycloak token의 realm role을
|
||||||
|
Spring Resource Server가 변환해 regular 403/admin 200을 반환
|
||||||
|
|
||||||
|
```sh
|
||||||
|
./scripts/verify-spring-role-mapping.sh
|
||||||
|
```
|
||||||
+2
-1
@@ -4,7 +4,8 @@
|
|||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test:pattern1": "node pattern1.mjs"
|
"test:pattern1": "node pattern1.mjs",
|
||||||
|
"test:role-mapping": "node role-mapping.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"playwright-core": "1.62.0"
|
"playwright-core": "1.62.0"
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { chromium } from "playwright-core";
|
||||||
|
|
||||||
|
const keycloakUrl = "http://localhost:8080";
|
||||||
|
const frontendUrl = "http://localhost:8088/";
|
||||||
|
|
||||||
|
async function accessToken(browser, username, password) {
|
||||||
|
const context = await browser.newContext();
|
||||||
|
const page = await context.newPage();
|
||||||
|
await page.goto(frontendUrl);
|
||||||
|
const tokenResponse = page.waitForResponse((response) =>
|
||||||
|
response.url().includes("/protocol/openid-connect/token")
|
||||||
|
&& response.request().postData()?.includes("grant_type=authorization_code"),
|
||||||
|
);
|
||||||
|
await page.locator("#login").click();
|
||||||
|
await page.locator("#username").fill(username);
|
||||||
|
await page.locator("#password").fill(password);
|
||||||
|
await page.locator("#kc-login").click();
|
||||||
|
const response = await tokenResponse;
|
||||||
|
assert.equal(response.status(), 200);
|
||||||
|
const token = (await response.json()).access_token;
|
||||||
|
await context.close();
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function adminStatus(token) {
|
||||||
|
return (
|
||||||
|
await fetch("http://localhost:8081/api/admin", {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
})
|
||||||
|
).status;
|
||||||
|
}
|
||||||
|
|
||||||
|
const regularPassword = process.env.REGULAR_USER_PASSWORD;
|
||||||
|
const adminPassword = process.env.ADMIN_USER_PASSWORD;
|
||||||
|
assert.ok(regularPassword && adminPassword);
|
||||||
|
|
||||||
|
const browser = await chromium.launch({
|
||||||
|
executablePath: process.env.CHROME_BIN ?? "/usr/bin/google-chrome",
|
||||||
|
headless: true,
|
||||||
|
args: ["--no-sandbox"],
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const regularToken = await accessToken(
|
||||||
|
browser,
|
||||||
|
"regular-user",
|
||||||
|
regularPassword,
|
||||||
|
);
|
||||||
|
const regularPayload = JSON.parse(
|
||||||
|
Buffer.from(regularToken.split(".")[1], "base64url").toString(),
|
||||||
|
);
|
||||||
|
assert.ok(regularPayload.realm_access.roles.includes("user-role"));
|
||||||
|
assert.equal(await adminStatus(regularToken), 403);
|
||||||
|
|
||||||
|
const adminToken = await accessToken(browser, "admin-user", adminPassword);
|
||||||
|
const adminPayload = JSON.parse(
|
||||||
|
Buffer.from(adminToken.split(".")[1], "base64url").toString(),
|
||||||
|
);
|
||||||
|
assert.ok(adminPayload.realm_access.roles.includes("admin-role"));
|
||||||
|
assert.equal(await adminStatus(adminToken), 200);
|
||||||
|
console.log("Spring RBAC verified: realm role -> ROLE_ authority -> 403/200");
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
Executable
+13
@@ -0,0 +1,13 @@
|
|||||||
|
#!/usr/bin/env sh
|
||||||
|
set -eu
|
||||||
|
|
||||||
|
set -a
|
||||||
|
. ./.env
|
||||||
|
set +a
|
||||||
|
|
||||||
|
docker compose down --volumes --remove-orphans
|
||||||
|
docker compose up --build -d --wait
|
||||||
|
npm --prefix e2e ci
|
||||||
|
REGULAR_USER_PASSWORD="$REGULAR_USER_PASSWORD" \
|
||||||
|
ADMIN_USER_PASSWORD="$ADMIN_USER_PASSWORD" \
|
||||||
|
npm --prefix e2e run test:role-mapping
|
||||||
Reference in New Issue
Block a user