Keycloak

[Keycloak] Custom API

kyoulho 2025. 3. 4. 22:41
728x90

Keycloak를 도입하여 인증·인가를 간편하게 구성할 수 있지만, “Admin REST API”로는 사용자와 그룹 정보를 한꺼번에 조회하기가 쉽지 않습니다.

이를 위해 RealmResourceProvider라는 확장(SPI)을 구현해 N+1 문제를 개선하고, 하나의 엔드포인트에서 사용자와 그들의 그룹 목록을 동시에 가져오는 방법을 소개합니다.


문제: 기본 Admin API의 N+1 호출

Keycloak에는

  • GET /admin/realms/{realm}/users : 사용자 목록 (그룹 정보 X)
  • GET /admin/realms/{realm}/users/{userId}/groups : 해당 사용자의 그룹 목록 엔드포인트만 제공됩니다.

수많은 사용자에 대해 그룹 정보를 확인하려면,

  1. 사용자 목록 API 호출 → 사용자 IDs 획득
  2. 사용자마다 그룹 목록 API를 추가 호출(N번)

결과적으로 N+1 문제(호출이 다량 발생)가 생깁니다.

원하는 목표: 단일 API로 (사용자, 그가 속한 그룹들) 정보를 한꺼번에 받기.


해결책: RealmResourceProvider(REST Extension) 활용

Keycloak는 내부 확장(SPI)을 통해, 서버 내부에 사용자 정의 JAX-RS 리소스(REST API)를 추가할 수 있습니다.

이것을 RealmResourceProvider라 부르며, 해당 SPI를 구현해 Keycloak 세션(KeycloakSession)에서 사용자/그룹 목록을 직접 조회한 뒤, 맞춤 JSON 형태로 반환할 수 있습니다.

장점

  • N+1 호출 한 번의 조회 로직으로 처리
  • 원하는 응답 DTO(예: username, email, groups 등)로 커스텀 가능
  • Keycloak 내부 인증/인가 컨텍스트(session.getContext())를 그대로 활용

단점

  • Java(JVM 언어) 로 SPI 구현 필요
  • Keycloak 버전업 시 호환성 유지 주의
  • 운영 환경에서 직접 배포해야 하므로 DevOps 부담


구현 예시

프로젝트 구조 (Gradle)

my-custom-rest-extension/
 ├─ build.gradle
 ├─ settings.gradle
 ├─ src/
 │   └─ main/
 │       ├─ java/
 │       │   └─ com/example/keycloak/rest/
 │       │       ├─ MyRealmResourceProviderFactory.java
 │       │       ├─ MyRealmResourceProvider.java
 │       │       ├─ MyCustomRestResource.java
 │       │       └─ dto/
 │       │           └─ UserWithGroupsDto.java
 │       └─ resources/
 │           └─ META-INF/
 │               └─ services/
 │                   └─ org.keycloak.services.resource.RealmResourceProviderFactory
 └─ Dockerfile

  • MyRealmResourceProviderFactory: Keycloak가 SPI를 인식하기 위한 팩토리
  • MyRealmResourceProvider: JAX-RS 리소스 객체를 리턴해 주는 Provider
  • MyCustomRestResource: 실제 REST API 구현 (사용자/그룹 정보를 한 번에)
  • UserWithGroupsDto: 반환 DTO 예시
  • META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory: Service Loader 정의
  • Dockerfile: 결과물을 Keycloak 이미지를 기반으로 빌드

주요 코드

아래는 간단히 “사용자 목록 + 그룹 목록”을 JSON으로 반환하는 엔드포인트 예시입니다.

(“/realms/{realm}/my-custom-rest/users-with-groups” 경로)

@Path("/")
public class MyCustomRestResource {

    private final KeycloakSession session;

    public MyCustomRestResource(KeycloakSession session) {
        this.session = session;
    }

    @GET
    @Path("users-with-groups")
    @Produces(MediaType.APPLICATION_JSON)
    public Response getUsersWithGroups(
            @QueryParam("first") @DefaultValue("0")  int first,
            @QueryParam("max")   @DefaultValue("20") int max,
            @QueryParam("search") @DefaultValue("")  String search
    ) {
        // 권한 체크 (예: realm-admin 역할 확인)
        if (!isCallerRealmAdmin()) {
            return Response.status(Response.Status.FORBIDDEN)
                    .entity("Not authorized")
                    .build();
        }

        RealmModel realm = session.getContext().getRealm();
        List<UserModel> users;

        // 검색어가 있으면 검색
        if (!search.isEmpty()) {
            users = session.users().searchForUserStream(realm, search, first, max)
                    .collect(Collectors.toList());
        } else {
            users = session.users().getUsersStream(realm, first, max)
                    .collect(Collectors.toList());
        }

        List<UserWithGroupsDto> results = users.stream().map(user -> {
            // 그룹 목록 추출
            List<String> groupNames = user.getGroupsStream()
                    .map(GroupModel::getName)
                    .collect(Collectors.toList());

            return new UserWithGroupsDto(
                    user.getId(),
                    user.getUsername(),
                    user.getEmail(),
                    groupNames
            );
        }).collect(Collectors.toList());

        return Response.ok(results).build();
    }

    private boolean isCallerRealmAdmin() {
        // Keycloak 내 세션에서 현재 인증 유저의 역할 체크
        AuthenticatedUserSession authSession = new AppAuthManager.BearerTokenAuthenticator(session).authenticate();
        if (authSession == null) return false;

        RealmModel realm = session.getContext().getRealm();
        RoleModel adminRole = realm.getRole("realm-admin");
        if (adminRole == null) return false;

        return authSession.getUser().hasRole(adminRole);
    }
}

위 로직은:

  1. search, first, max 파라미터로 페이징 & 검색
  2. UserModel 목록을 순회하며 **user.getGroupsStream()*으로 그룹들 조회
  3. DTO(UserWithGroupsDto)로 변환 후 JSON 배열 반환


Gradle 빌드 & Docker 배포

build.gradle 예시

plugins {
    id 'java'
}

group = 'com.example.keycloak'
version = '1.0.0'

repositories {
    mavenCentral()
}

ext {
    keycloakVersion = '21.1.1'
}

dependencies {
    compileOnly "org.keycloak:keycloak-server-spi:${keycloakVersion}"
    compileOnly "org.keycloak:keycloak-services:${keycloakVersion}"
}

jar {
    archiveBaseName.set("my-custom-rest-extension")
    archiveVersion.set("")
}

  1. compileOnly로 Keycloak 서버 SPI, services 의존성 선언
  2. jar 태스크로 JAR 생성

Dockerfile

FROM quay.io/keycloak/keycloak:21.1.1
WORKDIR /opt/keycloak

# Gradle 빌드 후 산출된 JAR 복사
COPY build/libs/my-custom-rest-extension.jar /opt/keycloak/providers/

# 개발 모드 실행
ENTRYPOINT ["/opt/keycloak/bin/kc.sh", "start-dev"]

 

728x90