두리공장
[keycloak] SPI 에서 custom role 연동하기 본문
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 이 잘 표시된다.
끝.
'cloud' 카테고리의 다른 글
스프링부트에서 keycloak admin restAPI를 사용하려면,,, (0) | 2023.10.29 |
---|---|
[ELK] 엘라스틱서치에서 logstash 로 RDBMS 데이터 pipeline으로 연결하기 (0) | 2023.06.17 |
[keycloak] User Storage SPI 사용시 Create at 필드 동기화 (0) | 2023.06.06 |
[keycloak] SPI (service provider interface) 를 이용하여 사용자DB를 연결하기 (version 20.0.5) (0) | 2023.05.22 |