Keycloak

[Keycloak] 클라이언트별 그룹 로그인 제한 구현

kyoulho 2025. 5. 27. 16:07

Keycloak 26 버전에서는 클라이언트 단위로 로그인 제어를 정교하게 할 수 있는 기능이 제한적입니다. 본 포스트에서는 특정 클라이언트에 특정 그룹만 로그인 가능하도록 제어하는 커스텀 Authenticator SPI를 구현하고, 이를 Browser Flow에 적용하는 과정을 정리합니다.

1. 요구사항 정리

  • 특정 클라이언트(clientId)에 대해서만 특정 그룹(Group.name)에 속한 사용자만 로그인 가능
  • 클라이언트별로 매핑된 허용 그룹 리스트는 관리자가 UI에서 직접 설정할 수 있어야 함
  • 설정은 Keycloak Admin Console의 Authentication Flow에서 입력

2. 해결 전략

  • Keycloak의 Authenticator SPI를 구현하여 사용자 인증 시점에 그룹 조건을 체크
  • 실행 위치는 Username Password Form 이후로 설정하여 인증된 사용자 정보 활용
  • MAP_TYPE으로 설정값을 받아 JSON 문자열을 파싱하여 클라이언트별 그룹 리스트 추출

3. 주요 구현 코드 요약

GroupClientAuthenticator

package com.kyoulho;

import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.ws.rs.core.Response;
import org.jboss.logging.Logger;
import org.keycloak.authentication.AuthenticationFlowContext;
import org.keycloak.authentication.AuthenticationFlowError;
import org.keycloak.authentication.Authenticator;
import org.keycloak.forms.login.LoginFormsProvider;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserModel;

import java.util.Arrays;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;


public class GroupClientAuthenticator implements Authenticator {
    private static final Logger logger = Logger.getLogger(GroupClientAuthenticator.class);
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        if (context.getUser() == null) {
            logger.warnf("🚫 사용자 미설정: clientId=%s", context.getAuthenticationSession().getClient().getClientId());
            LoginFormsProvider form = context.form();
            context.failureChallenge(
                    AuthenticationFlowError.INVALID_USER,
                    form.setError("사용자 정보가 설정되지 않았습니다.")
                            .createErrorPage(Response.Status.UNAUTHORIZED)
            );
            return;
        }

        Map<String, String> config = context.getAuthenticatorConfig().getConfig();
        String rawMapJson = config.get("clientGroupMap");

        Map<String, String> clientGroupMap;
        try {
            List<Map<String, String>> entries = objectMapper.readValue(rawMapJson, new TypeReference<>() {});
            clientGroupMap = entries.stream()
                    .filter(entry -> entry.containsKey("key") && entry.containsKey("value"))
                    .collect(Collectors.toMap(e -> e.get("key"), e -> e.get("value")));
        } catch (JsonProcessingException e) {
            logger.error("❌ clientGroupMap 파싱 실패", e);
            context.failureChallenge(
                    AuthenticationFlowError.INTERNAL_ERROR,
                    context.form().setError("그룹 설정 파싱에 실패했습니다. 관리자에게 문의하세요.")
                            .createErrorPage(Response.Status.INTERNAL_SERVER_ERROR)
            );
            return;
        }

        String currentClient = context.getAuthenticationSession().getClient().getClientId();
        String csvGroups = clientGroupMap.get(currentClient);

        if (csvGroups == null || csvGroups.trim().isEmpty()) {
            logger.warnf("🚫 그룹 매핑 없음: clientId=%s", currentClient);
            LoginFormsProvider form = context.form();
            context.failureChallenge(
                    AuthenticationFlowError.INVALID_USER,
                    form.setError("이 클라이언트에 대한 그룹 매핑이 없습니다.")
                            .createErrorPage(Response.Status.FORBIDDEN)
            );
            return;
        }

        List<String> allowedGroups = Arrays.stream(csvGroups.split(","))
                .map(String::trim)
                .filter(s -> !s.isEmpty())
                .collect(Collectors.toList());

        UserModel user = context.getUser();
        boolean hasGroup = user.getGroupsStream()
                .map(GroupModel::getName)
                .anyMatch(allowedGroups::contains);

        if (!hasGroup) {
            logger.warnf("🚫 접근 불가: user=%s, client=%s, requiredGroups=%s",
                    user.getUsername(), currentClient, allowedGroups);
            LoginFormsProvider form = context.form();
            context.failureChallenge(
                    AuthenticationFlowError.INVALID_USER,
                    form.setError("이 클라이언트에 로그인 권한이 없습니다.")
                            .createErrorPage(Response.Status.FORBIDDEN)
            );
            return;
        }

        logger.infof("✅ 인증 성공: user=%s, client=%s", user.getUsername(), currentClient);
        context.success();
    }


    @Override
    public void action(AuthenticationFlowContext context) {
    }

    @Override
    public boolean requiresUser() {
        return true;
    }

    @Override
    public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
        return true;
    }

    @Override
    public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
    }

    @Override
    public void close() {
    }
}

GroupClientAuthenticatorFactory

package com.kyoulho;

import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;
import org.keycloak.authentication.AuthenticatorFactory;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.KeycloakSessionFactory;
import org.keycloak.models.AuthenticationExecutionModel.Requirement;
import org.keycloak.provider.ProviderConfigProperty;

import java.util.Arrays;
import java.util.HashMap;
import java.util.List;

public class GroupClientAuthenticatorFactory implements AuthenticatorFactory {
    public static final String PROVIDER_ID = "group-client-auth";

    @Override
    public Authenticator create(KeycloakSession session) {
        return new GroupClientAuthenticator();
    }

    @Override
    public void init(Config.Scope config) {
    }

    @Override
    public void postInit(KeycloakSessionFactory factory) {
    }

    @Override
    public void close() {
    }

    @Override
    public String getId() {
        return PROVIDER_ID;
    }

    @Override
    public String getDisplayType() {
        return "Group + Client Authenticator";
    }

    @Override
    public String getHelpText() {
        return "클라이언트별 그룹 매핑을 기반으로 로그인 허용 사용자 제어";
    }

    @Override
    public Requirement[] getRequirementChoices() {
        return new Requirement[]{Requirement.REQUIRED, Requirement.DISABLED};
    }

    @Override
    public boolean isConfigurable() {
        return true;
    }

    @Override
    public boolean isUserSetupAllowed() {
        return false;
    }

    @Override
    public String getReferenceCategory() {
        return null;
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        ProviderConfigProperty prop = new ProviderConfigProperty();
        prop.setName("clientGroupMap");
        prop.setLabel("Client to Groups Mapping");
        prop.setHelpText("클라이언트 이름을 키로, 콤마로 구분된 그룹 목록을 값으로 설정하세요.");
        prop.setType(ProviderConfigProperty.MAP_TYPE);
        prop.setDefaultValue(null);
        return List.of(prop);
    }
}

 


4. Admin Console 설정

4-1. Flow 복제 및 실행기 추가

  1. Authentication → Flows → browser 복제 → browser-with-group-check 생성
  2. 복제된 Flow 진입 → Group + Client Authenticator 실행기 추가
  3. Username Password Form 아래로 위치시키고 REQUIRED 설정

4-2. Execution 순서 예시

4-3. 실행기 설정

 


5. 클라이언트에 Flow 바인딩

  1. Clients → [대상 클라이언트] → Settings
  2. Capability config → Authorization → Off
  3. Authentication 탭 → Flow →browser-with-group-check -> Bind Flow 설정
  4. Choose binding type → Browser flow → Save

6. 테스트 및 유의사항

  • 클라이언트에 대해 그룹 매핑이 없거나, 유저가 허용되지 않은 그룹에 속한 경우 로그인 실패
  • context.getUser()는 Username Password Form 이후에만 유효하므로 실행 순서 필수
  • requiresUser() == true 설정 중요
  • MAP_TYPE은 Keycloak 내부에서 JSON 문자열로 저장됨 → 별도 파싱 필요