diff --git a/.gitignore b/.gitignore index 0e13eeb..148e9ff 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ buildNumber.properties .mvn/timing.properties # https://github.com/takari/maven-wrapper#usage-without-binary-jar .mvn/wrapper/maven-wrapper.jar +/.classpath +/.project +/.settings \ No newline at end of file diff --git a/README.md b/README.md index 74733a4..d426b86 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,16 @@ # http-keycloak-userstorage-spi -An example for having a custom user storage for Keycloak users, groups and roles + +An example for having a custom user storage for Keycloak users, groups and roles. + +Keycloak allows to have your own UserStorage backend. So you don't need to have an AD or LDAP. A possible use-case is for example an existing legacy system with its own user storage and you want to build some new services around it. When you then start with OIDC, you can use Keycloak as a OIDC provider and that can use your legacy backend for user storage. + +This project uses the Keycloak UserStorage service provider interface to allow an HTTP client to read users from such a backend. + +The backend itself needs at least this REST endpoints. + +- GET /user - returns a list of HTTPUserModel. It supports paging and filtering (offset, limit) with the query param search for random search or group. +- GET /user/{username} - returns a single HTTPUserModel that matches the given username. If the HTTP response is not 200, there is no match. +- GET /user/mail/{email} - returns a single HTTPUserModel that matches the given mail address. If the HTTP response is not 200, there is no match. +- POST /user/validate/{username} - the POST body contains the password. This is used for validating the users password. + +The HTTPUserModel contains some basic informations about the user for Keycloak, like the username, first and last name, email and attributes. If you want to apply groups and roles to the user (which is useful, if your services depends on different roles) your backend needs to fill the HashMap>. Where the key is the group name and the List is the list of role names. Of course you can build complexer GroupModels and RoleModels, if you want. \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..7324d51 --- /dev/null +++ b/pom.xml @@ -0,0 +1,90 @@ + + + 4.0.0 + + http.keycloak + userstorage-spi + 0.1.0 + + Keycloak HTTP UserStoreProvider + + + + 11 + 11 + 11 + UTF-8 + UTF-8 + + 9.0.3 + 4.5.8.Final + + + + + org.keycloak + keycloak-core + provided + ${keycloak.version} + + + org.keycloak + keycloak-server-spi + provided + ${keycloak.version} + + + org.keycloak + keycloak-server-spi-private + provided + ${keycloak.version} + + + org.keycloak + keycloak-kerberos-federation + provided + ${keycloak.version} + + + org.jboss.resteasy + resteasy-client + ${resteasy.version} + + + org.jboss.resteasy + resteasy-jackson2-provider + provided + ${resteasy.version} + + + org.jboss.logging + jboss-logging + provided + 3.4.1.Final + + + junit + junit + test + 4.13.1 + + + org.jboss.spec.javax.transaction + jboss-transaction-api_1.3_spec + provided + 2.0.0.Final + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.0 + + + + + \ No newline at end of file diff --git a/src/main/java/http/keycloak/userstorage/FreshlyCreatedUsers.java b/src/main/java/http/keycloak/userstorage/FreshlyCreatedUsers.java new file mode 100644 index 0000000..62f0065 --- /dev/null +++ b/src/main/java/http/keycloak/userstorage/FreshlyCreatedUsers.java @@ -0,0 +1,79 @@ +package http.keycloak.userstorage; + + +import org.keycloak.models.KeycloakSession; + +import java.util.Optional; + +/** + * All new users who are still not persisted to http storage shall remain in current KeycloakSession. This way, + * if infinispan calls getUserById for the user that hasn't been persisted yet, this class will return the user as + * it was already persisted. + */ +public class FreshlyCreatedUsers { + + private final KeycloakSession session; + + public FreshlyCreatedUsers(KeycloakSession session) { + this.session = session; + } + + private static boolean isNotBlank(String str) { + return str != null && !str.trim().isEmpty(); + } + + private static String usernameKey(String username) { + return "username:" + username; + } + + private static String emailKey(String email) { + return "email:" + email; + } + + private static String idKey(String id) { + return "id:" + id; + } + + public Optional getFreshlyCreatedUserByUsername(String username) { + return Optional.ofNullable(session.getAttribute(usernameKey(username), HTTPUserModelDelegate.class)); + } + + public Optional getFreshlyCreatedUserById(String id) { + return Optional.ofNullable(session.getAttribute(idKey(id), HTTPUserModelDelegate.class)); + } + + public Optional getFreshlyCreatedUserByEmail(String email) { + return Optional.ofNullable(session.getAttribute(emailKey(email), HTTPUserModelDelegate.class)); + } + + public void saveInSession(HTTPUserModelDelegate userModel) { + String username = userModel.getUsername(); + if (isNotBlank(username)) { + session.setAttribute(usernameKey(username), userModel); + } + String email = userModel.getEmail(); + if (isNotBlank(email)) { + session.setAttribute(emailKey(email), userModel); + } + String id = userModel.getId(); + if (isNotBlank(id)) { + session.setAttribute(idKey(id), userModel); + } + } + + public void removeFromSession(HTTPUserModelDelegate userModel) { + String username = userModel.getUsername(); + if (isNotBlank(username)) { + session.removeAttribute(usernameKey(username)); + } + String email = userModel.getEmail(); + if (isNotBlank(email)) { + session.removeAttribute(emailKey(email)); + } + String id = userModel.getId(); + if (isNotBlank(id)) { + session.removeAttribute(idKey(id)); + } + } + +} \ No newline at end of file diff --git a/src/main/java/http/keycloak/userstorage/HTTPConfig.java b/src/main/java/http/keycloak/userstorage/HTTPConfig.java new file mode 100644 index 0000000..6eb3c2a --- /dev/null +++ b/src/main/java/http/keycloak/userstorage/HTTPConfig.java @@ -0,0 +1,62 @@ +package http.keycloak.userstorage; + +import org.keycloak.common.util.MultivaluedHashMap; + +/** + * A model for all MACHWeb specific configurations + */ +public class HTTPConfig { + private final MultivaluedHashMap config; + + public HTTPConfig(MultivaluedHashMap config) { + this.config = config; + } + + public String getUrl() { + return config.getFirst(HTTPConstants.CONFIG_URL); + } + + public String getUsername() { + return config.getFirst(HTTPConstants.CONFIG_USERNAME); + } + + public String getPassword() { + return config.getFirst(HTTPConstants.CONFIG_PASSWORD); + } + + public boolean isPagination() { + // for later - can be configurable + return false; + } + + public int getBatchSizeForSync() { + // for later - can be configurable, if isPagination is true + return 100; + } + + @Override + public boolean equals(Object obj) { + if (obj == this) + return true; + if (!(obj instanceof HTTPConfig)) + return false; + + HTTPConfig that = (HTTPConfig) obj; + + if (!config.equals(that.config)) + return false; + return true; + } + + @Override + public int hashCode() { + return config.hashCode() * 13; + } + + @Override + public String toString() { + MultivaluedHashMap copy = new MultivaluedHashMap(config); + copy.remove(HTTPConstants.CONFIG_PASSWORD); + return new StringBuilder(copy.toString()).toString(); + } +} diff --git a/src/main/java/http/keycloak/userstorage/HTTPConnector.java b/src/main/java/http/keycloak/userstorage/HTTPConnector.java new file mode 100644 index 0000000..6e26608 --- /dev/null +++ b/src/main/java/http/keycloak/userstorage/HTTPConnector.java @@ -0,0 +1,343 @@ +package http.keycloak.userstorage; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import javax.ws.rs.client.Entity; +import javax.ws.rs.client.WebTarget; +import javax.ws.rs.core.GenericType; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.MediaType; +import javax.ws.rs.core.Response; +import org.jboss.logging.Logger; +import org.jboss.resteasy.client.jaxrs.ResteasyClientBuilder; +import org.jboss.resteasy.plugins.providers.jackson.ResteasyJackson2Provider; +import org.keycloak.util.BasicAuthHelper; + +/** + * Connector that sends http requests to externalized user management service + * + * The Connector wants the following URLs for the backend + * + * - GET /user - returns a list of users (offset, limit, search and group) + * - GET /user/{username} - returns a user with the given username + * - GET /user/mail/{mail} - returns a user with the given mail address + * - POST /user/validate/{username} - with password as body returns 200 OK, if password is valid + * + * All writing or deleting operations are yet not supported. + */ +public class HTTPConnector { + + private static final Logger logger = Logger.getLogger(HTTPConnector.class); + + private static final ObjectMapper OBJECT_MAPPER; + private static final ResteasyJackson2Provider JACKSON_PROVIDER; + + static { + OBJECT_MAPPER = new ObjectMapper(); + OBJECT_MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + JACKSON_PROVIDER = new ResteasyJackson2Provider() {}; + JACKSON_PROVIDER.setMapper(OBJECT_MAPPER); + } + + private final String auth; + + private final WebTarget usersTarget; + private final WebTarget userByNameTarget; + private final WebTarget userByMailTarget; + private final WebTarget userValidateTarget; + + public HTTPConnector(HTTPConfig cfg) { + auth = BasicAuthHelper.createHeader(cfg.getUsername(), cfg.getPassword()); + + usersTarget = + ResteasyClientBuilder.newBuilder() + .register(JACKSON_PROVIDER, 100) + .build() + .target(cfg.getUrl()) + .path("/user"); + userByNameTarget = usersTarget.path("{username}"); + userByMailTarget = usersTarget.path("mail/{mail}"); + userValidateTarget = usersTarget.path("validate/{username}"); + } + + /** + * Helper method to build endpoint url for users resource + * + * @param realmId realm in which users are stored + * @return request builder + */ + private WebTarget usersEndpoint(Optional offset, Optional limit) { + if (offset.isPresent() && limit.isPresent()) + return usersTarget.queryParam("offset", offset.get()).queryParam("limit", limit.get()); + return usersTarget; + } + + /** + * Helper method to build endpoint url for user resource + * + * @param userId userId to search for + * @return request builder + */ + private WebTarget userByIdEndpoint(String userId) { + return userByNameTarget.resolveTemplate("username", userId); + } + + /** + * Helper method to build endpoint url for user resource + * + * @param username username of user to search + * @return request builder + */ + private WebTarget userByNameEndpoint(String username) { + return userByNameTarget.resolveTemplate("username", username); + } + + /** + * Helper method to build endpoint url for user resource + * + * @param mail Mail address of user + * @return request builder + */ + private WebTarget userByMailEndpoint(String mail) { + return userByMailTarget.resolveTemplate("mail", mail); + } + + /** + * Helper method to build endpoint url for user resource + * + * @param username username of user + * @return request builder + */ + private WebTarget validateUserPassword(String username) { + return userValidateTarget.resolveTemplate("username", username); + } + + public Optional getUserByExternalId(String realmId, String externalId) { + logger.infof("getUserByExternalId(s:%s, s:%s)", realmId, externalId); + Response resolvedUser = + userByIdEndpoint(externalId) + .request(MediaType.APPLICATION_JSON_TYPE) + .header(HttpHeaders.AUTHORIZATION, auth) + .get(); + if (isSuccessful(resolvedUser)) { + final Optional result = Optional.of(resolvedUser.readEntity(HTTPUserModel.class)); + logger.infof("getUserByExternalId(%s, %s) = %s", realmId, externalId, result); + return result; + } + logger.infof("getUserByExternalId(%s, %s) = empty", realmId, externalId); + return Optional.empty(); + } + + public Optional getUserByUsername(String realmId, String username) { + logger.infof("getUserByUsername(s:%s, s:%s)", realmId, username); + + Response resolvedUser = + userByNameEndpoint(username) + .request(MediaType.APPLICATION_JSON_TYPE) + .header(HttpHeaders.AUTHORIZATION, auth) + .get(); + + logger.infof("uri: %s", userByNameEndpoint(username).getUri()); + + if (isSuccessful(resolvedUser)) { + final Optional result = + Optional.ofNullable(resolvedUser.readEntity(HTTPUserModel.class)); + logger.infof("getUserByUsername(%s, %s) = %s", realmId, username, result); + return result; + } + logger.infof("getUserByUsername(%s, %s) = empty", realmId, username); + return Optional.empty(); + } + + private boolean isSuccessful(Response resolvedUser) { + return resolvedUser.getStatusInfo().toEnum() == Response.Status.OK && resolvedUser.hasEntity(); + } + + public Optional getUserByEmail(String realmId, String email) { + logger.infof("getUserByEmail(%s, %s)", realmId, email); + Response resolvedUser = + userByMailEndpoint(email) + .request(MediaType.APPLICATION_JSON_TYPE) + .header(HttpHeaders.AUTHORIZATION, auth) + .get(); + + logger.infof("uri: %s", userByMailEndpoint(email).getUri()); + + if (isSuccessful(resolvedUser)) { + logger.info("success"); + return Optional.ofNullable(resolvedUser.readEntity(HTTPUserModel.class)); + } + return Optional.empty(); + } + + public Optional getUsersCount(String realmId) { + // dummy + if (realmId != null) return Optional.of(getUsers(realmId, 0, 1).size()); + + logger.infof("getUsersCount(%s)", realmId); + final Response response = + usersEndpoint(Optional.empty(), Optional.empty()) + .request(MediaType.APPLICATION_JSON_TYPE) + .header(HttpHeaders.AUTHORIZATION, auth) + .get(); + if (isSuccessful(response)) { + List users = response.readEntity(new GenericType>() {}); + return Optional.of(users.isEmpty() ? 0 : users.size()); + } + return Optional.empty(); + } + + /** + * Helper method to build search methods on users resource + * + * @param realmId realm within which users exist + * @param offset common parameter for each search method for offset-based pagination + * @param limit common parameter for each search method for offset-based pagination + * @param appendQueryParameters function that adds additional queryParameters, used by search + * methods + * @return list of {@linkplain HTTPUserModel} that satisfy criteria + */ + private List getUsersTemplate( + String realmId, int offset, int limit, Function appendQueryParameters) { + final WebTarget usersEndpointWithAdditionalQueryParameters = + appendQueryParameters.apply(usersEndpoint(Optional.of(offset), Optional.of(limit))); + + logger.infof("uri: %s", usersEndpointWithAdditionalQueryParameters.getUri()); + final Response response = + usersEndpointWithAdditionalQueryParameters + .request(MediaType.APPLICATION_JSON_TYPE) + .header(HttpHeaders.AUTHORIZATION, auth) + .get(); + if (isSuccessful(response)) { + return response.readEntity(new GenericType>() {}); + } else if (response.getStatusInfo().toEnum() == Response.Status.BAD_REQUEST) { + throw new RuntimeException(response.readEntity(String.class)); + } + logger.errorf( + "getUsersTemplate(%s, %s, %s, %s}) = %s", + realmId, offset, limit, "appendQueryParameters", response); + return Collections.emptyList(); + } + + public List getUsers(String realmId, int offset, int limit) { + logger.infof("getUsers(%s, %s, %s)", realmId, offset, limit); + final List result = getUsersTemplate(realmId, offset, limit, Function.identity()); + logListOfUserModel(result); + return result; + } + + private void logListOfUserModel(List result) { + logger.infof("list of user models: %s", result); + } + + public List searchForUser(String realmId, String search, int offset, int limit) { + logger.infof("searchForUser(%s, %s, %d, %d)", realmId, search, offset, limit); + final List result = + getUsersTemplate(realmId, offset, limit, target -> target.queryParam("search", search)); + logListOfUserModel(result); + return result; + } + + /** + * @param realmId realm within which user exists + * @param params the filter to search for + * @param offset + * @param limit + * @return + */ + public List searchForUserByParams( + String realmId, Map params, int offset, int limit) { + logger.infof("searchForUserByParams(p'%s', %d, %d)", params, offset, limit); + final Function appendQueryParametersToTarget = + target -> { + for (Map.Entry entry : params.entrySet()) { + target = target.queryParam(entry.getKey(), entry.getValue()); + } + return target; + }; + final List result = + getUsersTemplate(realmId, offset, limit, appendQueryParametersToTarget); + logListOfUserModel(result); + return result; + } + + /** + * @param realmId realm within which user exists + * @param externalId + * @return + */ + public Optional isConfiguredPasswordForExternalId(String realmId, String externalId) { + logger.infof("isConfiguredPasswordForExternalId(%s, %s)", new Object[] {realmId, externalId}); + return Optional.of(true); + } + + /** + * Creates a User in the backend + * + * @param realmId realm within which user exists + * @param user the new user + * @param isManualSetUp + * @return + */ + public HTTPUserModel createUser(String realmId, HTTPUserModel user, boolean isManualSetUp) { + logger.infof("createUser(%s, %s)", realmId, user); + throw new RuntimeException("Creating user in http storage has failed"); + } + + /** + * Verify non-null password for a given user + * + * @param realmId realm within which user exists + * @param userId user service id + * @param password password from UI that needs to be verified. If it is null then this method + * returns false + * @return true - if is valid, false otherwise + */ + public boolean verifyPassword(String realmId, String userId, String password) { + if (password == null) { + logger.infof("verifyPassword(%s, %s, null) = false", realmId, userId); + return false; + } + + try { + logger.infof("uri: %s", validateUserPassword(userId).getUri()); + final Response response = + validateUserPassword(userId) + .request(MediaType.APPLICATION_JSON_TYPE) + .header(HttpHeaders.AUTHORIZATION, auth) + .post(Entity.entity(password, MediaType.APPLICATION_JSON)); + logger.infof("response: %d", response.getStatus()); + return response.getStatusInfo().toEnum() == Response.Status.OK; + } catch (Exception e) { + logger.error("could not validate password", e); + } + return false; + } + + /** + * Removes a user in the backend. + * + * @param realmId realm within which user exists + * @param externalId UserId to remove + * @return true, if user was removed + */ + public boolean removeUserByExternalId(String realmId, String externalId) { + return false; + } + + /** + * Updates a usermodel in the backend. + * + * @param realmId realm within which user exists + * @param updatedUserModel updated user model + * @param isManualSetUp + */ + public void updateUser(String realmId, HTTPUserModel updatedUserModel, boolean isManualSetUp) { + logger.infof("updateUser(%s, %s)", realmId, updatedUserModel); + } +} diff --git a/src/main/java/http/keycloak/userstorage/HTTPConstants.java b/src/main/java/http/keycloak/userstorage/HTTPConstants.java new file mode 100644 index 0000000..8801344 --- /dev/null +++ b/src/main/java/http/keycloak/userstorage/HTTPConstants.java @@ -0,0 +1,19 @@ +package http.keycloak.userstorage; + +/** + * A list of all HTTP specific constants + */ +public class HTTPConstants { + public static final String PROVIDER_NAME = "http"; + + public static final String CONFIG_URL = "url"; + public static final String CONFIG_URL_LABEL = "HTTP-URL"; + public static final String CONFIG_URL_HELP = "HTTP-URL-Help"; + public static final String CONFIG_USERNAME = "username"; + public static final String CONFIG_USERNAME_LABEL = "HTTP-Username"; + public static final String CONFIG_USERNAME_HELP = "HTTP-Username-Help"; + public static final String CONFIG_PASSWORD = "password"; + public static final String CONFIG_PASSWORD_LABEL = "HTTP-Password"; + public static final String CONFIG_PASSWORD_HELP = "HTTP-Password-Help"; + +} diff --git a/src/main/java/http/keycloak/userstorage/HTTPGroupModel.java b/src/main/java/http/keycloak/userstorage/HTTPGroupModel.java new file mode 100644 index 0000000..0d9834c --- /dev/null +++ b/src/main/java/http/keycloak/userstorage/HTTPGroupModel.java @@ -0,0 +1,146 @@ +package http.keycloak.userstorage; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; +import org.keycloak.models.ClientModel; +import org.keycloak.models.GroupModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; + +/** + * A specific HTTP Group model based on a JSON. Groups and roles are only named (no identifier, because name is unique) + */ +public class HTTPGroupModel implements GroupModel { + private final String name; + private final RealmModel realm; + private final List roles; + private GroupModel parent; + private Set childs = new LinkedHashSet<>(); + private HashMap> attributes = new HashMap<>(); + + public HTTPGroupModel(String name, List roles, RealmModel realm) { + this.name = name; + this.roles = roles; + this.realm = realm; + } + + @Override + public Set getRealmRoleMappings() { + return getRoleMappings(); + } + + @Override + public Set getClientRoleMappings(ClientModel app) { + return getRoleMappings(); + } + + @Override + public boolean hasRole(RoleModel role) { + return getRoleMappings().contains(role); + } + + @Override + public void grantRole(RoleModel role) { + roles.add(role.getName()); + } + + @Override + public Set getRoleMappings() { + return roles.stream().map(role -> new HTTPRoleModel(role, realm)).collect(Collectors.toSet()); + } + + @Override + public void deleteRoleMapping(RoleModel role) { + roles.remove(role.getName()); + } + + @Override + public String getId() { + return name; + } + + @Override + public String getName() { + return name; + } + + @Override + public void setName(String name) { + // no op + } + + @Override + public void setSingleAttribute(String name, String value) { + attributes.put(name, Arrays.asList(value)); + } + + @Override + public void setAttribute(String name, List values) { + attributes.put(name, values); + } + + @Override + public void removeAttribute(String name) { + attributes.remove(name); + } + + @Override + public String getFirstAttribute(String name) { + if (attributes.containsKey(name)) return attributes.get(name).get(0); + return null; + } + + @Override + public List getAttribute(String name) { + return attributes.get(name); + } + + @Override + public Map> getAttributes() { + return attributes; + } + + @Override + public GroupModel getParent() { + return parent; + } + + @Override + public String getParentId() { + if (parent != null) return parent.getId(); + return null; + } + + @Override + public Set getSubGroups() { + return childs; + } + + @Override + public void setParent(GroupModel group) { + this.parent = group; + } + + @Override + public void addChild(GroupModel subGroup) { + childs.add(subGroup); + } + + @Override + public void removeChild(GroupModel subGroup) { + childs.remove(subGroup); + } + + public RealmModel setRealm() { + return realm; + } + + public String toString() { + return String.format("HTTPGroupModel(name=%s, roles=%s, childs=%s, attributes=%s)", name, roles, childs, attributes); + } +} diff --git a/src/main/java/http/keycloak/userstorage/HTTPRoleModel.java b/src/main/java/http/keycloak/userstorage/HTTPRoleModel.java new file mode 100644 index 0000000..8cecd6f --- /dev/null +++ b/src/main/java/http/keycloak/userstorage/HTTPRoleModel.java @@ -0,0 +1,136 @@ +package http.keycloak.userstorage; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collection; +import java.util.HashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleContainerModel; +import org.keycloak.models.RoleModel; + +public class HTTPRoleModel implements RoleModel { + private final String name; + private final RealmModel realm; + private RoleModel parent = null; + private Set childs = new LinkedHashSet<>(); + private HashMap> attributes = new HashMap<>(); + + public HTTPRoleModel(String name, RealmModel realm) { + this.name = name; + this.realm = realm; + } + + @Override + public String getName() { + return name; + } + + @Override + public String getDescription() { + return name; + } + + @Override + public void setDescription(String description) { + // no op + } + + @Override + public String getId() { + return name; + } + + @Override + public void setName(String name) { + // no op + } + + @Override + public boolean isComposite() { + return childs.size()>0; + } + + @Override + public void addCompositeRole(RoleModel role) { + childs.add(role); + } + + @Override + public void removeCompositeRole(RoleModel role) { + childs.remove(role); + } + + @Override + public Set getComposites() { + return childs; + } + + @Override + public boolean isClientRole() { + return parent != null; + } + + @Override + public String getContainerId() { + return realm.getId(); + } + + @Override + public RoleContainerModel getContainer() { + return realm; + } + + @Override + public boolean hasRole(RoleModel role) { + return childs.contains(role); + } + + @Override + public void setSingleAttribute(String name, String value) { + attributes.put(name, Arrays.asList(value)); + } + + @Override + public void setAttribute(String name, Collection values) { + attributes.put(name, new ArrayList<>(values)); + } + + @Override + public void removeAttribute(String name) { + attributes.remove(name); + } + + @Override + public String getFirstAttribute(String name) { + if (attributes.containsKey(name) && attributes.get(name).size() > 0) return attributes.get(name).get(0); + return null; + } + + @Override + public List getAttribute(String name) { + if (attributes.containsKey(name)) return attributes.get(name); + return null; + } + + @Override + public Map> getAttributes() { + return attributes; + } + + @Override + public boolean equals(Object obj) { + if (obj instanceof HTTPRoleModel) { + return getId().equals(((HTTPRoleModel) obj).getId()); + } + return false; + } + + public String toString() { + return String.format("HTTPRoleModel(name=%s)", name); + } +} diff --git a/src/main/java/http/keycloak/userstorage/HTTPTransaction.java b/src/main/java/http/keycloak/userstorage/HTTPTransaction.java new file mode 100644 index 0000000..be9e104 --- /dev/null +++ b/src/main/java/http/keycloak/userstorage/HTTPTransaction.java @@ -0,0 +1,41 @@ +package http.keycloak.userstorage; + + +import org.keycloak.models.AbstractKeycloakTransaction; + +public class HTTPTransaction extends AbstractKeycloakTransaction { + + private final HTTPConnector httpConnector; + + private final HTTPUserModelDelegate delegate; + + private boolean isEnlisted = false; + + public boolean isEnlisted() { + return isEnlisted; + } + + public void setEnlisted(boolean enlisted) { + isEnlisted = enlisted; + } + + public HTTPTransaction(HTTPConnector httpConnector, HTTPUserModelDelegate delegate) { + this.httpConnector = httpConnector; + this.delegate = delegate; + } + + @Override + protected void commitImpl() { + if (delegate.isNotPersistedInHttpStorage()) { + httpConnector.createUser(delegate.getRealmId(), delegate.getDelegatedUserModel(), delegate.isAdminTool()); + delegate.setPersistedInHttpStorage(true); + } else { + httpConnector.updateUser(delegate.getRealmId(), delegate.getDelegatedUserModel(), delegate.isAdminTool()); + } + } + + @Override + protected void rollbackImpl() { + } + +} \ No newline at end of file diff --git a/src/main/java/http/keycloak/userstorage/HTTPUserModel.java b/src/main/java/http/keycloak/userstorage/HTTPUserModel.java new file mode 100644 index 0000000..698bacb --- /dev/null +++ b/src/main/java/http/keycloak/userstorage/HTTPUserModel.java @@ -0,0 +1,343 @@ +package http.keycloak.userstorage; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import java.beans.ConstructorProperties; +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import org.jboss.logging.Logger; +import org.keycloak.models.ClientModel; +import org.keycloak.models.GroupModel; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; + +@JsonIgnoreProperties(value = {"groups", "realmRoleMappings", "roleMappings", "groupsCount"}) +public class HTTPUserModel implements UserModel { + private static final Logger logger = Logger.getLogger(HTTPUserModel.class); + + private String id; + + private String username; + + private String password; + + private Long createdTimestamp; + + private boolean enabled; + + private Map> attributes = new HashMap<>(); + + private Set requiredActions; + + private String email; + + private String firstName; + + private String lastName; + + private boolean emailVerified; + + private Map> groupsAndRoles = new HashMap<>(); + + private RealmModel realm = null; + + @ConstructorProperties("id") + public HTTPUserModel(String id) { + this.id = id; + } + + /** {@inheritDoc} */ + @Override + public String getId() { + return id; + } + + /** {@inheritDoc} */ + @Override + public String getUsername() { + return username; + } + + /** {@inheritDoc} */ + @Override + public void setUsername(String username) { + this.username = username; + } + + /** {@inheritDoc} */ + public void setPassword(String password) { + this.password = password; + } + + public String getPassword() { + return password; + } + + /** {@inheritDoc} */ + @Override + public Long getCreatedTimestamp() { + return createdTimestamp; + } + + /** {@inheritDoc} */ + @Override + public boolean isEnabled() { + return enabled; + } + + /** {@inheritDoc} */ + @Override + public void setEnabled(boolean enabled) { + this.enabled = enabled; + } + + /** {@inheritDoc} */ + @Override + public Map> getAttributes() { + return attributes; + } + + /** {@inheritDoc} */ + @Override + public Set getRequiredActions() { + return requiredActions; + } + + /** {@inheritDoc} */ + @Override + public String getEmail() { + return email; + } + + /** {@inheritDoc} */ + @Override + public void setEmail(String email) { + this.email = email; + } + + /** {@inheritDoc} */ + @Override + public String getFirstName() { + return firstName; + } + + /** {@inheritDoc} */ + @Override + public void setFirstName(String firstName) { + this.firstName = firstName; + } + + /** {@inheritDoc} */ + @Override + public String getLastName() { + return lastName; + } + + /** {@inheritDoc} */ + @Override + public void setLastName(String lastName) { + this.lastName = lastName; + } + + /** {@inheritDoc} */ + @Override + public void setCreatedTimestamp(Long timestamp) { + this.createdTimestamp = timestamp; + } + + /** {@inheritDoc} */ + @Override + public void setSingleAttribute(String name, String value) { + attributes.put(name, Collections.singletonList(value)); + } + + /** {@inheritDoc} */ + @Override + public void setAttribute(String name, List values) { + attributes.put(name, values); + } + + /** {@inheritDoc} */ + @Override + public void removeAttribute(String name) { + attributes.remove(name); + } + + /** {@inheritDoc} */ + @Override + public String getFirstAttribute(String name) { + return attributes.containsKey(name) ? attributes.get(name).get(0) : null; + } + + /** {@inheritDoc} */ + @Override + public List getAttribute(String name) { + return attributes.getOrDefault(name, Collections.emptyList()); + } + + /** {@inheritDoc} */ + @Override + public void addRequiredAction(String action) { + requiredActions.add(action); + } + + /** {@inheritDoc} */ + @Override + public void removeRequiredAction(String action) { + requiredActions.remove(action); + } + + /** {@inheritDoc} */ + @Override + public void addRequiredAction(RequiredAction action) { + requiredActions.add(action.toString()); + } + + /** {@inheritDoc} */ + @Override + public void removeRequiredAction(RequiredAction action) { + requiredActions.remove(action.toString()); + } + + /** {@inheritDoc} */ + @Override + public boolean isEmailVerified() { + return emailVerified; + } + + /** {@inheritDoc} */ + @Override + public void setEmailVerified(boolean verified) { + emailVerified = verified; + } + + /** {@inheritDoc} */ + @Override + public Set getGroups() { + logger.infof("getGroups() with realm %s",realm); + return groupsAndRoles.keySet().stream() + .map(group -> new HTTPGroupModel(group, groupsAndRoles.get(group), realm)) + .collect(Collectors.toSet()); + } + + /** {@inheritDoc} */ + @Override + public void joinGroup(GroupModel group) { + // no op + } + + /** {@inheritDoc} */ + @Override + public void leaveGroup(GroupModel group) { + // no op + } + + /** {@inheritDoc} */ + @Override + public boolean isMemberOf(GroupModel group) { + return getGroups().contains(group); + } + + /** {@inheritDoc} */ + @Override + public String getFederationLink() { + // Not implemented + return null; + } + + /** {@inheritDoc} */ + @Override + public void setFederationLink(String link) { + // Not implemented + } + + /** {@inheritDoc} */ + @Override + public String getServiceAccountClientLink() { + // Not implemented + return null; + } + + /** {@inheritDoc} */ + @Override + public void setServiceAccountClientLink(String clientInternalId) { + // Not implemented + } + + /** {@inheritDoc} */ + @Override + public Set getRealmRoleMappings() { + return getRoleMappings(); + } + + /** {@inheritDoc} */ + @Override + public Set getClientRoleMappings(ClientModel app) { + return getRoleMappings(); + } + + /** {@inheritDoc} */ + @Override + public boolean hasRole(RoleModel role) { + return getRoleMappings().contains(role); + } + + /** {@inheritDoc} */ + @Override + public void grantRole(RoleModel role) { + // no op + } + + /** {@inheritDoc} */ + @Override + public Set getRoleMappings() { + logger.infof("getRoleMappings() with realm %s",realm); + return groupsAndRoles.values().stream() + .flatMap(List::stream) + .map(role -> new HTTPRoleModel(role, realm)) + .distinct() + .collect(Collectors.toSet()); + } + + /** {@inheritDoc} */ + @Override + public void deleteRoleMapping(RoleModel role) { + // no op + } + + public void setRealm(RealmModel realm) { + this.realm = realm; + } + + public Map> getGroupsAndRoles() { + return groupsAndRoles; + } + + public void setGroupsAndRoles(Map> groupsAndRoles) { + this.groupsAndRoles = groupsAndRoles; + } + + @Override + public String toString() { + return String.format( + "HTTPUserModel(id=%s, username=%s, firstName=%s, lastName=%s, email=%s, emailVerified=%b, createdTimestamp=%d, enabled=%b, groupsAndRoles=%s)", + id, + username, + firstName, + lastName, + email, + emailVerified, + createdTimestamp, + enabled, + groupsAndRoles); + } + + public void setRequiredActions(Set requiredActions) { + this.requiredActions = requiredActions; + } +} diff --git a/src/main/java/http/keycloak/userstorage/HTTPUserModelDelegate.java b/src/main/java/http/keycloak/userstorage/HTTPUserModelDelegate.java new file mode 100644 index 0000000..3961ebe --- /dev/null +++ b/src/main/java/http/keycloak/userstorage/HTTPUserModelDelegate.java @@ -0,0 +1,271 @@ +package http.keycloak.userstorage; + +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.stream.Collectors; +import org.jboss.logging.Logger; +import org.keycloak.component.ComponentModel; +import org.keycloak.models.AbstractKeycloakTransaction.TransactionState; +import org.keycloak.models.GroupModel; +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; + +/** + * Delegation pattern. Used to delegate managing roles and groups to Keycloak federated storage and + * entity data such as username, email, etc. to http storage + */ +public class HTTPUserModelDelegate extends AbstractUserAdapterFederatedStorage { + + private static final Logger logger = Logger.getLogger(HTTPUserModelDelegate.class); + + /** Is this delegate represents persisted entity in http storage? */ + private boolean isPersistedInHttpStorage; + + /** User model that keeps the data */ + private final HTTPUserModel httpUserModel; + + /** Http transaction that updates http storage at one moment */ + private final HTTPTransaction httpTransaction; + + public static HTTPUserModelDelegate createForExistingUser( + KeycloakSession session, + RealmModel realm, + ComponentModel storageProviderModel, + HTTPUserModel userModel, + HTTPConnector httpConnector) { + HTTPUserModelDelegate delegate = + new HTTPUserModelDelegate( + session, realm, storageProviderModel, userModel, httpConnector); + delegate.isPersistedInHttpStorage = true; + return delegate; + } + + /** + * @param session + * @param realm + * @param storageProviderModel + * @param httpUserModel + * @param httpConnector + */ + private HTTPUserModelDelegate( + KeycloakSession session, + RealmModel realm, + ComponentModel storageProviderModel, + HTTPUserModel httpUserModel, + HTTPConnector httpConnector) { + super(session, realm, storageProviderModel); + this.httpUserModel = httpUserModel; + httpUserModel.setRealm(realm); + httpTransaction = new HTTPTransaction(httpConnector, this); + } + + public void ensureTransactionEnlisted() { + if (TransactionState.NOT_STARTED.equals(httpTransaction.getState()) + && !httpTransaction.isEnlisted()) { + session.getTransactionManager().enlistAfterCompletion(httpTransaction); + httpTransaction.setEnlisted(true); + } + } + + public boolean isAdminTool() { + return session.getContext().getUri().getDelegate().getPath().startsWith("/admin/realms/"); + } + + public boolean isNotPersistedInHttpStorage() { + return !isPersistedInHttpStorage; + } + + public void setPersistedInHttpStorage(boolean persistedInHttpStorage) { + isPersistedInHttpStorage = persistedInHttpStorage; + } + + public HTTPUserModel getDelegatedUserModel() { + return httpUserModel; + } + + public String getRealmId() { + return realm.getId(); + } + + @Override + public String getUsername() { + return httpUserModel.getUsername(); + } + + @Override + public void setUsername(String username) { + logger.infof("setUsername(%s)", username); + if (Objects.equals(httpUserModel.getUsername(), username)) { + return; + } + httpUserModel.setUsername(username); + ensureTransactionEnlisted(); + } + + @Override + public String getEmail() { + return httpUserModel.getEmail(); + } + + @Override + public void setEmail(String email) { + logger.infof("setEmail(%s)", email); + if (Objects.equals(httpUserModel.getEmail(), email)) { + return; + } + httpUserModel.setEmail(email); + ensureTransactionEnlisted(); + } + + @Override + public String getFirstName() { + return httpUserModel.getFirstName(); + } + + @Override + public void setFirstName(String firstName) { + logger.infof("setFirstName(%s)", firstName); + if (Objects.equals(httpUserModel.getFirstName(), firstName)) { + return; + } + httpUserModel.setFirstName(firstName); + ensureTransactionEnlisted(); + } + + @Override + public String getLastName() { + return httpUserModel.getLastName(); + } + + @Override + public void setLastName(String lastName) { + logger.infof("setLastName(%s)", lastName); + if (Objects.equals(httpUserModel.getLastName(), lastName)) { + return; + } + httpUserModel.setLastName(lastName); + ensureTransactionEnlisted(); + } + + @Override + public boolean isEmailVerified() { + return httpUserModel.isEmailVerified(); + } + + @Override + public void setEmailVerified(boolean verified) { + logger.infof("setEmailVerified(%s)", verified); + if (httpUserModel.isEmailVerified() == verified) { + return; + } + httpUserModel.setEmailVerified(verified); + ensureTransactionEnlisted(); + } + + @Override + public boolean isEnabled() { + return httpUserModel.isEnabled(); + } + + @Override + public void setEnabled(boolean enabled) { + logger.infof("setEnabled(%s)", enabled); + if (httpUserModel.isEnabled() == enabled) { + return; + } + httpUserModel.setEnabled(enabled); + ensureTransactionEnlisted(); + } + + @Override + public Long getCreatedTimestamp() { + return httpUserModel.getCreatedTimestamp(); + } + + @Override + public void setCreatedTimestamp(Long timestamp) { + logger.infof("setCreatedTimestamp(%s)", timestamp); + if (Objects.equals(httpUserModel.getCreatedTimestamp(), timestamp)) { + return; + } + httpUserModel.setCreatedTimestamp(timestamp); + ensureTransactionEnlisted(); + } + + @Override + public void setSingleAttribute(String name, String value) { + logger.infof("setSingleAttribute(%s, %s)", name, value); + if (Objects.equals(httpUserModel.getFirstAttribute(name), value)) { + return; + } + httpUserModel.setSingleAttribute(name, value); + ensureTransactionEnlisted(); + } + + @Override + public void removeAttribute(String name) { + logger.infof("removeAttribute(%s)", name); + httpUserModel.removeAttribute(name); + ensureTransactionEnlisted(); + } + + @Override + public void setAttribute(String name, List values) { + logger.infof("setAttribute(%s, %s)", name, values); + if (httpUserModel.getAttribute(name).equals(values)) { + return; + } + httpUserModel.setAttribute(name, values); + ensureTransactionEnlisted(); + } + + @Override + public String getFirstAttribute(String name) { + logger.infof("getFirstAttribute(%s)", name); + return httpUserModel.getFirstAttribute(name); + } + + @Override + public Map> getAttributes() { + logger.infof("getAttributes()"); + return httpUserModel.getAttributes(); + } + + @Override + public List getAttribute(String name) { + logger.infof("getAttribute(%s)", name); + return httpUserModel.getAttribute(name); + } + + @Override + public String getId() { + if (storageId == null) { + storageId = new StorageId(storageProviderModel.getId(), httpUserModel.getId()); + } + return storageId.getId(); + } + + @Override + public Set getGroupsInternal() { + logger.info("getGroupsInternal()"); + + return httpUserModel.getGroupsAndRoles().keySet().stream() + .map(group -> new HTTPGroupModel(group, httpUserModel.getGroupsAndRoles().get(group), realm)) + .collect(Collectors.toSet()); + } + + @Override + protected Set getRoleMappingsInternal() { + logger.info("getRoleMappingsInternal()"); + return httpUserModel.getGroupsAndRoles().values().stream() + .flatMap(List::stream) + .map(role -> new HTTPRoleModel(role, realm)) + .distinct() + .collect(Collectors.toSet()); + } +} diff --git a/src/main/java/http/keycloak/userstorage/HTTPUserStorageProvider.java b/src/main/java/http/keycloak/userstorage/HTTPUserStorageProvider.java new file mode 100644 index 0000000..6eee975 --- /dev/null +++ b/src/main/java/http/keycloak/userstorage/HTTPUserStorageProvider.java @@ -0,0 +1,316 @@ +package http.keycloak.userstorage; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.function.Supplier; +import java.util.stream.Collectors; +import org.jboss.logging.Logger; +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.PasswordCredentialModel; +import org.keycloak.storage.StorageId; +import org.keycloak.storage.UserStorageProvider; +import org.keycloak.storage.user.UserLookupProvider; +import org.keycloak.storage.user.UserQueryProvider; + +/** + * Custom HttpUserStorageProvider. Makes possible to store users in externalized user management + * service. + */ +public class HTTPUserStorageProvider + implements UserStorageProvider, + UserLookupProvider, + CredentialInputValidator, + CredentialInputUpdater, + UserQueryProvider { + + private static final Logger logger = Logger.getLogger(HTTPUserStorageProvider.class); + + private final HTTPConnector httpConnector; + + private final KeycloakSession session; + + private final ComponentModel model; + + private final FreshlyCreatedUsers freshlyCreatedUsers; + + HTTPUserStorageProvider(HTTPConfig cfg, KeycloakSession session, ComponentModel model) { + this.session = session; + // for caching users + this.freshlyCreatedUsers = new FreshlyCreatedUsers(session); + this.model = model; + this.httpConnector = new HTTPConnector(cfg); + } + + // UserLookupProvider methods + + /** {@inheritDoc} */ + @Override + public HTTPUserModelDelegate getUserByUsername(String username, RealmModel realm) { + logger.infof("getUserByUsername(s:'%s')", username); + Supplier remoteCall = + () -> + httpConnector + .getUserByUsername(realm.getId(), username) + .map( + user -> + HTTPUserModelDelegate.createForExistingUser( + session, realm, model, user, httpConnector)) + .orElse(null); + return freshlyCreatedUsers.getFreshlyCreatedUserByUsername(username).orElseGet(remoteCall); + } + + /** {@inheritDoc} */ + @Override + public HTTPUserModelDelegate getUserById(String id, RealmModel realm) { + logger.infof("getUserById(s:'%s')", StorageId.externalId(id)); + Supplier remoteCall = + () -> + httpConnector + .getUserByExternalId(realm.getId(), StorageId.externalId(id)) + .map( + user -> + HTTPUserModelDelegate.createForExistingUser( + session, realm, model, user, httpConnector)) + .orElseThrow( + () -> + new RuntimeException( + "User is not found by external id = " + StorageId.externalId(id))); + + return freshlyCreatedUsers.getFreshlyCreatedUserById(id).orElseGet(remoteCall); + } + + /** {@inheritDoc} */ + @Override + public HTTPUserModelDelegate getUserByEmail(String email, RealmModel realm) { + logger.infof("getUserByEmail(s:'%s')", email); + Supplier remoteCall = + () -> + httpConnector + .getUserByEmail(realm.getId(), email) + .map( + user -> + HTTPUserModelDelegate.createForExistingUser( + session, realm, model, user, httpConnector)) + .orElse(null); + return freshlyCreatedUsers.getFreshlyCreatedUserByEmail(email).orElseGet(remoteCall); + } + + // UserQueryProvider methods + + /** {@inheritDoc} */ + @Override + public int getUsersCount(RealmModel realm) { + logger.info("getUsersCount()"); + return httpConnector + .getUsersCount(realm.getId()) + .orElseThrow(() -> new RuntimeException("No users count could be retrieved")); + } + + /** {@inheritDoc} */ + @Override + public List getUsers(RealmModel realm) { + logger.info("getUsers()"); + return httpConnector.getUsers(realm.getId(), 0, Integer.MAX_VALUE).stream() + .map( + user -> + HTTPUserModelDelegate.createForExistingUser( + session, realm, model, user, httpConnector)) + .collect(Collectors.toList()); + } + + /** {@inheritDoc} */ + @Override + public List getUsers(RealmModel realm, int offset, int limit) { + logger.infof("getUsers(%d,%d)", offset, limit); + return httpConnector.getUsers(realm.getId(), offset, limit).stream() + .map( + user -> + HTTPUserModelDelegate.createForExistingUser( + session, realm, model, user, httpConnector)) + .collect(Collectors.toList()); + } + + // UserQueryProvider method implementations + + /** {@inheritDoc} */ + @Override + public List searchForUser(String search, RealmModel realm) { + logger.infof("searchForUser(s:'%s')", search); + return searchForUser(search, realm, 0, Integer.MAX_VALUE); + } + + /** {@inheritDoc} */ + @Override + public List searchForUser(String search, RealmModel realm, int offset, int limit) { + logger.infof("searchForUser(s:'%s',%d,%d)", search, offset, limit); + return httpConnector.searchForUser(realm.getId(), search, offset, limit).stream() + .map( + user -> + HTTPUserModelDelegate.createForExistingUser( + session, realm, model, user, httpConnector)) + .collect(Collectors.toList()); + } + + /** {@inheritDoc} */ + @Override + public List searchForUser(Map params, RealmModel realm) { + logger.infof("searchForUser(p:'%s')", params); + return searchForUser(params, realm, 0, Integer.MAX_VALUE); + } + + /** {@inheritDoc} */ + @Override + public List searchForUser( + Map params, RealmModel realm, int offset, int limit) { + logger.infof("searchForUser(p:'%s',%d,%d)", params, offset, +limit); + return httpConnector.searchForUserByParams(realm.getId(), params, offset, limit).stream() + .map( + user -> + HTTPUserModelDelegate.createForExistingUser( + session, realm, model, user, httpConnector)) + .collect(Collectors.toList()); + } + + /** {@inheritDoc} */ + @Override + public List getGroupMembers( + RealmModel realm, GroupModel group, int offset, int limit) { + logger.infof("getGroupMembers(g:'%s',%d,%d)", group, offset, limit); + final Map singleParam = Collections.singletonMap("group", group.getName()); + return httpConnector.searchForUserByParams(realm.getId(), singleParam, offset, limit).stream() + .map( + user -> + HTTPUserModelDelegate.createForExistingUser( + session, realm, model, user, httpConnector)) + .collect(Collectors.toList()); + } + + /** {@inheritDoc} */ + @Override + public List getGroupMembers(RealmModel realm, GroupModel group) { + logger.info("getGroupMembers()"); + final Map singleParam = Collections.singletonMap("group", group.getName()); + return httpConnector.searchForUserByParams(realm.getId(), singleParam, 0, Integer.MAX_VALUE) + .stream() + .map( + user -> + HTTPUserModelDelegate.createForExistingUser( + session, realm, model, user, httpConnector)) + .collect(Collectors.toList()); + } + + /** {@inheritDoc} */ + @Override + public List searchForUserByUserAttribute( + String attrName, String attrValue, RealmModel realm) { + logger.infof("searchForUserByUserAttribute(%s,%s)", attrName, attrValue); + final Map singleParam = Collections.singletonMap(attrName, attrValue); + return httpConnector.searchForUserByParams(realm.getId(), singleParam, 0, Integer.MAX_VALUE) + .stream() + .map( + user -> + HTTPUserModelDelegate.createForExistingUser( + session, realm, model, user, httpConnector)) + .collect(Collectors.toList()); + } + + // CredentialInputValidator methods + + /** {@inheritDoc} */ + @Override + public boolean isConfiguredFor(RealmModel realm, UserModel user, String credentialType) { + if (!supportsCredentialType(credentialType)) { + return false; + } + return httpConnector + .isConfiguredPasswordForExternalId(realm.getId(), StorageId.externalId(user.getId())) + .orElseThrow( + () -> + new RuntimeException( + "Couldn't check is password set for a user with id = " + user.getId())); + } + + /** {@inheritDoc} */ + @Override + public boolean supportsCredentialType(String credentialType) { + return PasswordCredentialModel.TYPE.equals(credentialType); + } + + /** {@inheritDoc} */ + @Override + public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) { + logger.infof("isValid(username=%s)", user.getUsername()); + + if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel)) { + logger.info("credentialtype unknown or not correct model"); + return false; + } + UserCredentialModel cred = (UserCredentialModel) input; + String rawPassword = cred.getChallengeResponse(); + Optional freshlyCreatedUserById = + freshlyCreatedUsers.getFreshlyCreatedUserById(user.getId()); + if (freshlyCreatedUserById.isPresent()) { + logger.info("user was freshly installed"); + throw new RuntimeException(); + } + boolean result = + httpConnector.verifyPassword( + realm.getId(), StorageId.externalId(user.getId()), rawPassword); + logger.infof("password valid: %b", result); + + return result; + } + + // CredentialInputUpdater methods + + /** {@inheritDoc} */ + @Override + public boolean updateCredential(RealmModel realm, UserModel user, CredentialInput input) { + logger.infof("updateCredential(%s,%s)", user, input); + if (!(input instanceof UserCredentialModel)) { + return false; + } + if (!PasswordCredentialModel.TYPE.equals(input.getType())) { + return false; + } + if (!(user instanceof HTTPUserModelDelegate)) { + throw new RuntimeException(); + } + UserCredentialModel cred = (UserCredentialModel) input; + HTTPUserModelDelegate delegate = (HTTPUserModelDelegate) user; + delegate.getDelegatedUserModel().setPassword(cred.getChallengeResponse()); + delegate.ensureTransactionEnlisted(); + return true; + } + + /** {@inheritDoc} */ + @Override + public void disableCredentialType(RealmModel realm, UserModel user, String credentialType) { + // is not supported + throw new RuntimeException(); + } + + /** {@inheritDoc} */ + @Override + public Set getDisableableCredentialTypes(RealmModel realm, UserModel user) { + // is not supported + return Collections.emptySet(); + } + + /** {@inheritDoc} */ + @Override + public void close() { + // noop + } +} diff --git a/src/main/java/http/keycloak/userstorage/HTTPUserStorageProviderFactory.java b/src/main/java/http/keycloak/userstorage/HTTPUserStorageProviderFactory.java new file mode 100644 index 0000000..22b5c93 --- /dev/null +++ b/src/main/java/http/keycloak/userstorage/HTTPUserStorageProviderFactory.java @@ -0,0 +1,72 @@ +package http.keycloak.userstorage; + +import java.net.URI; +import java.util.List; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.component.ComponentModel; +import org.keycloak.component.ComponentValidationException; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.keycloak.storage.UserStorageProviderFactory; + +public class HTTPUserStorageProviderFactory + implements UserStorageProviderFactory { + private static final Logger logger = Logger.getLogger(HTTPUserStorageProviderFactory.class); + + private List configProperties = null; + + @Override + public String getId() { + return HTTPConstants.PROVIDER_NAME; + } + + @Override + public void init(Config.Scope config) { + logger.info("Initializing HTTP UserStorage SPI"); + } + + @Override + public List getConfigProperties() { + if (configProperties == null) { + configProperties = ProviderConfigurationBuilder.create().property().name(HTTPConstants.CONFIG_URL) + .helpText(HTTPConstants.CONFIG_URL_HELP).label(HTTPConstants.CONFIG_URL_LABEL) + .type(ProviderConfigProperty.STRING_TYPE).add().property().name(HTTPConstants.CONFIG_USERNAME) + .helpText(HTTPConstants.CONFIG_USERNAME_HELP).label(HTTPConstants.CONFIG_USERNAME_LABEL) + .type(ProviderConfigProperty.STRING_TYPE).add().property().name(HTTPConstants.CONFIG_PASSWORD) + .helpText(HTTPConstants.CONFIG_PASSWORD_HELP).label(HTTPConstants.CONFIG_PASSWORD_LABEL) + .type(ProviderConfigProperty.PASSWORD).secret(true).add().build(); + } + return configProperties; + } + + @Override + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel model) + throws ComponentValidationException { + HTTPConfig cfg = new HTTPConfig(model.getConfig()); + + try { + URI.create(cfg.getUrl()); + } catch (NullPointerException npe) { + throw new ComponentValidationException("HTTPErrorURLNotSet"); + } catch (IllegalArgumentException iae) { + throw new ComponentValidationException("HTTPErrorURLNotCorrect"); + } + + if (cfg.getUsername() == null || cfg.getUsername().trim().length() == 0) { + throw new ComponentValidationException("HTTPErrorUsernameNotSet"); + } + if (cfg.getPassword() == null || cfg.getPassword().trim().length() == 0) { + throw new ComponentValidationException("HTTPErrorPasswordNotSet"); + } + } + + @Override + public HTTPUserStorageProvider create(KeycloakSession session, ComponentModel model) { + HTTPConfig cfg = new HTTPConfig(model.getConfig()); + return new HTTPUserStorageProvider(cfg, session, model); + } +} \ No newline at end of file diff --git a/src/main/resources/META-INF/keycloak-themes.json b/src/main/resources/META-INF/keycloak-themes.json new file mode 100644 index 0000000..ad3e0ec --- /dev/null +++ b/src/main/resources/META-INF/keycloak-themes.json @@ -0,0 +1,6 @@ +{ + "themes": [{ + "name" : "http", + "types": [ "admin" ] + }] +} \ No newline at end of file diff --git a/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory b/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory new file mode 100644 index 0000000..cfae065 --- /dev/null +++ b/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory @@ -0,0 +1 @@ +http.keycloak.userstorage.HTTPUserStorageProviderFactory \ No newline at end of file diff --git a/src/main/resources/theme/http/admin/messages/messages_en.properties b/src/main/resources/theme/http/admin/messages/messages_en.properties new file mode 100644 index 0000000..b437e84 --- /dev/null +++ b/src/main/resources/theme/http/admin/messages/messages_en.properties @@ -0,0 +1,19 @@ +# encoding: UTF-8 + +# +# this messages must be copied into +# /themes/base/admin/messages/admin-messages_en.properties +# +HTTP-URL=HTTP Backend URL +HTTP-URL-Help=Base URL for the HTTP backend + +HTTP-Username=HTTP Backend User +HTTP-Username-Help=This user can be used to login to HTTP backend service to read the users + +HTTP-Password=HTTP Backend Password +HTTP-Password-Help=The password for the HTTP backend + +HTTPErrorURLNotSet=The HTTP backend URL is empty. +HTTPErrorURLNotCorrect=The HTTP backend URL is not correctly formatted. +HTTPErrorUsernameNotSet=The HTTP backend username is empty. +HTTPErrorPasswordNotSet=The HTTP backend password is empty.s diff --git a/src/main/resources/theme/http/admin/theme.properties b/src/main/resources/theme/http/admin/theme.properties new file mode 100644 index 0000000..217db78 --- /dev/null +++ b/src/main/resources/theme/http/admin/theme.properties @@ -0,0 +1,2 @@ +parent=base +import=common/keycloak \ No newline at end of file