Skip to content

title: Keycloak + Spring Security OAuth 2.0 Resource Server date: 2024-12-19T23:00+09:00 tags:

  • 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 ๋งŒ์„ ํฌํ•จํ•ด๋„ ๋œ๋‹ค

groovy
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server'
    implementation 'org.springframework.boot:spring-boot-starter-security'
}
yaml
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

์„œ๋น„์Šค ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜์—์„œ ์‚ฌ์šฉ๋  ์• ํ”Œ๋ฆฌ์ผ€์ด์…˜ ์†์„ฑ์€ ์œ„์™€ ๊ฐ™์œผ๋ฉฐ ์Šคํ”„๋ง ์‹œํ๋ฆฌํ‹ฐ์— ๋Œ€ํ•œ ์„ค์ •์„ ์ง„ํ–‰ํ•ด๋ณด์ž.

java
@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();
    }
}
java
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 ๋ฐฉ์‹์œผ๋กœ ํ† ํฐ์„ ๋ฐœ๊ธ‰ํ•˜์—ฌ ๋ฐฑ์—”๋“œ์— ์ „๋‹ฌํ•˜์—ฌ ํ…Œ์ŠคํŠธ ํ•ด๋ณผ ์ˆ˜ ์žˆ๋‹ค.

Released under the MIT License.