Keycloak + Spring Security OAuth 2.0 Resource Server
νλ‘ νΈμλ μ±λμμ Keycloak λ₯Ό ν΅ν΄ λ°κΈνμ¬ λ°±μλ μμ²μΌλ‘ μ λ¬λλ JWT ν ν°μ λν κ²μ¦μ μν΄ Spring Securityμ OAuth 2.0 Resource Serverλ₯Ό μ¬μ©νμ¬ JWT ν ν°μ΄ μ λ’°ν μ μλ κ³³μμ μλͺ λμλμ§ νμΈνλ κ²μ μμ보μ. λ³Έ κΈμμλ μ¬μ©μ μΈμ¦μ λν Authorization Code Flowλ₯Ό λ°±μλ μ±λλ‘ μ λ¬νμ¬ ν ν°μ κ΅ννλ κ³Όμ μ κ±°μΉμ§ μκ³ JavaScript Keycloak Adapterλ₯Ό μ¬μ©νμ¬ PKCE κΈ°λ°μΌλ‘ λ°κΈλ ν ν°μ μ λ¬λ°λλ€κ³ κ°μ νλ€.
- Keycloak 26.0.6
- Spring Boot 3.4.0
- spring-security-oauth2-resource-server:6.4.1
OAuth 2.0 Resource Server
μΌλ°μ μΈ μμ μμλ OAuth2 Authorization Server, OAuth2 Client λ₯Ό ν¬ν¨νμ§λ§ ν ν° λ°κΈ κ³Όμ μ μλΉμ€ μ ν리μΌμ΄μ
μμ μννμ§ μλλ€λ©΄ 리μμ€ μλ²μ λν μμ‘΄μ±μΈ spring-boot-starter-oauth2-resource-server
λ§μ ν¬ν¨ν΄λ λλ€
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
implementation 'org.springframework.boot:spring-boot-starter-security'
}
spring.security:
oauth2:
resourceserver:
jwt:
issuer-uri: ${keycloak.issuer-uri}
keycloak:
issuer-uri: http://localhost:8080/realms/mambo
jwk-set-uri: http://localhost:8080/realms/mambo/protocol/openid-connect/cert
client-id: backend
μλΉμ€ μ ν리μΌμ΄μ μμ μ¬μ©λ μ ν리μΌμ΄μ μμ±μ μμ κ°μΌλ©° μ€νλ§ μν리ν°μ λν μ€μ μ μ§νν΄λ³΄μ.
@Configuration
@RequiredArgsConstructor
public class SecurityConfiguration {
private final KeycloakProperties keycloakProperties;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.formLogin(AbstractHttpConfigurer::disable);
http.httpBasic(AbstractHttpConfigurer::disable);
http.csrf(AbstractHttpConfigurer::disable);
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/").permitAll()
.requestMatchers(PathRequest.toStaticResources().atCommonLocations()).permitAll()
.anyRequest().denyAll());
http.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(new KeycloakJwtAuthenticationConverter())));
return http.build();
}
}
public class KeycloakJwtAuthenticationConverter implements Converter<Jwt, AbstractAuthenticationToken> {
private static final String TYPE = "type";
private static final String RESOURCE_ACCESS = "resource_access";
private static final String ACCOUNT = "account";
private static final String ROLES = "roles";
private static final String ROLE_PREFIX = "ROLE_";
@Override
public AbstractAuthenticationToken convert(Jwt jwt) {
Object username = jwt.getClaims().get("preferred_username");
Set<GrantedAuthority> authorities = extractAuthorities(jwt);
return new JwtAuthenticationToken(jwt, authorities, username == null ? null : String.valueOf(username));
}
private Set<GrantedAuthority> extractAuthorities(Jwt jwt) {
Set<String> roleSet = new HashSet<>();
Map<String, Object> claims = jwt.getClaims();
for (Map.Entry<String, Object> entry : claims.entrySet()) {
String key = entry.getKey();
if (key.equals(TYPE)) {
roleSet.add(String.valueOf(entry.getValue()));
} else if (key.equals(RESOURCE_ACCESS)) {
Map<String, List<String>> resourceAccess = (Map<String, List<String>>) entry.getValue();
if (resourceAccess.containsKey(ACCOUNT)) {
Map<String, List<String>> account = (Map<String, List<String>>) resourceAccess.get("account");
if (account.containsKey(ROLES)) {
roleSet.addAll(account.get(ROLES));
}
}
}
}
return roleSet.stream()
.map(role -> new SimpleGrantedAuthority(ROLE_PREFIX + role))
.collect(Collectors.toSet());
}
}
μ μμμ κ°μ΄ KeycloakJwtAuthenticationConverter λ₯Ό ꡬννμ§ μκ³ JwtAuthenticationConveter λ₯Ό μ¬μ©νκ³ JwtGrantedAuthoritiesConverter λ₯Ό μμ±ν΄λ λλ€. μ°Έκ³ λ‘ Keycloak μμ λ°κΈλλ OIDC ν ν°μλ iss κ° ν¬ν¨λμ΄ μμΌλ―λ‘ JwtDecoder λλ issuer-uri μ jwk-set-uriλ₯Ό λͺ μνλκ² νμνμ§ μλ€.
JWT ν ν° κ²μ¦ μ€λ₯ μ
κΈ°λ³Έμ μΈ BearerTokenAuthenticationEntryPoint λ μ€λ₯μ λν λ΄μ©μ WWW-Authenticate
μλ΅ ν€λμ ν¬ν¨λλ€. λ§μ½, 401 Unauthorized
μ λν μ€λ₯ λ΄μ©μ μλ΅ λ°λλ‘ λ°ννκ³ μ νλ€λ©΄ AuthenticationEntryPoint 컀μ€ν°λ§μ΄μ§ ν΄μ μ€μ νλλ‘ νμ.
Resource Owner Password Credentials
ν΄λΌμ΄μΈνΈμ Direct access grants μ΅μ μ νμ±ν λμ΄μλ€λ©΄ Postmanμ ν΅ν΄ Resource Owner Password Credentials λ°©μμΌλ‘ ν ν°μ λ°κΈνμ¬ λ°±μλμ μ λ¬νμ¬ ν μ€νΈ ν΄λ³Ό μ μλ€.