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 복제 및 실행기 추가
- Authentication → Flows → browser 복제 → browser-with-group-check 생성
- 복제된 Flow 진입 → Group + Client Authenticator 실행기 추가
- Username Password Form 아래로 위치시키고 REQUIRED 설정
4-2. Execution 순서 예시
4-3. 실행기 설정
5. 클라이언트에 Flow 바인딩
- Clients → [대상 클라이언트] → Settings 탭
- Capability config → Authorization → Off
- Authentication 탭 → Flow →browser-with-group-check -> Bind Flow 설정
- Choose binding type → Browser flow → Save
6. 테스트 및 유의사항
- 클라이언트에 대해 그룹 매핑이 없거나, 유저가 허용되지 않은 그룹에 속한 경우 로그인 실패
- context.getUser()는 Username Password Form 이후에만 유효하므로 실행 순서 필수
- requiresUser() == true 설정 중요
- MAP_TYPE은 Keycloak 내부에서 JSON 문자열로 저장됨 → 별도 파싱 필요