Notice
Recent Posts
Recent Comments
Link
«   2025/05   »
1 2 3
4 5 6 7 8 9 10
11 12 13 14 15 16 17
18 19 20 21 22 23 24
25 26 27 28 29 30 31
Tags more
Archives
Today
Total
관리 메뉴

두리공장

[keycloak] SPI 에서 custom role 연동하기 본문

cloud

[keycloak] SPI 에서 custom role 연동하기

두리공장 2023. 6. 7. 00:35

SPI 를 사용하면서 외부DB의 Custom Role을 적용하고 싶어졌다. 하지만 github 에는 아래와 같은 문구가 있었다.

Limitations
- Do not allow user information update, including password update
- Do not supports user roles our groups

지원을 안한다고 해서, 그냥 쓸수는 없지 않은가....
그래서 직접 구현해 보기로 했다.
인터넷에 찾아보니 UserAdapter 에서 getRoleMappings 를 오버라이드해서 추가 구현이 가능함을 볼 수 있었다. 그래서 아래와 같이 추가해 보았다.

public class UserAdapter extends AbstractUserAdapterFederatedStorage {

    private final String keycloakId;
    private       String username;
    private List<Map<String, String> > userRoles;
    
    public UserAdapter(KeycloakSession session, RealmModel realm, ComponentModel model, Map<String, String> data, boolean allowDatabaseToOverwriteKeycloak, List<Map<String, String>> userRoles) {
        super(session, realm, model);
        this.keycloakId = StorageId.keycloakId(model, data.get("id"));
        this.username = data.get("username");
        this.userRoles = userRoles;
        try {
          Map<String, List<String>> attributes = this.getAttributes();
          for (Entry<String, String> e : data.entrySet()) {
              Set<String>  newValues = new HashSet<>();
              if (!allowDatabaseToOverwriteKeycloak) {
                List<String> attribute = attributes.get(e.getKey());
                if (attribute != null) {
                    newValues.addAll(attribute);
                }
              }
              newValues.add(StringUtils.trimToNull(e.getValue()));
              this.setAttribute(e.getKey(), newValues.stream().filter(Objects::nonNull).collect(Collectors.toList()));

              // create Time 을 가져온다.
              log.info("createdTimestamp:" + data.get("createdTimestamp"));
              SimpleDateFormat sdf = new SimpleDateFormat("yyyy-MM-dd hh:mm:ss");
              if(!data.get("createdTimestamp").isEmpty() || data.get("createdTimestamp") != null) {
                  Date dt = sdf.parse(data.get("createdTimestamp"));
                  this.setCreatedTimestamp(dt.getTime());
              }
          }
        } catch(Exception e) {
          log.errorv(e, "UserAdapter constructor, username={0}", this.username);
        }
    }


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

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

    @Override
    public void setUsername(String username) {
        this.username = username;
    }

    /**
     * role을 추가한다.
     * @return
     */
    @Override
    public Set<RoleModel> getRoleMappings() {
        Set<RoleModel> set = new HashSet(this.getFederatedRoleMappings());
        if (this.appendDefaultRolesToRoleMappings()) {
            set.addAll((Collection)this.realm.getDefaultRole().getCompositesStream().collect(Collectors.toSet()));
        }

        set.addAll(this.getRoleMappingsInternal());

        if(userRoles.size() == 0){
            log.info("not exist role");

        }
        else {
            log.info("exist role");

            for (Map<String, String> userRole : userRoles) {
                RoleEntity roleEntity3 = new RoleEntity();
                roleEntity3.setId(userRole.get("role_id").toString());
                roleEntity3.setName(userRole.get("role_name").toString());
                roleEntity3.setDescription(userRole.get("role_desc").toString());

                RoleModel role2 = new RoleAdapter(session, realm, null, roleEntity3);
                set.add(role2);
            }
        }
        return set;
    }
}

UserAdapter 의 파라미터에서 Role 정보를 받게끔 하고 나서, 해당 클래스를 호출하는 소스를 수정했다.
DBUserStorageProvider.java

    private Stream<UserModel> toUserModel(RealmModel realm, List<Map<String, String>> users, List<Map<String,String>> userRoles) {
        return users.stream()
            .map(m -> new UserAdapter(session, realm, model, m, allowDatabaseToOverwriteKeycloak, userRoles));
    }
    
    @Override
    public UserModel getUserById(RealmModel realm, String id) {

        log.infov("lookup user by id: realm={0} userId={1}", realm.getId(), id);

        String externalId = StorageId.externalId(id);
        Map<String, String> user = repository.findUserById(externalId);
        List<Map<String,String>> userRoles = repository.findRoleById(externalId);

        //user 에 model 추가
        log.info("getUserById =====================> userRoles size:" + userRoles.size() + ", username:" + user.get("username"));
        if (user == null) {
            log.debugv("findUserById returned null, skipping creation of UserAdapter, expect login error");
            return null;
        } else {
            return new UserAdapter(session, realm, model, user, allowDatabaseToOverwriteKeycloak, userRoles);
        }
    }
    
    @Override
    public UserModel getUserByUsername(RealmModel realm, String username) {
        List<Map<String,String>> userRoles = repository.findRoleByUsername(username);
        log.infov("lookup user by username: realm={0} username={1}", realm.getId(), username);

        return repository.findUserByUsername(username).map(u -> new UserAdapter(session, realm, model, u, allowDatabaseToOverwriteKeycloak, userRoles)).orElse(null);
    }
    
    private Stream<UserModel> internalSearchForUser(String search, RealmModel realm, PagingUtil.Pageable pageable) {
        List<Map<String, String>> userRoles = repository.findRoleByUsername("search");
        return toUserModel(realm, repository.findUsers(search, pageable), userRoles);
    }

위의 소스를 보면 알겠지만, repository.findUserById,  repository.findRoleByUsername 메소드를 구현해야 한다.
그래서 UserRepository.java에 아래와 같이 구현했다.

   // get role
    public List<Map<String, String>> findRoleByUsername(String username) {
        if (username == null || username.isEmpty()) {
            return Optional.ofNullable(doQuery(queryConfigurations.getFindByRole(), null, this::readMap)).orElse(null);
        } else {
            return Optional.ofNullable(doQuery(queryConfigurations.getFindByRole(), null, this::readMap, username)).orElse(null);
        }
    }
    public List<Map<String, String>> findRoleById(String id) {
        if (id == null || id.isEmpty()) {
            return Optional.ofNullable(doQuery(queryConfigurations.getFindByRole(), null, this::readMap)).orElse(null);
        } else {
            return Optional.ofNullable(doQuery(queryConfigurations.getFindByRole(), null, this::readMap, id)).orElse(null);
        }
    }

그리고 나서, 쿼리를 가져오기 위해 QueryConfigurations 에 해당 쿼리를 호출하는 부분을 구현한다.

package org.opensingular.dbuserprovider.model;

import org.opensingular.dbuserprovider.persistence.RDBMS;

public class QueryConfigurations {

    private String count;
    private String listAll;
    private String findById;
    private String findByUsername;
    private String findBySearchTerm;
    private String findPasswordHash;
    private String hashFunction;
    private RDBMS  RDBMS;
    private boolean allowKeycloakDelete;
    private boolean allowDatabaseToOverwriteKeycloak;

    private String findByRole;

    public QueryConfigurations(String count, String listAll, String findById, String findByUsername, String findBySearchTerm, String findPasswordHash, String hashFunction, RDBMS RDBMS, boolean allowKeycloakDelete, boolean allowDatabaseToOverwriteKeycloak, String findByRole) {
        this.count = count;
        this.listAll = listAll;
        this.findById = findById;
        this.findByUsername = findByUsername;
        this.findBySearchTerm = findBySearchTerm;
        this.findPasswordHash = findPasswordHash;
        this.hashFunction = hashFunction;
        this.RDBMS = RDBMS;
        this.allowKeycloakDelete = allowKeycloakDelete;
        this.allowDatabaseToOverwriteKeycloak = allowDatabaseToOverwriteKeycloak;
        this.findByRole = findByRole;
    }

    public RDBMS getRDBMS() {
        return RDBMS;
    }

    public String getCount() {
        return count;
    }

    public String getListAll() {
        return listAll;
    }

    public String getFindById() {
        return findById;
    }

    public String getFindByUsername() {
        return findByUsername;
    }

    public String getFindByRole() {return findByRole;}

    public String getFindBySearchTerm() {
        return findBySearchTerm;
    }

    public String getFindPasswordHash() {
        return findPasswordHash;
    }

    public String getHashFunction() {
        return hashFunction;
    }

    public boolean isBlowfish() {
        return hashFunction.toLowerCase().contains("blowfish");
    }

    public boolean getAllowKeycloakDelete() {
        return allowKeycloakDelete;
    }

    public boolean getAllowDatabaseToOverwriteKeycloak() {
        return allowDatabaseToOverwriteKeycloak;
    }
}

마지막으로 셋팅시 입력한 쿼리를 가져오기 위해 DBUserStorageProviderFactory 를 수정해 준다.

... 중략 ...
    private synchronized ProviderConfig configure(ComponentModel model) {
        log.infov("Creating configuration for model: id={0} name={1}", model.getId(), model.getName());
        ProviderConfig providerConfig = new ProviderConfig();
        String         user           = model.get("user");
        String         password       = model.get("password");
        String         url            = model.get("url");
        RDBMS          rdbms          = RDBMS.getByDescription(model.get("rdbms"));
        providerConfig.dataSourceProvider.configure(url, rdbms, user, password, model.getName());
        providerConfig.queryConfigurations = new QueryConfigurations(
                model.get("count"),
                model.get("listAll"),
                model.get("findById"),
                model.get("findByUsername"),
                model.get("findBySearchTerm"),
                model.get("findPasswordHash"),
                model.get("hashFunction"),
                rdbms,
                model.get("allowKeycloakDelete", false),
                model.get("allowDatabaseToOverwriteKeycloak", false),
                model.get("findByRole")
        );
        return providerConfig;
    }
    
... 중략 ...

		   .add()
           .property()
           .name("findByRole")
           .label("Find user by role SQL query")
           .helpText(DEFAULT_HELP_TEXT + String.format(PARAMETER_HELP, "user userid") + PARAMETER_PLACEHOLDER_HELP)
           .type(ProviderConfigProperty.STRING_TYPE)
           .defaultValue(" select a.role_id, a.userid, b.id, b.username, c.role_name, c.role_desc\n"
                    + " from user_roles a left join users b\n"
                    + "                        on (a.userid = b.id)\n"
                    + "                  left join roles c\n"
                    + "                        on (a.role_id = c.role_id)\n"
                    + " where a.userid = ?")
... 중략 ...

물론 roles 테이블과 user_roles(매핑용) 테이블이 존재해야 한다.

select a.role_id, a.userid, b.id, b.username, c.role_name, c.role_desc
  from user_roles a left join users b
	                     on (a.userid = b.id)
	               left join roles c
	                    on (a.role_id = c.role_id);

이렇게 한 후, keycloak을 재기동 한다. 그리고 keycloak 어드민의 User federation 메뉴에 가서 다시한번 save 해 준다.

User 의 Role Mapping 탭에서 조회해 보면 Custom Role 이 잘 표시된다.

끝.