Spring Boot 보안 토큰을 Keycloak의 공개 키(JWKS)를 사용해 로컬에서 검증
Source: Dev.to
죄송합니다만, 번역하려는 전체 텍스트를 제공해 주시면 해당 내용을 한국어로 번역해 드리겠습니다. 현재는 링크만 제공되어 있어 실제 기사 내용이 포함되어 있지 않으므로 번역을 진행할 수 없습니다. 텍스트를 복사해서 보내주시면 바로 도와드리겠습니다.
액터
| Actor | Role |
|---|---|
| Keycloak (Authorization Server / IdP) | • OIDC/OAuth2 흐름(인증 코드, 클라이언트 자격 증명 등)을 통해 사용자/클라이언트를 인증합니다. • iss, sub, exp, iat, nbf, aud, scope, preferred_username, realm_access 등과 같은 클레임을 포함한 JWT 액세스 토큰을 발급합니다.• 레알름 아래 JWKS 엔드포인트에 서명 키를 공개합니다. |
| Spring Boot API (Resource Server) | • 보호된 REST 엔드포인트를 노출합니다. • Spring Security의 OAuth2 리소스 서버 지원을 사용하여: - 들어오는 요청에서 Authorization: Bearer <token>을 추출합니다.- Keycloak의 JWKS를 사용해 JWT 서명과 클레임을 로컬에서 검증합니다. - 클레임/스코프를 권한으로 매핑하고 @PreAuthorize, hasRole 등으로 접근 규칙을 적용합니다. |
| Client (SPA, mobile app, B2B, …) | • 적절한 OAuth2 흐름을 통해 Keycloak으로부터 토큰을 획득합니다. • Spring API를 호출할 때 액세스 토큰을 Bearer 토큰으로 전송합니다. |
핵심 포인트: 토큰이 발급된 후, Spring 서비스는 토큰을 검증하기 위해 Keycloak의 공개 키만 필요하며, 매 요청마다 Keycloak에 호출할 필요가 없습니다.
Token Issuance Flow (Simplified)
-
User or backend client 가 로그인합니다 (브라우저 리다이렉트 / OIDC 또는 client‑credentials).
-
Keycloak 이 자격 증명을 검증하고, 사용자의 아이덴티티와 역할을 구성한 뒤 access token (필요에 따라 refresh token도) 을 발급합니다.
-
발급된 access token 은 JWT 로, Keycloak 의 개인 키(
kid로 식별) 로 서명됩니다. -
Keycloak 은 해당 public keys 를 다음 경로에서 공개합니다:
https://<host>/realms/<realm>/.well-known/openid-configuration (metadata) https://<host>/realms/<realm>/protocol/openid-connect/certs (JWKS) -
클라이언트는 이제 자체 포함된 서명된 JWT 를 보유하게 됩니다. 리소스 서버가 공개 키를 알고 있는 한, 검증을 위해 Keycloak 에 추가 호출을 할 필요가 없습니다.
Spring Security를 OAuth2 리소스 서버로 구성하기
옵션 A – Issuer‑URI (권장)
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/my-realm
- Spring은 발급자를 위해
/.well-known/openid-configuration에 요청합니다. - 메타데이터에서
jwks_uri를 읽어옵니다. - Keycloak JWKS 엔드포인트를 사용해
JwtDecoder를 생성합니다. - 발급자 검증을 강제합니다 (
iss클레임이 설정된 발급자와 일치해야 함).
장점: 간단하고 견고합니다 – Keycloak이 JWKS URL을 변경해도 발급자가 동일하면 메타데이터가 일관성을 유지합니다.
옵션 B – JWKS‑Set‑URI (직접)
spring:
security:
oauth2:
resourceserver:
jwt:
jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs
- 발견 과정을 건너뛰고 JWKS 엔드포인트를 직접 지정합니다.
- 보통
issuer-uri와 함께 사용하거나 커스텀 검증기를 추가해iss검증을 유지합니다.
JWKS 캐싱
Spring의 기본 JWT 지원(Nimbus)은 키를 캐시합니다:
- 필요에 따라 한 번 (또는 필요 시) Keycloak에서 JWKS를 가져옵니다.
- 키를 메모리에 저장합니다.
- 캐시 정책에 따라 새
kid가 키 회전 후 나타날 때 등 갱신합니다.
결과: 요청당 호출 없음 to Keycloak; 캐시가 만료되거나 새 키가 도입될 때만 가끔 가져옵니다.
요청 처리 흐름 (API 호출당)
-
들어오는 HTTP 요청
Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6ImtleS0xIn0... -
Bearer 토큰 추출 – Spring Security의 필터 체인이 토큰을 추출하고 설정된
JwtDecoder에 전달합니다. -
토큰 파싱 및 서명 검증
- JWT 헤더 디코딩 →
kid읽기. - JWKS 캐시에서 일치하는 공개 키 조회.
- 서명 검증 (예: RS256).
- 검증에 실패하면 인증이 거부됩니다.
- JWT 헤더 디코딩 →
-
클레임 검증 (표준 검사)
exp– 만료되지 않음.nbf– 현재 유효.iat– 선택적 사용자 정의 검증.iss– 설정된 발행자와 일치해야 함.aud– 선택적이며, API가 기대하는 audience/client‑id와 일치해야 함.
-
인증 객체 생성
Jwt객체가 생성되고 토큰 클레임에서 파생된 권한을 가진JwtAuthenticationToken으로 래핑됩니다.SecurityContextHolder에 저장됩니다.
-
컨트롤러/서비스 수준에서의 인가
@PreAuthorize("hasAuthority('SCOPE_my-scope')") @PreAuthorize("hasRole('admin')") // after mapping결정은 검증된 토큰만을 기반으로 이루어지며 Keycloak 호출이 없습니다.
-
실패 처리 – 토큰 누락/무효, 서명 오류, 발행자 불일치, 혹은 토큰 만료 시
401 Unauthorized또는403 Forbidden이 반환됩니다.
Maven 의존성
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-oauth2-client</artifactId>
<optional>true</optional>
</dependency>
예시 application.yml (부분)
server:
port: 8080
spring:
security:
oauth2:
resourceserver:
jwt:
# 두 옵션 중 하나를 선택하세요:
issuer-uri: https://keycloak.example.com/realms/my-realm
# jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs
TL;DR
- Keycloak → 서명된 JWT를 발행하고 JWKS를 공개합니다.
- Spring Boot (Resource Server) → 캐시된 JWKS를 사용해 JWT를 로컬에서 검증합니다 (
issuer-uri또는jwk-set-uri통해). - Clients → JWT를 Bearer 토큰으로 전송합니다.
Keycloak에 매 요청마다 라운드‑트립이 필요하지 않으며, 키 회전, 클레임‑기반 보안, 그리고 Spring의 강력한 권한 부여 어노테이션을 그대로 활용할 수 있습니다.
port: 8080
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/my-realm
보안 구성 (Java)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/public/**").permitAll()
.requestMatchers("/admin/**").hasRole("admin")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
);
return http.build();
}
}
Keycloak 역할을 Spring 권한에 매핑하기
Keycloak은 역할을 realm_access.roles 및 resource_access.<client>.roles 와 같은 클레임에 넣습니다. 아래 컨버터는 이러한 역할을 Spring의 ROLE_ 권한으로 매핑합니다:
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter;
import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.stream.Stream;
@Configuration
public class JwtConverterConfig {
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter defaultConverter = new JwtGrantedAuthoritiesConverter();
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> defaultAuthorities = defaultConverter.convert(jwt);
Collection<GrantedAuthority> realmRoles = jwt.getClaimAsMap("realm_access") != null
? ((Collection<String>) ((Map<?, ?>) jwt.getClaim("realm_access"))
.getOrDefault("roles", List.of()))
.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList()
: List.of();
return Stream.concat(defaultAuthorities.stream(), realmRoles.stream())
.toList();
});
return converter;
}
}
컨버터를 보안 설정에 연결합니다:
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)
이제 @PreAuthorize("hasRole('admin')") 와 같은 어노테이션을 사용하면 Keycloak의 레임 역할 admin과 연동됩니다.
추가 JWT 검증 (Audience, Tenant 등)
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.oauth2.core.DelegatingOAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2Error;
import org.springframework.security.oauth2.core.OAuth2TokenValidator;
import org.springframework.security.oauth2.core.OAuth2TokenValidatorResult;
import org.springframework.security.oauth2.jwt.*;
import java.util.List;
@Configuration
public class JwtDecoderConfig {
private static final String EXPECTED_AUDIENCE = "my-api";
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder jwtDecoder =
JwtDecoders.fromIssuerLocation("https://keycloak.example.com/realms/my-realm");
OAuth2TokenValidator<Jwt> withIssuer =
JwtValidators.createDefaultWithIssuer("https://keycloak.example.com/realms/my-realm");
OAuth2TokenValidator<Jwt> audienceValidator = jwt -> {
List<String> audiences = jwt.getAudience();
if (audiences != null && audiences.contains(EXPECTED_AUDIENCE)) {
return OAuth2TokenValidatorResult.success();
}
OAuth2Error error = new OAuth2Error("invalid_token",
"The required audience is missing", null);
return OAuth2TokenValidatorResult.failure(error);
};
jwtDecoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator));
return jwtDecoder;
}
}
디코더를 보안 설정에 연결합니다:
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.decoder(jwtDecoder()))
)
JWKS 캐싱 및 회전 감지
JWKS를 더 자주(예: 1분마다) 가져오면서 검증을 로컬에서 유지하려면:
NimbusJwtDecoder(fromIssuerLocation또는withJwkSetUri) 사용.- 캐싱 레이어(Spring Cache, Caffeine, Redis 등)와 함께 JWKS 소스를 커스터마이징.
- JWKS 엔트리에 TTL(예: 60 초) 설정.
이 접근 방식은 각 인스턴스가 주기적으로 JWKS를 새로 고쳐 키 회전을 빠르게 감지하게 하며, 요청 수준 성능에 영향을 주지 않습니다.
Tip: 더 나은 성능과 결합도 감소를 위해 토큰 인트로스펙션보다 JWT 검증(리소스‑서버 모드)을 선호하세요.
핵심 속성 요약
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://keycloak.example.com/realms/my-realm
SecurityFilterChain을 역할 기반 접근으로 구성합니다.- Keycloak 영역 역할을
ROLE_권한으로 매핑하기 위해 사용자 정의JwtAuthenticationConverter를 제공합니다. - audience(또는 기타 클레임) 검증을 위한 선택적
JwtDecoder빈을 추가합니다. - 필요에 따라 키 회전을 감지하도록 JWKS 캐시 TTL을 조정합니다(예: 60 초마다).
Keycloak Integration Tips
jwk-set-uri– Spring이 Keycloak의 JWKS를 가져올 수 있도록 이 값을 설정하세요.- 매 요청마다 Keycloak에 호출하는 것을 피하기 위해 JWKS 캐싱을 활용하세요; 회전 민감도와 부하를 고려해 캐시 TTL을 조정합니다.
JwtDecoder검증기를 통해iss,exp,nbf,aud와 같은 클레임(및 도메인별 제약) 을 강제하세요.- 커스텀
JwtAuthenticationConverter로 Keycloak 역할/클레임을 Spring 권한에 매핑하여 인가 로직을 깔끔하게 유지하세요.