MSA 인증 서비스 Keycloak User Storage SPI

SPI ?


Keycloak 에서 제공하는 모듈과 같은 개념이다. SPI 는 Service Provider Interface 의 줄임말로 Keycloak 에 있는 각 기능들을 개발자가 직접 구현할 수 있게 도와준다. 일반적으로 Provider 파일과 ProviderFactory 파일로 구현한다. 구현 가능한 기능들을 로그인 & 회원가입등의 테마, LDAP, 인증 등이 있다.


User Storage SPI


User Storage SPI 는 기본적으로 H2 데이터베이스를 사용하는 Keycloak 을 자신의 외부 DB 에도 연결하도록 도와준다. 즉, 외부에 설치된 DB 를 이용해서 인증을 할 수 있게 된다.

참고: User Storgae SPI


User Storage 구현


User Storage 디렉토리 구조

  • adapter/CustomUserAdapter: 외부 DB 컬럼과 Keycloak DB 를 매핑시켜주는 어댑터
  • entity/AuthoritiesMapping: 외부 DB 의 권한 매핑 엔티티
  • entity/AuthoritiesMappingKey: 외부 DB 의 권한 매핑 복합키 엔티티
  • entity/AuthoritiesType: 외부 DB 의 권한 엔티티
  • entity/UserInfo: 외부 DB 의 사용자 엔티티
  • repository/CustomUserStorageRepository: 외부 DB 리포지토리 (Hibernate 사용)
  • CustomUserStorageProvider: 외부 DB 의 데이터를 사용하여 인증, 사용자 관리등을 구현
  • CustomUserStorageProviderFactory: Provider Id 를 설정하고 Keycloak 과 연결
  • resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory: 반드시 만들어야되는 파일로 Keycloak 에서는 이 파일로 Provider 를 찾아 적용한다.
  • resources/META-INF/persistence.xml: Hibernate 설정 파일

이제 하나하나 코드를 보며 알아보자. 먼저 가장 우선으로 구현해야 되는것이 META-INF 아래 파일과 Provider, ProviderFactory 이다.

먼저 META-INF/services 아래 파일을 보면 파일 이름은 Keycloak 의 UserStorageProviderFactory 클래스의 패키지 경로를 포함한 이름이다. 파일을 만들면 내용은 간단하게 개발자가 만든 Custom User Storage Provider 의 패키지 경로와 파일명을 적어주면 된다.

resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory

# {package path}.{provider factory file name}
com.demo.storage.CustomUserStorageProviderFactory

다음으로 Provider Factory 를 보자.

CustomUserStorageProviderFactory

import javax.naming.InitialContext;

import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.storage.UserStorageProviderFactory;

public class CustomUserStorageProviderFactory implements UserStorageProviderFactory<CustomUserStorageProvider> {

  @Override
  public CustomUserStorageProvider create(KeycloakSession session, ComponentModel model) {
    try {
      InitialContext ctx = new InitialContext();
      // 1) java:global/{jar 파일명}/Provider 클래스명
      CustomUserStorageProvider provider = (CustomUserStorageProvider) ctx.lookup("java:global/custom-user-storage-jpa/" + CustomUserStorageProvider.class.getSimpleName());
      provider.setModel(model);
      provider.setSession(session);

      return provider;
    } catch (Exception e) {
      throw new RuntimeException(e);
    }
  }

  @Override
  public String getId() {
    // 2) configuration.xml 에서 <spi name="{id값}"> 에 작성
    return "custom-user-storage";
  }
  
}
  1. JBoss(WildFly) 에서 설정 내용을 찾는다. 여기서는 전역 설정중 개발자가 구현한 User Storage Provider 를 찾으며, 주의해야 할 점은 “java:global/” 뒤에 반드시 개발자가 빌드한 jar 의 파일명으로 작성해야 한다는 것이다. WildFly 가 처음인 내게는 반나절을 꼬박 보낼정도로 찾기 힘들었다.. 마지막으로 뒤에 Provider 클래스 명을 입력하자.
  2. 여기도 중요한 부분인데 Keycloak 에서는 standalone.xml 파일에 SPI 들이 선언된 부분이 있는데, 여기에 개발자가 만든 SPI 도 작성하게 된다. 이때 Keycloak 은 무언가를 이용해서 개발자가 만든 SPI 를 찾거나 사용해야 하는데 이때 선언하는게 Id 값이다. 나중에 standalone.xml 파일에도 작성하겠지만 어떤 양식이 정해진게 아니어서 마음대로 작성해도 된다. 하지만 중요한점은 여기에 입력한 Id 값이랑 standalone.xml 에 작성할 Id 값이랑 같아야 한다.

위 두개 파일은 단지 Keycloak 과 매칭시켜주는 역할을 중점으로 한다. 다음은 본격적으로 SPI 의 내용을 구현하는 클래스다. 여기는 구현된 메소드가 조금 많아서 따로 설명하기 보다는 코드에 주석을 달아서 간략하게 설명을 하겠다.

CustomUserStorageProvider

import java.util.HashSet;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;

import javax.ejb.Local;
import javax.ejb.Remove;
import javax.ejb.Stateful;
import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;

import com.daumsoft.storage.userStorage.adapter.CustomUserAdapter;
import com.daumsoft.storage.userStorage.entity.UserInfo;

import org.keycloak.component.ComponentModel;
import org.keycloak.credential.CredentialInput;
import org.keycloak.credential.CredentialInputUpdater;
import org.keycloak.credential.CredentialInputValidator;
import org.keycloak.models.GroupModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.UserCredentialModel;
import org.keycloak.models.UserModel;
import org.keycloak.models.credential.OTPCredentialModel;
import org.keycloak.models.credential.PasswordCredentialModel;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.UserStorageProvider;
import org.keycloak.storage.user.UserLookupProvider;
import org.keycloak.storage.user.UserQueryProvider;
import org.keycloak.storage.user.UserRegistrationProvider;
import org.mindrot.jbcrypt.BCrypt;

// StateFul Bean 으로 선언
@Stateful
// EJB 클라이언트와 동일한 환경에 있을 경우 선언 즉, 여기선 WildFly 에 배포되기 때문에 동일한 환경으로 간주함
@Local(CustomUserStorageProvider.class)           // User Storage SPI 
public class CustomUserStorageProvider implements UserStorageProvider, 
                                                  // 사용자 조회
                                                  UserLookupProvider, 
                                                  // 사용자 조회
                                                  UserQueryProvider, 
                                                  // 사용자 등록
                                                  UserRegistrationProvider, 
                                                  // 비밀번호 검사
                                                  CredentialInputValidator, 
                                                  // 비밀번호 변경 
                                                  CredentialInputUpdater  {

  protected ComponentModel model;
  protected KeycloakSession session;

  // 외부 DB 쿼리를 사용할 EntityManager 사용, Spring Boot JPA 를 사용하려 했지만 안되서 그냥 Hibernate 를 사용
  @PersistenceContext
  protected EntityManager entityManager;

  // 빈 생성자 생성, 안해주면 에러가 난다.
  public CustomUserStorageProvider() {}
  // Keycloak 의 사용자 세션과 컴포넌트 모델을 가져옴
  public CustomUserStorageProvider(KeycloakSession session, ComponentModel model) {
    this.model = model;
    this.session = session;
  }
  
  // 작업이 끝나면 EntityManager 세션 종료등을 한꺼번에 수행하는 메소드
  @Remove
  @Override
  public void close() {}

  /**
   * 사용자가 로그인을 할때 입력된 Id 로 사용자 정보를 조회
   */
  @Override
  public UserModel getUserById(String keycloakId, RealmModel realm) {
    System.out.println("\n\ngetUserById\n\n");

    String userId = StorageId.externalId(keycloakId);
    UserInfo userInfo = entityManager.createNamedQuery("findByUserId", UserInfo.class)
    .setParameter("userId", userId)
    .getResultList().stream().findFirst().orElse(null);
    
    return new CustomUserAdapter(session, realm, model, userInfo);
  }
  /**
   * 사용자가 로그인을 할때 입력된 username 으로 사용자 정보를 조회
   */
  @Override
  public UserModel getUserByUsername(String username, RealmModel realm) {
    System.out.println("\ngetUserByUsername : " + username + "\n");
    
    UserInfo userInfo = entityManager.createNamedQuery("findByUserId", UserInfo.class)
    .setParameter("userId", username)
    .getResultList().stream().findFirst().orElse(null);

    return userInfo == null ? null : new CustomUserAdapter(session, realm, model, userInfo);
  }

  /**
   * 사용자가 로그인을 할때 입력된 Email 로 사용자 정보를 조회
   */
  @Override
  public UserModel getUserByEmail(String email, RealmModel realm) {
    System.out.println("\ngetUserByEmail: " + email + "\n");

    UserInfo userInfo = entityManager.createNamedQuery("findByUserEmail", UserInfo.class)
    .setParameter("userEmail", email)
    .getResultList().stream().findFirst().orElse(null);

    return userInfo == null ? null : new CustomUserAdapter(session, realm, model, userInfo);
  }

  /** 
   * 사용자 추가, 외부 DB 에 들어가는 것이 아니라 Keycloak DB 에 추가된다.
   */ 
  @Override
  public UserModel addUser(RealmModel realm, String username) {
    // System.out.println("\naddUser\n");
    // System.out.println(username);
    // System.out.println("\naddUser\n");
    // UserInfo userInfo = new UserInfo();
    // userInfo.setUserId(username);
    // userInfo.setDelYn("N");
    // entityManager.persist(userInfo);
    // return new CustomUserAdapter(session, realm, model, userInfo);
    return null;
  }

  /**
   * 사용자 삭제, 역시 Keycloak DB 에서 제거하는 용도
   */ 
  @Override
  public boolean removeUser(RealmModel realm, UserModel user) {
    return false;
  }
  
  /**
   * 사용자 비밀번호 업데이트
   */
  @Override
  public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) {
    System.out.println("updatecredential\n\n");
    System.out.println(user.toString());
    System.out.println(input);
    System.out.println("updatecredential\n\n");
    return true;
  }

  /**
   * 비밀번호 종류(Otp, Password, 등) 을 비활성화
   */
  @Override
  public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) {
    System.out.println("disableCredentialType\n\n");
    System.out.println(user.toString());
    System.out.println(credentialType);
    System.out.println("disableCredentialType\n\n");
  }

  /**
   * 비활성화 된 비밀번호 종류(Otp, Password) 조회, 여기서 OTP 는 사용하지 않을 것이므로 OTP 를 반환
   */
  @Override
  public Set<String> getDisableableCredentialTypes(RealmModel realm, UserModel user) {
    System.out.println("getDisableableCredentialTypes\n\n");

    Set<String> set = new HashSet<>();
    set.add(OTPCredentialModel.TYPE);

    return set;
  }

  /**
   * 지원되는 비밀번호 종류 (Password, Otp, ...) 가 현재 사용한 종류와 맞는지 확인
   */
  @Override
  public boolean supportsCredentialType(String credentialType) {
    System.out.println("\n\n supportsCredentialType: " + credentialType + "\n\n");

    return PasswordCredentialModel.TYPE.equals(credentialType);
  }

  /**
   * 정확히 어떤 기능인지는 모르지만 비밀번호 인증에 어떤 설정을 해주는? 메소드
   */
  @Override
  public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) {
    System.out.println("\n\n isConfiguredFor: " + credentialType + "\n\n");
    return false;
  }

  /**
   * 비밀번호 검사 메소드, 여기서는 Bcrypt 를 사용했다.
   */
  @Override
  public boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput) {
    if (!(credentialInput instanceof UserCredentialModel))
      return false;
    if (supportsCredentialType(credentialInput.getType())) {
      UserInfo userInfo = (UserInfo)session.getAttribute("userInfo");

      return BCrypt.checkpw(credentialInput.getChallengeResponse(), userInfo.getUserPassword());
    } else {
      return false; 
    }
  }

  /**
   * 사용자 수를 조회
   */
  @Override
  public int getUsersCount(RealmModel realm) {
    return 0;
  }

  /**
   * 사용자 목록을 조회, Keycloak REST API 등에서 사용
   */
  @Override
  public List<UserModel> getUsers(RealmModel realm) {
    System.out.println("\ngetUsers\n");
    return null;
  }

  /**
   * firstResult 페이지에서 maxResults 개의 사용자를 조회
   */
  @Override
  public List<UserModel> getUsers(RealmModel realm, int firstResult, int maxResults) {
    System.out.println("getUsers\n\n");
    System.out.println(firstResult + ", " + maxResults);
    System.out.println("getUsers\n\n");
    return null;
  }

  /**
   * Keycloak 관리자 대시보드에서 검색한 사용자 목록을 조회
   */
  @Override
  public List<UserModel> searchForUser(String search, RealmModel realm) {
    System.out.println("\nsearchForUser 1 :" + search + "\n\n");
    return null;
  }

  /**
   * Keycloak 관리자 대시보드에서 검색한 사용자 목록을 조회
   */
  @Override
  public List<UserModel> searchForUser(String search, RealmModel realm, int firstResult, int maxResults) {
    System.out.println("\nsearchForUser 2 :" + search + "\n");
    
    List<UserInfo> result = entityManager.createNamedQuery("findByUserIdLike", UserInfo.class).setParameter("userId", search).getResultList();

    // AuthoritiesType authType = result.get(0).getAuthoritiesMapping().get(0).getAuthoritiesType();
    
    // System.out.println("searchForUser 2 Role: ");
    // System.out.println(authType.getAuthorityType());
    // System.out.println("searchForUser 2 end");

    return result
    .stream()
    .map(user -> new CustomUserAdapter(session, realm, model, user))
    .collect(Collectors.toList());
  }

  @Override
  public List<UserModel> searchForUser(Map<String, String> params, RealmModel realm) {
    System.out.println("\nsearchForUser 3 : \n\n");
    return null;
  }

  /**
   * Keycloak 관리자 대시보드에서 전체 사용자 목록을 조회
   */
  @Override
  public List<UserModel> searchForUser(Map<String, String> params, RealmModel realm, int firstResult, int maxResults) {
    System.out.println("\nsearchForUser 4 :\n\n");
    
    List<UserInfo> result = entityManager.createNamedQuery("findAll", UserInfo.class)
    .setFirstResult(firstResult)
    .setMaxResults(20)
    .getResultList();

    return result.stream().map(user -> new CustomUserAdapter(session, realm, model, user))
        .collect(Collectors.toList());
  }

  /**
   * Keycloak 대시보드에서 검색한 그룹 멤버를 조회
   */ 
  @Override
  public List<UserModel> getGroupMembers(RealmModel realm, GroupModel group, int firstResult, int maxResults) {
    return null;
  }

  /**
   * Keycloak 대시보드에서 전체 그룹 멤버를 조회
   */ 
  @Override
  public List<UserModel> getGroupMembers(RealmModel realm, GroupModel group) {
    return null;
  }

  /**
   * Keycloak 대시보드에서 사용자 Attribute 를 조회
   */ 
  @Override
  public List<UserModel> searchForUserByUserAttribute(String attrName, String attrValue, RealmModel realm) {
    return null;
  }

  /**
   * Provider 에서 사용되는 Setter
   */
  public void setModel(ComponentModel model) {
    this.model = model;
  }

  /**
   * Provider 에서 사용되는 Setter
   */
  public void setSession(KeycloakSession session) {
    this.session = session;
  }
  
}

Provider 클래스는 간단한 인증 외에도 Keycloak 대시보드에서 사용되는 사용자 관련 조회, 등록, 변경, 삭제등을 직접 구현할 수 있게 되어있다. 다음은 외부 DB 의 내용들을 Keycloak DB 에 매핑할 수 있게 도와주는 Adapter 클래스를 보자. 역시 적지 않은 메소드로 인해 별도로 설명보다는 간단한 주석으로 처리했다.

CustomUserAdapter

import com.daumsoft.storage.userStorage.entity.AuthoritiesType;
import com.daumsoft.storage.userStorage.entity.UserInfo;

import org.keycloak.component.ComponentModel;
import org.keycloak.models.KeycloakSession;
import org.keycloak.models.RealmModel;
import org.keycloak.models.RoleModel;
import org.keycloak.storage.StorageId;
import org.keycloak.storage.adapter.AbstractUserAdapterFederatedStorage;

                                       // User Adapter Federated Storage 추상클래스 상속
public class CustomUserAdapter extends AbstractUserAdapterFederatedStorage {

  protected UserInfo userInfo;
  protected String keycloakId;

  // Keycloak DB 와 매핑될 Adapter 생성자
  public CustomUserAdapter(KeycloakSession session, RealmModel realm, ComponentModel model, UserInfo userInfo) {
    super(session, realm, model);

    this.userInfo = userInfo;

    if (userInfo != null) {
      // 외부 DB 에서 조회한 사용자 정보가 null 이 아닐 경우 외부 DB 의 사용자 PK 를 Keycloak 사용자 DB 의 PK 값으로 사용
      this.keycloakId = StorageId.keycloakId(model, String.valueOf(userInfo.getUserSeq()));
      // Provider 에서 사용하기 위해 세션에 사용자 정보 값을 저장
      session.setAttribute("userInfo", userInfo);
    }
  }
  
  /**
   * Keycloak DB 의 username 입력, 조회 일반적으로 사용되는 id 개념이다
   */
  @Override
  public String getUsername() {
    return userInfo != null ? userInfo.getUserId() : null;
  }

  @Override
  public void setUsername(String username) {
    userInfo.setUserId(username);
  }
  /**
   * Keycloak DB 의 Email 입력, 조회
   */
  @Override
  public String getEmail() {
    return userInfo != null ? userInfo.getUserEmail() : null;
  }

  @Override
  public void setEmail(String email) {
    userInfo.setUserEmail(email);
  }

  /**
   * 외부 DB 의 Role 을 가져와 Keycloak 에서 사용할 수 있게하는 메소드
   */ 
  @Override
  public void grantRole(RoleModel role) {
    AuthoritiesType authType = userInfo.getAuthoritiesMapping().get(0).getAuthoritiesType();

    System.out.println("grantRole\n");
    System.out.println(authType.getAuthorityType());
    System.out.println("\ngrantRole");
  }
  
}

3개의 주요 클래스와 하나의 설정파일들을 보았다. 엔티티와 리포지토리의 경우는 외부 DB 환경에 맞춰서 구현하면 될거 같다. 추가로 하나더 알아보자면 이전에 Keycloak standalone.xml 설정파일에서 작성한 외부 DB 소스가 있다. 이걸 사용하려면 persistance.xml 에 다음을 추가해주면 된다.

persistance.xml

<?xml version="1.0" encoding="UTF-8"?>
<persistence xmlns="https://xmlns.jcp.org/xml/ns/persistence" 
  xmlns:xsi="https://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="https://xmlns.jcp.org/xml/ns/persistence
             https://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd" version="2.1">

  <persistence-unit name="custom-user-storage-jpa">
    <description>Hibernate Entity Manager Example</description>
    <!-- standalone.xml 에서 작성한 DB 설정들을 가져온다. -->
    <jta-data-source>java:jboss/datasource/UserStoreXADS</jta-data-source>

    <properties>
      <property name="hibernate.show_sql" value="true" />
      <property name="hibernate.hbm2ddl.auto" value="none" />
      <property name="hibernate.dialect" value="org.hibernate.dialect.MariaDBDialect" />
      <!-- <property name="hibernate.physical_naming_strategy" value="org.ohjic.hfinSync.PhysicalNamingStrategyImpl" /> -->
    </properties>

  </persistence-unit>

</persistence>


Keycloak standalone.xml 설정


Provider 를 적용하기 위해서 몇가지 사전에 설정파일에 작성해야 하는것이 있다. 많지는 않고 두가지? 정도 된다.

첫째로 개발자가 만든 spi 를 찾기 위한 선언코드, 이전에 ProviderFactory 에서 작성한 Id 값을 여기에서 사용한다.

standalone.xml

<!-- Provider Factory 에서 작성한 id 값을 입력 -->
<spi name="custom-user-storage">
</spi>

둘째는 persistance.xml 에서 Keycloak 설정파일에서 작성한 DB 속성을 사용하기 위해 설정한 DataSource 내용이다. 이전 글에서 작성한 Datasource 부분에서 “xa-“ 부분을 추가했다. 이 부분이 persistance.xml 에서 사용되는 DB 설정이다.

standalone.xml


<subsystem xmlns="urn:jboss:domain:datasources:5.0">
  <datasources>
    ...
    <xa-datasource jndi-name="java:jboss/datasource/UserStoreXADS" pool-name="UserStoreXADS">
        <xa-datasource-property name="ServerName">
            {external db host}
        </xa-datasource-property>
        <xa-datasource-property name="DatabaseName">
            {external db name}
        </xa-datasource-property>
        <driver>mariadb</driver>
        <security>
            <user-name>{external db username}</user-name>
            <password>{external db password}</password>
        </security>
    </xa-datasource>
    <drivers>
      <driver name="mariadb" module="org.mariadb">
          <xa-datasource-class>org.mariadb.jdbc.MariaDbDataSource</xa-datasource-class>
      </driver>
    </drivers>
  </datasources>
  ...
</subsystem>


How to use?


개발도 다 되었고, 설정파일도 작성했다. 그럼 이제 빌드를 할것이고, jar 파일을 만들것이다. 그럼 이제 이걸 어떻게 해야하는거지? 라는 고민을 했고 찾아보았다. 멀지 않은데서 찾을 수 있었다. 공식문서에 잘 나와있었다. 방법은 간단하다. standalone/deployments 아래에 jar 파일을 복사해주면 된다. 단! 앞에서 말한 파일명을 Provider Factory 클래스에서 작성한 “java:global” 뒤에 오는 이름과 같게 해줘야 한다는점 주의하자.

이제 Keycloak 서버를 재실행하거나 실행중이라면 가만두면 자동으로 Deploy 가 된다. 그리고서는 Keycloak 대시보드에서 잘 적용되었는지 확인해보자.

먼저 User Federation 탭을 선택하고 Select box 에서 자신이 추가한 Id 값을 찾는다.

선택한 후 save 버튼을 누르면 아래와 같이 잘 추가된 화면을 볼 수 있다. 아래 이미지는 공식문서에서 가져온것이므로 만약 위에 작성한 jar 를 추가해줬다면 ID 에 “custom-user-storage” 가 보일 것이다.


마치며


일단 구현을 하고보니 조금 어려운 점이 많았다. 사용자 추가되는 점에서 외부 DB 와 Keycloak DB 가 다르다거나 사용되는 Key 들이 너무 달라서 구현하는데 리소스가 많이 들어가게 되었다. 그래서 경험삼아 구현을 했다고 치고 다른 방법을 찾기로 했다. 어떻게 해야 할까 고민하다가 어차피 Keycloak 에서 사용자 관련 모든 기능을 제공하니 차라리 Keycloak DB 를 사용하자고! 그리고는 헐레벌떡 파이썬으로 Keycloak REST API 를 이용해서 외부 DB 내용을 입력해주었다.

REST API 는 다음 편에서 간단히 소개하고, OIDC 를 사용하는 Keycloak 에서 Naver 로그인을 연동하는 방법도 잠깐 소개할 것이다.

댓글남기기