diff --git a/docker-compose.yml b/docker-compose.yml index a75db2f..a9f96f1 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -20,23 +20,7 @@ services: - ./keycloak/conf/quarkus.properties:/opt/keycloak/conf/quarkus.properties:ro - ./keycloak/keywind.jar:/opt/keycloak/providers/keywind.jar - ./keycloak/systemli.jar:/opt/keycloak/providers/systemli.jar - depends_on: - - userli - - userli: - image: mariadb:10.3 - environment: - MYSQL_USER: mail - MYSQL_PASSWORD: password - MYSQL_DATABASE: mail - MARIADB_RANDOM_ROOT_PASSWORD: true - volumes: - - ./mariadb/dump.sql:/docker-entrypoint-initdb.d/dump.sql - - userli:/var/lib/mysql networks: default: name: keycloak - -volumes: - userli: diff --git a/pom.xml b/pom.xml index 1296ff4..37309dd 100644 --- a/pom.xml +++ b/pom.xml @@ -30,40 +30,12 @@ ${version.keycloak} provided - - org.jboss.logging - jboss-logging - 3.5.3.Final - provided - - - com.goterl - lazysodium-java - 5.1.4 - - - com.goterl - resource-loader - 2.0.2 - - - org.junit.jupiter - junit-jupiter-engine - 5.10.2 - test - org.projectlombok lombok 1.18.32 provided - - jakarta.persistence - jakarta.persistence-api - 3.1.0 - provided - @@ -76,33 +48,6 @@ org.apache.maven.plugins maven-dependency-plugin 3.6.1 - - - copy - package - - copy - - - - - com.goterl - lazysodium-java - 5.1.4 - true - ${project.build.directory} - - - com.goterl - resource-loader - 2.0.2 - true - ${project.build.directory} - - - - - diff --git a/src/main/java/org/systemli/keycloak/Constants.java b/src/main/java/org/systemli/keycloak/Constants.java new file mode 100644 index 0000000..e8bbb23 --- /dev/null +++ b/src/main/java/org/systemli/keycloak/Constants.java @@ -0,0 +1,10 @@ +package org.systemli.keycloak; + +import lombok.experimental.UtilityClass; + +@UtilityClass +public class Constants { + public final String REALM_DOMAIN = "userliRealmDomain"; + public final String BASE_URL = "userliBaseUrl"; + public final String API_TOKEN = "userliKeycloakToken"; +} diff --git a/src/main/java/org/systemli/keycloak/PasswordVerifier.java b/src/main/java/org/systemli/keycloak/PasswordVerifier.java deleted file mode 100644 index 0680493..0000000 --- a/src/main/java/org/systemli/keycloak/PasswordVerifier.java +++ /dev/null @@ -1,27 +0,0 @@ -package org.systemli.keycloak; - -import java.nio.charset.StandardCharsets; - -import com.goterl.lazysodium.LazySodiumJava; -import com.goterl.lazysodium.SodiumJava; -import com.goterl.lazysodium.interfaces.PwHash; -import com.goterl.lazysodium.utils.LibraryLoader; - -public class PasswordVerifier { - - private PwHash.Native hasher; - - public PasswordVerifier() { - SodiumJava sodium = new SodiumJava(LibraryLoader.Mode.BUNDLED_ONLY); - LazySodiumJava lazySodium = new LazySodiumJava(sodium); - this.hasher = lazySodium; - } - - public boolean verify(String password, String hash) { - // See https://github.com/terl/lazysodium-java/issues/39#issuecomment-420614131 - byte[] hashBytes = (hash + "\0").getBytes(StandardCharsets.UTF_8); - byte[] passwordBytes = password.getBytes(StandardCharsets.UTF_8); - - return hasher.cryptoPwHashStrVerify(hashBytes, passwordBytes, passwordBytes.length); - } -} diff --git a/src/main/java/org/systemli/keycloak/UserNotFoundException.java b/src/main/java/org/systemli/keycloak/UserNotFoundException.java new file mode 100644 index 0000000..2839ee4 --- /dev/null +++ b/src/main/java/org/systemli/keycloak/UserNotFoundException.java @@ -0,0 +1,7 @@ +package org.systemli.keycloak; + +import lombok.experimental.StandardException; + +@StandardException +public class UserNotFoundException extends Exception { +} diff --git a/src/main/java/org/systemli/keycloak/UserliDomain.java b/src/main/java/org/systemli/keycloak/UserliDomain.java deleted file mode 100644 index f366edc..0000000 --- a/src/main/java/org/systemli/keycloak/UserliDomain.java +++ /dev/null @@ -1,17 +0,0 @@ -package org.systemli.keycloak; - -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.NamedQuery; -import jakarta.persistence.Table; -import lombok.Data; - -@Data -@NamedQuery(name = "findByName", query = "select d from UserliDomain d where d.name = :name") -@Table(name = "virtual_domains") -@Entity -public class UserliDomain { - @Id - private String id; - private String name; -} diff --git a/src/main/java/org/systemli/keycloak/UserliHttpClient.java b/src/main/java/org/systemli/keycloak/UserliHttpClient.java new file mode 100644 index 0000000..a450c9e --- /dev/null +++ b/src/main/java/org/systemli/keycloak/UserliHttpClient.java @@ -0,0 +1,71 @@ +package org.systemli.keycloak; + +import com.fasterxml.jackson.core.type.TypeReference; +import lombok.SneakyThrows; +import org.apache.http.impl.client.CloseableHttpClient; +import org.keycloak.broker.provider.util.SimpleHttp; +import org.keycloak.component.ComponentModel; +import org.keycloak.connections.httpclient.HttpClientProvider; +import org.keycloak.models.KeycloakSession; + +import java.util.List; + +public class UserliHttpClient { + + private final CloseableHttpClient httpClient; + private final String realmDomain; + private final String baseUrl; + private final String keycloakApiToken; + + public UserliHttpClient(KeycloakSession session, ComponentModel model) { + this.httpClient = session.getProvider(HttpClientProvider.class).getHttpClient(); + this.realmDomain = model.get(Constants.REALM_DOMAIN); + this.baseUrl = model.get(Constants.BASE_URL); + this.keycloakApiToken = model.get(Constants.API_TOKEN); + } + + @SneakyThrows + public List getUsers(String search, int first, int max) { + String url = String.format("%s/api/keycloak/%s", baseUrl, realmDomain); + SimpleHttp simpleHttp = SimpleHttp.doGet(url, httpClient) + .auth(keycloakApiToken) + .param("first", String.valueOf(first)) + .param("max", String.valueOf(max)); + if (search != null) { + simpleHttp.param("search", search); + } + return simpleHttp.asJson(new TypeReference<>() {}); + } + + @SneakyThrows + public Integer getUsersCount() { + String url = String.format("%s/api/keycloak/%s/count", baseUrl, realmDomain); + String count = SimpleHttp.doGet(url, httpClient) + .auth(keycloakApiToken) + .asString(); + return Integer.valueOf(count); + } + + @SneakyThrows + public UserliUser getUserById(String id) { + String url = String.format("%s/api/keycloak/%s/user/%s", baseUrl, realmDomain, id); + SimpleHttp.Response response = SimpleHttp.doGet(url, httpClient) + .auth(keycloakApiToken) + .asResponse(); + if (response.getStatus() == 404) { + throw new UserNotFoundException(); + } + return response.asJson(UserliUser.class); + } + + @SneakyThrows + public Boolean validate(String email, String password) { + String url = String.format("%s/api/keycloak/%s/validate/%s", baseUrl, realmDomain, email); + SimpleHttp.Response response = SimpleHttp.doPost(url, httpClient) + .auth(keycloakApiToken) + .param("password", password) + .asResponse(); + return response.getStatus() == 200; + } + +} diff --git a/src/main/java/org/systemli/keycloak/UserliUser.java b/src/main/java/org/systemli/keycloak/UserliUser.java index c7b6c39..5687f8d 100644 --- a/src/main/java/org/systemli/keycloak/UserliUser.java +++ b/src/main/java/org/systemli/keycloak/UserliUser.java @@ -1,37 +1,13 @@ package org.systemli.keycloak; -import jakarta.persistence.Entity; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.NamedQuery; -import jakarta.persistence.Table; +import java.util.List; import lombok.Data; -import lombok.NoArgsConstructor; -import lombok.NonNull; @Data -@NoArgsConstructor -@NamedQuery(name = "findUserByEmailAndDomain", query = "select u from UserliUser u where u.email = :email and u.domain = :domain") -@NamedQuery(name = "findUsersByEmailAndDomain", query = "select u from UserliUser u where u.email like :email and u.domain = :domain") -@NamedQuery(name = "findUserByUsernameAndDomain", query = "select u from UserliUser u where u.email like :username and u.domain = :domain") -@NamedQuery(name = "findUserByIdAndDomain", query = "select u from UserliUser u where u.id = :id and u.domain = :domain") -@NamedQuery(name = "countUsersByDomain", query = "select count(u) from UserliUser u where u.domain = :domain") -@Table(name = "virtual_users") -@Entity public class UserliUser { - @Id - @NonNull private String id; - @NonNull private String email; - @NonNull - private String password; - @NonNull - private Boolean deleted; - @ManyToOne - @JoinColumn(name = "domain_id") - private UserliDomain domain; + private List roles; public String getUsername() { return email.substring(0, email.indexOf('@')); diff --git a/src/main/java/org/systemli/keycloak/UserliUserAdapter.java b/src/main/java/org/systemli/keycloak/UserliUserAdapter.java index 8f9546d..6662b4e 100644 --- a/src/main/java/org/systemli/keycloak/UserliUserAdapter.java +++ b/src/main/java/org/systemli/keycloak/UserliUserAdapter.java @@ -39,11 +39,6 @@ public boolean isEmailVerified() { return true; } - @Override - public boolean isEnabled() { - return !user.getDeleted(); - } - @Override public Map> getAttributes() { MultivaluedHashMap attributes = new MultivaluedHashMap<>(); diff --git a/src/main/java/org/systemli/keycloak/UserliUserStorageProvider.java b/src/main/java/org/systemli/keycloak/UserliUserStorageProvider.java index 7ed8e28..20dca82 100644 --- a/src/main/java/org/systemli/keycloak/UserliUserStorageProvider.java +++ b/src/main/java/org/systemli/keycloak/UserliUserStorageProvider.java @@ -1,12 +1,12 @@ package org.systemli.keycloak; import org.keycloak.component.ComponentModel; -import org.keycloak.connections.jpa.JpaConnectionProvider; import org.keycloak.credential.CredentialInput; 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; @@ -14,67 +14,31 @@ import org.keycloak.storage.user.UserLookupProvider; import org.keycloak.storage.user.UserQueryProvider; -import jakarta.persistence.EntityManager; +import jakarta.ws.rs.WebApplicationException; +import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Properties; import java.util.stream.Stream; -public class UserliUserStorageProvider - implements UserStorageProvider, UserLookupProvider, CredentialInputValidator, UserQueryProvider { +public class UserliUserStorageProvider implements UserStorageProvider, UserLookupProvider, UserQueryProvider, CredentialInputValidator { private final KeycloakSession session; private final ComponentModel model; - private final PasswordVerifier passwordVerifier = new PasswordVerifier(); - private final UserliDomain domain; - private final EntityManager entityManager; + private final UserliHttpClient client; + + protected Map loadedUsers = new HashMap<>(); + protected Properties properties = new Properties(); public UserliUserStorageProvider(KeycloakSession session, ComponentModel model) { this.session = session; this.model = model; - this.entityManager = session.getProvider(JpaConnectionProvider.class, "userli").getEntityManager(); - this.domain = entityManager.createNamedQuery("findByName", UserliDomain.class) - .setParameter("name", model.get("domain")).getSingleResult(); + this.client = new UserliHttpClient(session, model); } @Override public void close() { - entityManager.close(); - } - - @Override - public Stream searchForUserStream(RealmModel realm, Map params, Integer firstResult, - Integer maxResults) { - String search = params.get(UserModel.SEARCH); - List users = entityManager.createNamedQuery("findUsersByEmailAndDomain", UserliUser.class) - .setParameter("email", search + "%") - .setParameter("domain", domain) - .setMaxResults(maxResults) - .setFirstResult(firstResult) - .getResultList(); - - return users.stream().map(user -> new UserliUserAdapter(session, realm, model, user)); - } - - @Override - public int getUsersCount(RealmModel realm, boolean includeServiceAccount) { - int count = entityManager.createNamedQuery("countUsersByDomain", Long.class) - .setParameter("domain", domain).getSingleResult().intValue(); - return count; - } - - @Override - public Stream getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, - Integer maxResults) { - // We don't support groups - return Stream.empty(); - } - - @Override - public Stream searchForUserByUserAttributeStream(RealmModel realm, String attrName, - String attrValue) { - // We don't support user attributes - return Stream.empty(); } @Override @@ -89,48 +53,70 @@ public boolean isConfiguredFor(RealmModel realm, UserModel user, String credenti @Override public boolean isValid(RealmModel realm, UserModel user, CredentialInput credentialInput) { - if (!this.supportsCredentialType(credentialInput.getType())) { + if (!this.supportsCredentialType(credentialInput.getType()) || !(credentialInput instanceof UserCredentialModel)) { return false; } - UserliUser loadedUser = entityManager.createNamedQuery("findUserByEmailAndDomain", UserliUser.class) - .setParameter("email", user.getEmail()).setParameter("domain", domain) - .getSingleResult(); - - if (loadedUser == null) { + String password = credentialInput.getChallengeResponse(); + if (password == null) { return false; } - return passwordVerifier.verify(credentialInput.getChallengeResponse(), loadedUser.getPassword()); + return client.validate(user.getEmail(), password); } @Override public UserModel getUserById(RealmModel realm, String id) { - UserliUser user = entityManager.createNamedQuery("findUserByIdAndDomain", UserliUser.class) - .setParameter("id", StorageId.externalId(id)) - .setParameter("domain", domain) - .getSingleResult(); - - return new UserliUserAdapter(session, realm, model, user); + return findUser(realm, StorageId.externalId(id)); } @Override public UserModel getUserByUsername(RealmModel realm, String username) { - UserliUser user = entityManager.createNamedQuery("findUserByUsernameAndDomain", UserliUser.class) - .setParameter("username", username + "@" + domain.getName()) - .setParameter("domain", domain) - .getSingleResult(); - - return new UserliUserAdapter(session, realm, model, user); + return findUser(realm, username); } @Override public UserModel getUserByEmail(RealmModel realm, String email) { - UserliUser user = entityManager.createNamedQuery("findUserByEmailAndDomain", UserliUser.class) - .setParameter("email", email) - .setParameter("domain", domain) - .getSingleResult(); + return findUser(realm, email); + } + + private UserModel findUser(RealmModel realm, String identifier) { + UserModel adapter = loadedUsers.get(identifier); + if (adapter == null) { + try { + UserliUser user = client.getUserById(identifier); + adapter = new UserliUserAdapter(session, realm, model, user); + loadedUsers.put(identifier, adapter); + } catch (WebApplicationException ignored) { + } + } + return adapter; + } + + @Override + public int getUsersCount(RealmModel real) { + return client.getUsersCount(); + } + + @Override + public Stream searchForUserStream(RealmModel realm, Map params, Integer firstResult, Integer maxResults) { + String search = params.get(UserModel.SEARCH); + return toUserModelStream(client.getUsers(search, firstResult, maxResults), realm); + } - return new UserliUserAdapter(session, realm, model, user); + private Stream toUserModelStream(List users, RealmModel realm) { + return users.stream().map(user -> new UserliUserAdapter(session, realm, model, user)); + } + + @Override + public Stream getGroupMembersStream(RealmModel realm, GroupModel group, Integer firstResult, Integer maxResults) { + // We don't support groups + return Stream.empty(); + } + + @Override + public Stream searchForUserByUserAttributeStream(RealmModel realm, String attrName, String attrValue) { + // We don't support user attributes + return Stream.empty(); } } diff --git a/src/main/java/org/systemli/keycloak/UserliUserStorageProviderFactory.java b/src/main/java/org/systemli/keycloak/UserliUserStorageProviderFactory.java index d5e2954..be00211 100644 --- a/src/main/java/org/systemli/keycloak/UserliUserStorageProviderFactory.java +++ b/src/main/java/org/systemli/keycloak/UserliUserStorageProviderFactory.java @@ -1,48 +1,50 @@ package org.systemli.keycloak; -import java.util.List; - 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; +import org.keycloak.utils.StringUtil; + +import java.util.List; public class UserliUserStorageProviderFactory implements UserStorageProviderFactory { - public static final String PROVIDER_ID = "userli-storage-provider"; - - protected final List configMetadata; - - public UserliUserStorageProviderFactory() { - configMetadata = ProviderConfigurationBuilder.create() - .property() - .name("domain") - .label("Domain") - .type(ProviderConfigProperty.STRING_TYPE) - .defaultValue("systemli.org") - .helpText("Domain which is allowed to login") - .required(true) - .add() - .build(); - } - - @Override - public UserliUserStorageProvider create(KeycloakSession session, ComponentModel model) { - return new UserliUserStorageProvider(session, model); - } - - @Override - public String getId() { - return PROVIDER_ID; - } - - @Override - public String getHelpText() { - return "Userli User Storage Provider"; - } - - @Override - public List getConfigProperties() { - return configMetadata; - } + + public static final String PROVIDER_ID = "userli-user-provider"; + + @Override + public UserliUserStorageProvider create(KeycloakSession session, ComponentModel model) { + return new UserliUserStorageProvider(session, model); + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getHelpText() { + return "Userli User Provider"; + } + + @Override + public List getConfigProperties() { + return ProviderConfigurationBuilder.create() + .property(Constants.REALM_DOMAIN, "Realm domain", "Domain used for this Realm", ProviderConfigProperty.STRING_TYPE, "", null) + .property(Constants.BASE_URL, "Base URL", "Base URL of the API", ProviderConfigProperty.STRING_TYPE, "", null) + .property(Constants.API_TOKEN, "API token", "Token to interact with Userli", ProviderConfigProperty.STRING_TYPE, "", null) + .build(); + } + + @Override + public void validateConfiguration(KeycloakSession session, RealmModel realm, ComponentModel config) throws ComponentValidationException { + if (StringUtil.isBlank(config.get(Constants.REALM_DOMAIN)) + || StringUtil.isBlank(config.get(Constants.BASE_URL)) + || StringUtil.isBlank(config.get(Constants.API_TOKEN))) { + throw new ComponentValidationException("Configuration not properly set, please verify."); + } + } } diff --git a/src/main/resources/META-INF/beans.xml b/src/main/resources/META-INF/beans.xml deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/resources/META-INF/persistence.xml b/src/main/resources/META-INF/persistence.xml deleted file mode 100644 index 743e57a..0000000 --- a/src/main/resources/META-INF/persistence.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - org.systemli.keycloak.UserliUser - org.systemli.keycloak.UserliDomain - - - - - - - - diff --git a/src/test/java/org/systemli/keycloak/PasswordVerifierTest.java b/src/test/java/org/systemli/keycloak/PasswordVerifierTest.java deleted file mode 100644 index b012ad5..0000000 --- a/src/test/java/org/systemli/keycloak/PasswordVerifierTest.java +++ /dev/null @@ -1,30 +0,0 @@ -package org.systemli.keycloak; - -import org.junit.jupiter.api.Test; - -import static org.junit.jupiter.api.Assertions.*; - -class PasswordVerifierTest { - - private PasswordVerifier passwordVerifier; - - public PasswordVerifierTest() { - passwordVerifier = new PasswordVerifier(); - } - - @Test - void testVerify() { - String password = "password"; - String hash = "$argon2id$v=19$m=65536,t=4,p=1$Exe5ShJPEy6Ar4tFMTmIzg$exP1vf3XaWSXGkboeGfEGhbfly9kMrdl4BIzPKQWY/E"; - - assertTrue(passwordVerifier.verify(password, hash)); - } - - @Test - void testVerifyWrongPassword() { - String password = "wrongpassword"; - String hash = "$argon2id$v=19$m=65536,t=4,p=1$Exe5ShJPEy6Ar4tFMTmIzg$exP1vf3XaWSXGkboeGfEGhbfly9kMrdl4BIzPKQWY/E"; - - assertFalse(passwordVerifier.verify(password, hash)); - } -}