이번 글에서는 기존 내부 인증 서버를 사용하고 있는 환경에서, 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으로 추가해야 한다.
작업 순서는 다음과 같았다.
- Realm 선택
- Authentication
- Flows
- 기본 browser 플로우를 Copy
- 복사한 새 플로우 선택
- Add execution
- 목록에서 Cookie Authenticator 추가
- Requirement 를 ALTERNATIVE 로 변경
- 위치를 Cookie 다음, Forms 앞쪽으로 조정
- Bindings 탭에서 Browser Flow를 새 플로우로 변경
이 과정을 마치고 나서야 실제 로그인 요청에서 custom authenticator가 실행되기 시작했다. User Storage SPI는 User federation에 추가하면된다.
구글 SAML Client - keycloak 연동
- Clients
- Create client - Client ID: google
- Client - Settings - Name ID format: email, Force POST binding: ON
- Realm settings - keys - Algorithm: RS256 - Certificate copy
- https://admin.google.com/ac/security/sso
- google - SAML 프로필 등록
- IDP 엔티티 ID: google
- 로그인 페이지 URL: https://{keycloak-domain}/realms/{realm}/protocol/saml
- 인증서 업로드: (4)에서 복사한 Certificate을 .pom 파일로 만들어 등록
- -----BEGIN CERTIFICATE----- ``` -----END CERTIFICATE-----
- SP 세부정보에 빌드된 엔티티 ID를 keycloak Client ID에 입력
- SP 세부정보에 빌드된 ACS URL을 아래 칸에 입력
- Settings - Valid redirect URIs
- Settings - Master SAML Processing URL
- Advanced - Assertion Consumer Service POST Binding URL
- keys - Encryption keys config - Encrypt assertions: ON
- keys - Encryption keys config - Certificate: 구글에서 생성한 SP 인증서