이번 글에서는 기존 내부 인증 서버를 사용하고 있는 환경에서, Keycloak Authentication SPI 를 이용해 로그인 화면 노출 없이 쿠키 기반 자동 로그인을 구현한 과정을 정리한다.

우리 환경은 단순한 Keycloak 단독 인증 구조가 아니었다.
이미 운영 중인 인증 서버가 있었고, 이를 통해 인증 토큰을 발급받아 쿠키 기반 세션 유지를 하고 있었다. 여기에 Google Workspace SSO 를 붙이기 위해 Keycloak을 추가하면서, 다음과 같은 요구사항이 생겼다.

  • GWS 로그인 시 기존 인증 체계를 그대로 사용해야 한다.
  • Google Workspace SSO의 IdP 역할은 Keycloak이 담당해야 한다.
  • 사용자가 이미 로그인되어 내부 인증 서버 쿠키가 살아 있다면, Keycloak 로그인 화면 없이 자동 로그인되어야 한다.
  • 반대로 내부 인증 서버 쿠키가 없거나 유효하지 않으면, 그때만 Keycloak 로그인 화면이 보여야 한다.

즉, 기존 인증의 출처(Source of Truth) 유지하고, Keycloak은 Google Workspace와 연동하기 위한 SAML IdP 및 세션 관리자 역할을 하도록 구성했다.


이번 요구사항의 핵심은 'User Storage SPI''Authentication SPI' 설계였다. 우리가 해결해야 했던 것은 다음이었다.

  • 사용자가 Keycloak 로그인 페이지를 보기 전에
  • 브라우저에 있는 내부 인증 서버 쿠키를 확인하고
  • 유효하면 로그인 화면 없이 바로 인증 성공 처리

keycloak에 유저를 등록해서 사용하지 않고 기존 내부 디비에 저장된 유저 정보를 사용하기 위해 User Storage SPI, 로그인 시점의 인증 방식 제얼르 위해 Authentication SPI를 모두 이용했다.

  • Authentication SPI
    현재 요청에서 로그인 성공 여부를 결정
  • User Storage SPI
    User 조회 로직(DB/JDBC) - userId/userName를 기준으로 내부 사용자 조회

Keycloak에서 이걸 구현하려면 Browser Flow 에 custom Authenticator를 추가해야 한다.

우리가 의도한 플로우는 다음과 같다.

  • Cookie — ALTERNATIVE
  • Internal Cookie Authenticator — ALTERNATIVE
  • Forms subflow

이 구조의 의미는 다음과 같다.

1) Cookie execution

Keycloak 자체 세션 쿠키가 있으면 여기서 바로 통과한다.
즉, Keycloak 세션이 살아 있으면 custom authenticator는 아예 실행되지 않는다.

2) Internal Cookie Authenticator

Keycloak 세션은 없지만 브라우저에 내부 인증 서버 쿠키가 있으면, 이 execution에서 내부 인증 서버를 통해 검증한다.
검증 성공 시 context.setUser() 와 context.success() 를 호출해 로그인 화면 없이 인증을 완료한다.

3) Forms subflow

내부 인증 서버 쿠키도 없거나 검증 실패하면, 그제야 일반 로그인 폼을 보여준다.

이 구조 덕분에 다음 요구사항을 만족할 수 있었다.

  • Keycloak 세션이 있으면 빠르게 통과
  • Keycloak 세션이 없더라도 내부 인증 서버 쿠키가 있으면 무인 재인증
  • 둘 다 없을 때만 로그인 폼 표시

내부 DB 조회 방식

 

1. AbstractUserAdapterFederatedStorage

public class UserAdapter extends AbstractUserAdapterFederatedStorage {

    private final ComponentModel model;
    private final User user;

    public UserAdapter(
            KeycloakSession session,
            RealmModel realm,
            ComponentModel model,
            User user
    ) {
        super(session, realm, model);
        this.model = model;
        this.user = user;
    }

    @Override
    public String getId() {
        return StorageId.keycloakId(model, user.userId());
    }

    @Override
    public String getUsername() {
        return user.username();
    }

    @Override
    public String getEmail() {
        return user.email();
    }

}

 

2. User

public record User(
        String userId,
        String username,
        String email,
        boolean enabled
) {

}

 

3. UserStorageProviderFactory

public class SsoUserStorageProviderFactory implements UserStorageProviderFactory<SsoUserStorageProvider> {

    public static final String PROVIDER_ID = "sso-user-storage";

    @Override
    public SsoUserStorageProvider create(KeycloakSession session, ComponentModel model) {
        SsoApi ssoApi = new SsoApiImpl(
                model.getConfig().getFirst("signUrl")
        );

        UserRepository userRepository = new UserRepositoryImpl(
                model.getConfig().getFirst("jdbcUrl"),
                model.getConfig().getFirst("jdbcUser"),
                model.getConfig().getFirst("jdbcPassword")
        );

        return new SsoUserStorageProvider(
                session,
                model,
                ssoApi,
                userRepository
        );
    }

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

    @Override
    public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException {
        require(config, "signUrl");
        require(config, "jdbcUrl");
        require(config, "jdbcUser");
        require(config, "jdbcPassword");
    }

    private void require(ComponentModel config, String key) {
        String value = config.getConfig().getFirst(key);
        if (value == null || value.isBlank()) {
            throw new ComponentValidationException(key + " is required");
        }
    }

    @Override
    public List<ProviderConfigProperty> getConfigProperties() {
        ProviderConfigProperty signUrl = new ProviderConfigProperty();
        signUrl.setName("signUrl");
        signUrl.setLabel("Sign URL");
        signUrl.setType(ProviderConfigProperty.STRING_TYPE);
        signUrl.setHelpText("SSO sign API URL");

        ProviderConfigProperty jdbcUrl = new ProviderConfigProperty();
        jdbcUrl.setName("jdbcUrl");
        jdbcUrl.setLabel("JDBC URL");
        jdbcUrl.setType(ProviderConfigProperty.STRING_TYPE);
        jdbcUrl.setHelpText("Database JDBC connection URL");

        ProviderConfigProperty jdbcUser = new ProviderConfigProperty();
        jdbcUser.setName("jdbcUser");
        jdbcUser.setLabel("JDBC User");
        jdbcUser.setType(ProviderConfigProperty.STRING_TYPE);
        jdbcUser.setHelpText("Database login user");

        ProviderConfigProperty jdbcPassword = new ProviderConfigProperty();
        jdbcPassword.setName("jdbcPassword");
        jdbcPassword.setLabel("JDBC Password");
        jdbcPassword.setType(ProviderConfigProperty.PASSWORD);
        jdbcPassword.setHelpText("Database login password");

        return List.of(signUrl, jdbcUrl, jdbcUser, jdbcPassword);
    }

    @Override
    public void close() {
    }

}

 

4. UserStorageProvider

public class SsoUserStorageProvider implements UserStorageProvider, UserLookupProvider, CredentialInputValidator, CredentialInputUpdater {

    private final KeycloakSession session;
    private final ComponentModel model;
    private final SsoApi ssoApi;
    private final UserRepository userRepository;

    private final Map<String, UserModel> loadedByUsername = new HashMap<>();
    private final Map<String, UserModel> loadedByExternalId = new HashMap<>();

    public SsoUserStorageProvider(
            KeycloakSession session,
            ComponentModel model,
            SsoApi ssoApi,
            UserRepository userRepository
    ) {
        this.session = session;
        this.model = model;
        this.ssoApi = ssoApi;
        this.userRepository = userRepository;
    }

    @Override
    public UserModel getUserById(RealmModel realm, String id) {
        String externalId = StorageId.externalId(id);

        UserModel cached = loadedByExternalId.get(externalId);
        if (cached != null) {
            return cached;
        }

        User user = userRepository.findByUserId(externalId);
        if (user == null) {
            return null;
        }

        UserModel adapter = new UserAdapter(session, realm, model, user);
        cache(user, adapter);
        return adapter;
    }

    @Override
    public UserModel getUserByUsername(RealmModel realm, String username) {
        UserModel cached = loadedByUsername.get(username);
        if (cached != null) {
            return cached;
        }

        User user = userRepository.findByUsername(username);
        if (user == null) {
            return null;
        }

        UserModel adapter = new UserAdapter(session, realm, model, user);
        cache(user, adapter);
        return adapter;
    }

    @Override
    public UserModel getUserByEmail(RealmModel realm, String email) {
        return null;
    }

    @Override
    public boolean supportsCredentialType(String credentialType) {
        return PasswordCredentialModel.TYPE.equals(credentialType);
    }

    @Override
    public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
        return supportsCredentialType(credentialType);
    }

    @Override
    public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) {
        if (user == null || input == null) {
            return false;
        }
        if (!supportsCredentialType(input.getType())) {
            return false;
        }

        String username = user.getUsername();
        String password = input.getChallengeResponse();

        SsoAuthResponse ssoRes = ssoApi.requestSign(
                nvl(username),
                nvl(password),
                ""
        );
        List<String> cookies = ssoRes.setCookies();
        String userId = null;
        try {
            userId = SsoCookieUtils.replaceCookie(cookies);
        } catch (Exception e) {
            return false;
        }
        if (userId == null || userId.isBlank()) {
            return false;
        }

        String expectedExternalId = StorageId.externalId(user.getUsername());
        return userId.equals(expectedExternalId);
    }

    private void cache(User user, UserModel adapter) {
        loadedByUsername.put(user.username(), adapter);
        loadedByExternalId.put(user.userId(), adapter);
    }

    private String nvl(String value) {
        return value == null ? "" : value;
    }

    @Override
    public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
        throw new ReadOnlyException("Password is managed by external SSO");
    }

    @Override
    public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
        throw new ReadOnlyException("Password is managed by external SSO");
    }

    @Override
    public Stream<String> getDisableableCredentialTypesStream(RealmModel realm, UserModel user) {
        return Stream.of(PasswordCredentialModel.TYPE);
    }

    @Override
    public void close() {
    }
}

쿠키 인증 처리 로직

 

1. AuthenticatorFactory

public class CookieAuthenticatorFactory implements AuthenticatorFactory {

    public static final String PROVIDER_ID = "cookie-authenticator";

    private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = {
            AuthenticationExecutionModel.Requirement.REQUIRED,
            AuthenticationExecutionModel.Requirement.ALTERNATIVE,
            AuthenticationExecutionModel.Requirement.DISABLED
    };

    private static final List<ProviderConfigProperty> CONFIG_PROPERTIES;

    static {
        ProviderConfigProperty authUrl = new ProviderConfigProperty();
        authUrl.setName("authUrl");
        authUrl.setLabel("Auth URL");
        authUrl.setType(ProviderConfigProperty.STRING_TYPE);
        authUrl.setHelpText("SSO auth API URL");

        ProviderConfigProperty cookieName = new ProviderConfigProperty();
        cookieName.setName(CookieAuthenticator.COOKIE_NAME);
        cookieName.setLabel("Cookie Name");
        cookieName.setType(ProviderConfigProperty.STRING_TYPE);
        cookieName.setDefaultValue(CookieAuthenticator.COOKIE_NAME);

        ProviderConfigProperty jdbcUrl = new ProviderConfigProperty();
        jdbcUrl.setName("jdbcUrl");
        jdbcUrl.setLabel("JDBC URL");
        jdbcUrl.setType(ProviderConfigProperty.STRING_TYPE);
        jdbcUrl.setHelpText("Database JDBC connection URL");

        ProviderConfigProperty jdbcUser = new ProviderConfigProperty();
        jdbcUser.setName("jdbcUser");
        jdbcUser.setLabel("JDBC User");
        jdbcUser.setType(ProviderConfigProperty.STRING_TYPE);
        jdbcUser.setHelpText("Database login user");

        ProviderConfigProperty jdbcPassword = new ProviderConfigProperty();
        jdbcPassword.setName("jdbcPassword");
        jdbcPassword.setLabel("JDBC Password");
        jdbcPassword.setType(ProviderConfigProperty.PASSWORD);
        jdbcPassword.setHelpText("Database login password");

        CONFIG_PROPERTIES = List.of(authUrl, cookieName, jdbcUrl, jdbcUser, jdbcPassword);
    }

}

 

2. Authenticator

public class CookieAuthenticator implements Authenticator {

    private static final Logger log = Logger.getLogger(CookieAuthenticator.class);

    public static final String COOKIE_NAME = "-";

    @Override
    public void authenticate(AuthenticationFlowContext context) {
        AuthenticatorConfigModel config = context.getAuthenticatorConfig();
        Map<String, String> cfg = config != null ? config.getConfig() : Map.of();

        String authUrl = cfg.get("authUrl");
        String jdbcUrl = cfg.get("jdbcUrl");
        String jdbcUser = cfg.get("jdbcUser");
        String jdbcPassword = cfg.get("jdbcPassword");

        UserRepository userRepository = new UserRepositoryImpl(jdbcUrl, jdbcUser, jdbcPassword);
        TokenVerifier tokenVerifier = new TokenVerifierImpl(authUrl, userRepository);

        String cookieHeader = context.getHttpRequest()
                .getHttpHeaders()
                .getHeaderString(HttpHeaders.COOKIE);

        if (cookieHeader == null || cookieHeader.isBlank()) {
            context.attempted();
            return;
        }

        try {
            User user = tokenVerifier.verify(cookieHeader);
            if (user == null || user.username() == null || user.username().isBlank()) {
                log.warn("token verification failed or user info missing");
                context.attempted();
                return;
            }

            UserModel userModel = findUser(context, user);
            if (userModel == null) {
                log.warnf("Keycloak user not found. username=%s, email=%s",
                        user.username(), user.email());

                // 정책에 따라 attempted()로 폼으로 넘길지, failure()로 막을지 선택
                context.failure(AuthenticationFlowError.UNKNOWN_USER);
                return;
            }

            if (!userModel.isEnabled()) {
                log.warnf("User disabled. username=%s", user.username());
                context.failure(AuthenticationFlowError.USER_DISABLED);
                return;
            }

            context.setUser(userModel);
            context.success();
        } catch (Exception e) {
            log.error("token authentication error", e);

            // 위변조/서버오류를 강하게 막고 싶으면 failure
            // 그냥 다음 폼으로 넘기고 싶으면 attempted
            context.failure(AuthenticationFlowError.INTERNAL_ERROR);
        }
    }

    @Override
    public void action(AuthenticationFlowContext context) {
        // 이 authenticator는 화면/폼 submit을 안 쓰므로 보통 no-op
        context.attempted();
    }

    @Override
    public boolean requiresUser() {
        // token으로 사용자를 식별하므로 false
        return false;
    }

    @Override
    public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) {
        // 별도 user setup 필요 없음
        return true;
    }

    @Override
    public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {
        // required action 없음
    }

    @Override
    public void close() {
    }
    
    private UserModel findUser(AuthenticationFlowContext context, User user) {
        KeycloakSession session = context.getSession();
        RealmModel realm = context.getRealm();
        UserModel userModel = session.users().getUserByUsername(realm, user.username());
        if (userModel != null) {
            return userModel;
        }
        return null;
    }
}

 

3. TokenVerifierImpl

public class TokenVerifierImpl implements TokenVerifier {
    private final HttpClient httpClient = HttpClient.newHttpClient();
    private final UserRepository userRepository;
    private final String authUrl;

    public TokenVerifierImpl(String authUrl, UserRepository userRepository) {
        this.authUrl = authUrl;
        this.userRepository = userRepository;
    }

    @Override
    public User verify(String cookieHeader) throws Exception {
        if (cookieHeader == null || cookieHeader.isBlank()) {
            return null;
        }

        try {
            String body = "-";

            HttpRequest request = HttpRequest.newBuilder()
                    .uri(URI.create(authUrl))
                    .header("Cookie", cookieHeader)
                    .header("Content-Type", "application/x-www-form-urlencoded")
                    .POST(HttpRequest.BodyPublishers.ofString(body))
                    .build();

            HttpResponse<String> response =
                    httpClient.send(request, HttpResponse.BodyHandlers.ofString());

            List<String> cookies = response.headers().allValues("set-cookie");
            String userId = SsoCookieUtils.replaceCookie(cookies);
            if (userId == null || userId.isBlank()) {
                return null;
            }

            return userRepository.findByUsername(userId);
        } catch (Exception e) {
            throw new RuntimeException("verifyByCookieHeader failed", e);
        }
    }

    private String enc(String value) {
        return URLEncoder.encode(value == null ? "" : value, StandardCharsets.UTF_8);
    }

}

코드만 배포했다고 바로 동작하는 건 아니었다. 여기서 많이 헷갈린 부분이 있는데, 우리가 한 건 User Federation provider 등록이 아니라 custom authenticator를 서버에 배포한 것이다. 즉, JAR을 providers/ 에 배포했다고 해서 끝이 아니다. 실제로 로그인에 사용하려면 Browser Flow에 execution으로 추가해야 한다.

 

작업 순서는 다음과 같았다.

  1. Realm 선택
  2. Authentication
  3. Flows
  4. 기본 browser 플로우를 Copy
  5. 복사한 새 플로우 선택
  6. Add execution
  7. 목록에서 Cookie Authenticator 추가
  8. Requirement 를 ALTERNATIVE 로 변경
  9. 위치를 Cookie 다음, Forms 앞쪽으로 조정
  10. Bindings 탭에서 Browser Flow를 새 플로우로 변경

이 과정을 마치고 나서야 실제 로그인 요청에서 custom authenticator가 실행되기 시작했다. User Storage SPI는 User federation에 추가하면된다.


구글 SAML Client - keycloak 연동

  1. Clients
  2. Create client - Client ID: google
  3. Client - Settings - Name ID format: email, Force POST binding: ON
  4. Realm settings - keys - Algorithm: RS256 - Certificate copy
  5. https://admin.google.com/ac/security/sso
  6. google - SAML 프로필 등록
  7. IDP 엔티티 ID: google
  8. 로그인 페이지 URL: https://{keycloak-domain}/realms/{realm}/protocol/saml
  9. 인증서 업로드: (4)에서 복사한 Certificate을 .pom 파일로 만들어 등록
    • -----BEGIN CERTIFICATE----- ``` -----END CERTIFICATE-----
  10. SP 세부정보에 빌드된 엔티티 ID를 keycloak Client ID에 입력
  11. SP 세부정보에 빌드된 ACS URL을 아래 칸에 입력
    • Settings - Valid redirect URIs
    • Settings - Master SAML Processing URL
    • Advanced - Assertion Consumer Service POST Binding URL
  12. keys - Encryption keys config - Encrypt assertions: ON
  13. keys - Encryption keys config - Certificate: 구글에서 생성한 SP 인증서

'Others' 카테고리의 다른 글

opensearch root CA 등록  (0) 2026.04.17
logback + fluent-bit + opensearch 개발 서버용  (1) 2026.04.15
Redis, Prometheus, Grafana 모니터링  (0) 2025.10.31
mybatis sql 로그 pretty하게 남기기  (4) 2025.07.31
Redis + Sentine  (0) 2025.03.28

+ Recent posts