ν”„λ‘ νŠΈμ—”λ“œ μ±„λ„μ—μ„œ 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'
}
application.yml
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

μ„œλΉ„μŠ€ μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ—μ„œ μ‚¬μš©λ  μ• ν”Œλ¦¬μΌ€μ΄μ…˜ 속성은 μœ„μ™€ κ°™μœΌλ©° μŠ€ν”„λ§ μ‹œνλ¦¬ν‹°μ— λŒ€ν•œ 섀정을 μ§„ν–‰ν•΄λ³΄μž.

SecurityConfiguration
@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(); } }
KeycloakJwtAuthenticationConverter
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 λ°©μ‹μœΌλ‘œ 토큰을 λ°œκΈ‰ν•˜μ—¬ λ°±μ—”λ“œμ— μ „λ‹¬ν•˜μ—¬ ν…ŒμŠ€νŠΈ ν•΄λ³Ό 수 μžˆλ‹€.