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));
- }
-}