From 4c0d29a4e8179422c5152f748e5aa6a55951a12e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niko=20Ko=CC=88bler?= Date: Thu, 5 Oct 2023 19:42:10 +0200 Subject: [PATCH] http-server facade for user repo --- flintstones-userprovider/pom.xml | 4 + .../flintstones/FlintstoneUserAdapter.java | 2 +- .../FlintstonesUserStorageProvider.java | 34 +- ...FlintstonesUserStorageProviderFactory.java | 21 +- .../user/flintstones/repo/Credential.java | 13 + .../user/flintstones/repo/FlintstoneUser.java | 4 +- .../repo/FlintstonesApiClient.java | 110 +++++++ .../repo/FlintstonesApiServer.java | 151 +++++++++ .../repo/FlintstonesRepository.java | 53 +-- ...eycloak.storage.UserStorageProviderFactory | 1 - .../src/main/resources/flintstones.csv | 6 - .../FlintstonesUserStorageProviderTest.java | 11 +- .../src/test/resources/flintstones-realm.json | 3 + .../src/test/resources/user-api.yml | 301 ++++++++++++++++++ 14 files changed, 658 insertions(+), 56 deletions(-) create mode 100644 flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/Credential.java create mode 100644 flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstonesApiClient.java create mode 100644 flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstonesApiServer.java delete mode 100644 flintstones-userprovider/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory delete mode 100644 flintstones-userprovider/src/main/resources/flintstones.csv create mode 100644 flintstones-userprovider/src/test/resources/user-api.yml diff --git a/flintstones-userprovider/pom.xml b/flintstones-userprovider/pom.xml index 82fc839..b5e189d 100644 --- a/flintstones-userprovider/pom.xml +++ b/flintstones-userprovider/pom.xml @@ -38,6 +38,10 @@ org.projectlombok lombok + + com.google.auto.service + auto-service + org.slf4j slf4j-api diff --git a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/FlintstoneUserAdapter.java b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/FlintstoneUserAdapter.java index 2889b8e..7620a79 100644 --- a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/FlintstoneUserAdapter.java +++ b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/FlintstoneUserAdapter.java @@ -29,7 +29,7 @@ public class FlintstoneUserAdapter extends AbstractUserAdapterFederatedStorage { public FlintstoneUserAdapter(KeycloakSession session, RealmModel realm, ComponentModel model, FlintstoneUser user) { super(session, realm, model); - this.storageId = new StorageId(storageProviderModel.getId(), user.getUsername()); + this.storageId = new StorageId(storageProviderModel.getId(), user.getId()); this.user = user; } diff --git a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/FlintstonesUserStorageProvider.java b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/FlintstonesUserStorageProvider.java index cb5954a..5fa798a 100644 --- a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/FlintstonesUserStorageProvider.java +++ b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/FlintstonesUserStorageProvider.java @@ -1,7 +1,8 @@ package dasniko.keycloak.user.flintstones; +import dasniko.keycloak.user.flintstones.repo.Credential; import dasniko.keycloak.user.flintstones.repo.FlintstoneUser; -import dasniko.keycloak.user.flintstones.repo.FlintstonesRepository; +import dasniko.keycloak.user.flintstones.repo.FlintstonesApiClient; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.keycloak.component.ComponentModel; @@ -42,7 +43,7 @@ public class FlintstonesUserStorageProvider implements UserStorageProvider, private final KeycloakSession session; private final ComponentModel model; - private final FlintstonesRepository repository; + private final FlintstonesApiClient apiClient; // map of loaded users in this transaction private final Map loadedUsers = new HashMap<>(); @@ -64,7 +65,8 @@ public boolean isValid(RealmModel realm, UserModel user, CredentialInput input) if (!supportsCredentialType(input.getType()) || !(input instanceof UserCredentialModel cred)) { return false; } - return repository.validateCredentials(user.getUsername(), cred.getChallengeResponse()); + Credential credential = new Credential("password", cred.getChallengeResponse()); + return apiClient.verifyCredentials(StorageId.externalId(user.getId()), credential); } @Override @@ -88,7 +90,8 @@ public boolean updateCredential(RealmModel realm, UserModel user, CredentialInpu } } - return repository.updateCredentials(user.getUsername(), cred.getChallengeResponse()); + Credential credential = new Credential("password", cred.getChallengeResponse()); + return apiClient.updateCredentials(StorageId.externalId(user.getId()), credential); } @Override @@ -103,17 +106,17 @@ public Stream getDisableableCredentialTypesStream(RealmModel realm, User @Override public UserModel getUserById(RealmModel realm, String id) { String externalId = StorageId.externalId(id); - return findUser(realm, externalId, repository::findUserByUsernameOrEmail); + return findUser(realm, externalId, apiClient::getUserById); } @Override public UserModel getUserByUsername(RealmModel realm, String username) { - return findUser(realm, username, repository::findUserByUsernameOrEmail); + return findUser(realm, username, apiClient::getUserByUsername); } @Override public UserModel getUserByEmail(RealmModel realm, String email) { - return getUserByUsername(realm, email); + return findUser(realm, email, apiClient::getUserByEmail); } private UserModel findUser(RealmModel realm, String identifier, Function fnFindUser) { @@ -133,18 +136,16 @@ private UserModel findUser(RealmModel realm, String identifier, Function searchForUserStream(RealmModel realm, Map params, Integer firstResult, Integer maxResults) { List result; if (params.containsKey(UserModel.USERNAME)) { - result = List.of(repository.findUserByUsernameOrEmail(params.get(UserModel.USERNAME))); - } else if (params.containsKey(UserModel.SEARCH)) { - result = repository.findUsers(params.get(UserModel.SEARCH)); + result = List.of(apiClient.getUserByUsername(params.get(UserModel.USERNAME))); } else { - result = repository.getAllUsers(); + result = apiClient.searchUsers(params.getOrDefault(UserModel.SEARCH, null), firstResult, maxResults); } return result.stream().map(user -> new FlintstoneUserAdapter(session, realm, model, user)); } @@ -164,8 +165,9 @@ public UserModel addUser(RealmModel realm, String username) { if (syncUsers()) { FlintstoneUser flintstoneUser = new FlintstoneUser(); flintstoneUser.setUsername(username); + apiClient.createUser(flintstoneUser); + flintstoneUser = apiClient.getUserByUsername(username); FlintstoneUserAdapter newUser = new FlintstoneUserAdapter(session, realm, model, flintstoneUser); - repository.createUser(flintstoneUser); newUsers.add(newUser); loadedUsers.put(username, newUser); return newUser; @@ -176,19 +178,19 @@ public UserModel addUser(RealmModel realm, String username) { @Override public boolean removeUser(RealmModel realm, UserModel user) { String externalId = StorageId.externalId(user.getId()); - return repository.removeUser(externalId); + return apiClient.deleteUser(externalId); } @Override public void close() { newUsers.forEach(newUser -> { - repository.updateUser(newUser.getUser()); + apiClient.updateUser(newUser.getUser()); loadedUsers.remove(newUser.getUsername()); }); loadedUsers.values().forEach(user -> { FlintstoneUserAdapter userAdapter = (FlintstoneUserAdapter) user; if (userAdapter.isDirty()) { - repository.updateUser(userAdapter.getUser()); + apiClient.updateUser(userAdapter.getUser()); } }); } diff --git a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/FlintstonesUserStorageProviderFactory.java b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/FlintstonesUserStorageProviderFactory.java index 40a0e3e..d6c894a 100644 --- a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/FlintstonesUserStorageProviderFactory.java +++ b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/FlintstonesUserStorageProviderFactory.java @@ -1,6 +1,8 @@ package dasniko.keycloak.user.flintstones; -import dasniko.keycloak.user.flintstones.repo.FlintstonesRepository; +import com.google.auto.service.AutoService; +import dasniko.keycloak.user.flintstones.repo.FlintstonesApiClient; +import dasniko.keycloak.user.flintstones.repo.FlintstonesApiServer; import org.keycloak.component.ComponentModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; @@ -13,18 +15,21 @@ /** * @author Niko Köbler, http://www.n-k.de, @dasniko */ +@AutoService(UserStorageProviderFactory.class) public class FlintstonesUserStorageProviderFactory implements UserStorageProviderFactory { public static final String PROVIDER_ID = "the-flintstones"; + public static final String USER_API_BASE_URL = "apiBaseUrl"; static final String USER_CREATION_ENABLED = "userCreation"; static final String USE_PASSWORD_POLICY = "usePasswordPolicy"; - private FlintstonesRepository repository; + private FlintstonesApiServer apiServer; @Override public FlintstonesUserStorageProvider create(KeycloakSession session, ComponentModel model) { - return new FlintstonesUserStorageProvider(session, model, repository); + FlintstonesApiClient apiClient = new FlintstonesApiClient(session, model); + return new FlintstonesUserStorageProvider(session, model, apiClient); } @Override @@ -34,14 +39,22 @@ public String getId() { @Override public void postInit(KeycloakSessionFactory factory) { - repository = new FlintstonesRepository(); + apiServer = new FlintstonesApiServer(); } @Override public List getConfigProperties() { return ProviderConfigurationBuilder.create() + .property(USER_API_BASE_URL, "API Base URL", "", ProviderConfigProperty.STRING_TYPE, "http://localhost:8000", null) .property(USER_CREATION_ENABLED, "Sync Registrations", "Should newly created users be created within this store?", ProviderConfigProperty.BOOLEAN_TYPE, "false", null) .property(USE_PASSWORD_POLICY, "Validate password policy", "Determines if Keycloak should validate the password with the realm password policy before updating it.", ProviderConfigProperty.BOOLEAN_TYPE, "false", null) .build(); } + + @Override + public void close() { + if (apiServer != null) { + apiServer.stop(); + } + } } diff --git a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/Credential.java b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/Credential.java new file mode 100644 index 0000000..0cc5e70 --- /dev/null +++ b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/Credential.java @@ -0,0 +1,13 @@ +package dasniko.keycloak.user.flintstones.repo; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class Credential { + private String type; + private String value; +} diff --git a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstoneUser.java b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstoneUser.java index 2af0069..a665f98 100644 --- a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstoneUser.java +++ b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstoneUser.java @@ -13,6 +13,7 @@ @NoArgsConstructor public class FlintstoneUser implements Cloneable { + private String id; private String username; private String email; private String firstName; @@ -22,7 +23,8 @@ public class FlintstoneUser implements Cloneable { private Long created; private List roles; - public FlintstoneUser(String email, String firstName, String lastName, boolean enabled, List roles) { + public FlintstoneUser(String id, String email, String firstName, String lastName, boolean enabled, List roles) { + this.id = id; this.username = email.substring(0, email.indexOf("@")); this.email = email; this.firstName = firstName; diff --git a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstonesApiClient.java b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstonesApiClient.java new file mode 100644 index 0000000..1608bd4 --- /dev/null +++ b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstonesApiClient.java @@ -0,0 +1,110 @@ +package dasniko.keycloak.user.flintstones.repo; + +import com.fasterxml.jackson.core.type.TypeReference; +import dasniko.keycloak.user.flintstones.FlintstonesUserStorageProviderFactory; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; +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; + +@Slf4j +public class FlintstonesApiClient { + + private final CloseableHttpClient httpClient; + private final String baseUrl; + + public FlintstonesApiClient(KeycloakSession session, ComponentModel model) { + this.httpClient = session.getProvider(HttpClientProvider.class).getHttpClient(); + this.baseUrl = model.get(FlintstonesUserStorageProviderFactory.USER_API_BASE_URL); + } + + @SneakyThrows + public List searchUsers(String search, int first, int max) { + String url = String.format("%s/users", baseUrl); + SimpleHttp simpleHttp = prepareGetRequest(url); + if (first >= 0) { + simpleHttp.param("first", String.valueOf(first)); + } + if (max >= 0) { + simpleHttp.param("max", String.valueOf(max)); + } + if (search != null) { + simpleHttp.param("search", search); + } + return simpleHttp.asJson(new TypeReference<>() {}); + } + + @SneakyThrows + public Integer usersCount() { + String url = String.format("%s/users/count", baseUrl); + String count = prepareGetRequest(url).asString(); + return Integer.valueOf(count); + } + + @SneakyThrows + public boolean createUser(FlintstoneUser user) { + String url = String.format("%s/users", baseUrl); + return SimpleHttp.doPost(url, httpClient).json(user).asStatus() == 201; + } + + @SneakyThrows + public FlintstoneUser getUserById(String userId) { + String url = String.format("%s/users/%s", baseUrl, userId); + try (SimpleHttp.Response response = prepareGetRequest(url).asResponse()) { + if (response.getStatus() >= 400) { + return null; + } + return response.asJson(FlintstoneUser.class); + } + } + + public FlintstoneUser getUserByUsername(String username) { + return getUserByUsernameOrEmail("username", username); + } + + public FlintstoneUser getUserByEmail(String email) { + return getUserByUsernameOrEmail("email", email); + } + + @SneakyThrows + private FlintstoneUser getUserByUsernameOrEmail(String field, String value) { + String url = String.format("%s/users", baseUrl); + SimpleHttp simpleHttp = prepareGetRequest(url); + simpleHttp.param(field, value); + List result = simpleHttp.asJson(new TypeReference<>() {}); + return result.isEmpty() ? null : result.get(0); + } + + @SneakyThrows + public boolean updateUser(FlintstoneUser user) { + String url = String.format("%s/users/%s", baseUrl, user.getId()); + return SimpleHttp.doPut(url, httpClient).json(user).asStatus() == 204; + } + + @SneakyThrows + public boolean deleteUser(String userId) { + String url = String.format("%s/users/%s", baseUrl, userId); + return SimpleHttp.doDelete(url, httpClient).asStatus() == 204; + } + + @SneakyThrows + public boolean verifyCredentials(String userId, Credential credential) { + String url = String.format("%s/users/%s/credentials/verify", baseUrl, userId); + return SimpleHttp.doPost(url, httpClient).json(credential).asStatus() == 204; + } + + @SneakyThrows + public boolean updateCredentials(String userId, Credential credential) { + String url = String.format("%s/users/%s/credentials", baseUrl, userId); + return SimpleHttp.doPut(url, httpClient).json(credential).asStatus() == 204; + } + + private SimpleHttp prepareGetRequest(String url) { + return SimpleHttp.doGet(url, httpClient).acceptJson(); + } +} diff --git a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstonesApiServer.java b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstonesApiServer.java new file mode 100644 index 0000000..3bc04f4 --- /dev/null +++ b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstonesApiServer.java @@ -0,0 +1,151 @@ +package dasniko.keycloak.user.flintstones.repo; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.sun.net.httpserver.HttpExchange; +import com.sun.net.httpserver.HttpHandler; +import com.sun.net.httpserver.HttpServer; +import lombok.SneakyThrows; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.InetSocketAddress; +import java.util.Arrays; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +public class FlintstonesApiServer { + + private static final int PORT = 8000; + private HttpServer server; + + public FlintstonesApiServer() { + start(); + } + + public static void main(String[] args) { + new FlintstonesApiServer(); + } + + @SneakyThrows + private void start() { + long start = System.currentTimeMillis(); + + server = HttpServer.create(new InetSocketAddress(PORT), 0); + server.createContext("/users", new FlintstonesHandler()); + server.setExecutor(null); + server.start(); + + log.info("{} started on port {} in {} ms.", this.getClass().getSimpleName(), PORT, System.currentTimeMillis() - start); + } + + public void stop() { + if (server != null) { + server.stop(0); + } + } + + private static class FlintstonesHandler implements HttpHandler { + private final FlintstonesRepository repository = new FlintstonesRepository(); + private final ObjectMapper mapper = new ObjectMapper(); + + @Override + public void handle(HttpExchange exchange) throws IOException { + String method = exchange.getRequestMethod(); + String uriString = exchange.getRequestURI().toString(); + log.debug("Received request for {} - {}", method, uriString); + long start = System.currentTimeMillis(); + + String userId = null; + String credentials = null; + String[] parts = exchange.getRequestURI().getPath().split("/"); + if (parts.length >= 3) { + userId = parts[2]; + } + if (parts.length >= 4) { + credentials = parts[3]; + } + + InputStream requestBody = exchange.getRequestBody(); + + Object entity = null; + int status = 200; + + if (userId == null) { + List users = List.of(); + if ("GET".equalsIgnoreCase(method)) { + String query = exchange.getRequestURI().getQuery(); + if (query != null) { + Map queryParams = Arrays.stream(query.split("&")) + .map(s -> s.split("=")).collect(Collectors.toMap(k -> k[0], v -> v[1])); + if (queryParams.containsKey("username")) { + FlintstoneUser user = repository.findUserByUsernameOrEmail(queryParams.get("username")); + if (user != null) { + users = List.of(user); + } + } else if (queryParams.containsKey("email")) { + FlintstoneUser user = repository.findUserByUsernameOrEmail(queryParams.get("email")); + if (user != null) { + users = List.of(user); + } + } else if (queryParams.containsKey("search")) { + users = repository.findUsers(queryParams.get("search")); + } else { + users = repository.getAllUsers(); + } + } else { + users = repository.getAllUsers(); + } + entity = users; + } else if ("POST".equalsIgnoreCase(method)) { + FlintstoneUser flintstoneUser = mapper.readValue(requestBody, FlintstoneUser.class); + repository.createUser(flintstoneUser); + status = 201; + } + } else if (credentials == null) { + if ("GET".equalsIgnoreCase(method)) { + if ("count".equalsIgnoreCase(userId)) { + entity = Map.of("count", repository.getUsersCount()); + } else { + entity = repository.findUserById(userId); + if (entity == null) { + status = 404; + } + } + } else if ("PUT".equalsIgnoreCase(method)) { + FlintstoneUser flintstoneUser = mapper.readValue(requestBody, FlintstoneUser.class); + repository.updateUser(flintstoneUser); + status = 204; + } else if ("DELETE".equalsIgnoreCase(method)) { + boolean result = repository.removeUser(userId); + status = result ? 204 : 400; + } + } else { + Credential credential = mapper.readValue(requestBody, Credential.class); + status = 400; + boolean result; + if ("PUT".equalsIgnoreCase(method)) { + result = repository.updateCredentials(userId, credential.getValue()); + status = result ? 204 : 400; + } + if ("POST".equalsIgnoreCase(method)) { + result = repository.validateCredentials(userId, credential.getValue()); + status = result ? 204 : 400; + } + } + + byte[] bytes = mapper.writeValueAsBytes(entity); + exchange.getResponseHeaders().add("Content-Type", "application/json"); + exchange.sendResponseHeaders(status, bytes.length); + OutputStream os = exchange.getResponseBody(); + os.write(bytes); + os.flush(); + os.close(); + + log.debug("Processed request for {} - {} in {} ms.", method, uriString, System.currentTimeMillis() - start); + } + } +} diff --git a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstonesRepository.java b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstonesRepository.java index 66ec056..c3c6a3b 100644 --- a/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstonesRepository.java +++ b/flintstones-userprovider/src/main/java/dasniko/keycloak/user/flintstones/repo/FlintstonesRepository.java @@ -2,12 +2,9 @@ import lombok.SneakyThrows; -import java.io.BufferedReader; -import java.io.InputStream; -import java.io.InputStreamReader; import java.util.ArrayList; import java.util.List; -import java.util.Objects; +import java.util.UUID; import java.util.stream.Collectors; /** @@ -18,58 +15,62 @@ public class FlintstonesRepository { private final List users = new ArrayList<>(); @SneakyThrows - public FlintstonesRepository() { - try (InputStream inputStream = Objects.requireNonNull(getClass().getClassLoader().getResourceAsStream("/flintstones.csv"))) { - List lines = new BufferedReader(new InputStreamReader(inputStream)).lines().toList(); - lines.forEach(line -> { - String[] values = line.split(";"); - users.add( - new FlintstoneUser(values[0], values[1], values[2], Boolean.parseBoolean(values[3]), values.length > 4 ? List.of(values[4]) : null) - ); - }); - } + FlintstonesRepository() { + users.add(new FlintstoneUser("66671638-b48a-4bab-8f37-d20efea42ce3", "fred.flintstone@flintstones.com", "Fred", "Flintstone", true, List.of("stoneage"))); + users.add(new FlintstoneUser("ced34250-cb88-4cc0-87e8-6fca25a926e3", "wilma.flintstone@flintstones.com", "Wilma", "Flintstone", true, List.of("stoneage"))); + users.add(new FlintstoneUser("12df5d9c-c6ac-48e8-8086-b10ab7985a65", "pebbles.flintstone@flintstones.com", "Pebbles", "Flintstone", true, List.of("stoneage"))); + users.add(new FlintstoneUser("1b6c083b-3e14-40ef-897c-a0d3cdba22ed", "barney.rubble@flintstones.com", "Barney", "Rubble", true, List.of("stoneage"))); + users.add(new FlintstoneUser("42c88684-c585-4d83-b748-7a49213c4690", "betty.rubble@flintstones.com", "Betty", "Rubble", true, null)); + users.add(new FlintstoneUser("08950eb2-920b-48f5-bbe0-9694e8ad6fc4", "bambam.rubble@flintstones.com", "Bam Bam", "Rubble", false, null)); } - public List getAllUsers() { + List getAllUsers() { return users; } - public int getUsersCount() { + int getUsersCount() { return users.size(); } + FlintstoneUser findUserById(String id) { + return users.stream() + .filter(user -> user.getId().equalsIgnoreCase(id)) + .findFirst().orElse(null); + } + private FlintstoneUser findUserByUsernameOrEmailInternal(String username) { return users.stream() .filter(user -> user.getUsername().equalsIgnoreCase(username) || user.getEmail().equalsIgnoreCase(username)) .findFirst().orElse(null); } - public FlintstoneUser findUserByUsernameOrEmail(String username) { + FlintstoneUser findUserByUsernameOrEmail(String username) { FlintstoneUser user = findUserByUsernameOrEmailInternal(username); return user != null ? user.clone() : null; } - public List findUsers(String query) { + List findUsers(String query) { return users.stream() .filter(user -> query.equalsIgnoreCase("*") || user.getUsername().contains(query) || user.getEmail().contains(query)) .collect(Collectors.toList()); } - public boolean validateCredentials(String username, String password) { - return findUserByUsernameOrEmailInternal(username).getPassword().equals(password); + boolean validateCredentials(String id, String password) { + return findUserById(id).getPassword().equals(password); } - public boolean updateCredentials(String username, String password) { - findUserByUsernameOrEmailInternal(username).setPassword(password); + boolean updateCredentials(String id, String password) { + findUserById(id).setPassword(password); return true; } - public void createUser(FlintstoneUser user) { + void createUser(FlintstoneUser user) { + user.setId(UUID.randomUUID().toString()); user.setCreated(System.currentTimeMillis()); users.add(user); } - public void updateUser(FlintstoneUser user) { + void updateUser(FlintstoneUser user) { FlintstoneUser existing = findUserByUsernameOrEmailInternal(user.getUsername()); existing.setEmail(user.getEmail()); existing.setFirstName(user.getFirstName()); @@ -77,8 +78,8 @@ public void updateUser(FlintstoneUser user) { existing.setEnabled(user.isEnabled()); } - public boolean removeUser(String username) { - return users.removeIf(p -> p.getUsername().equals(username)); + boolean removeUser(String id) { + return users.removeIf(p -> p.getId().equals(id)); } } diff --git a/flintstones-userprovider/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory b/flintstones-userprovider/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory deleted file mode 100644 index 2034d6e..0000000 --- a/flintstones-userprovider/src/main/resources/META-INF/services/org.keycloak.storage.UserStorageProviderFactory +++ /dev/null @@ -1 +0,0 @@ -dasniko.keycloak.user.flintstones.FlintstonesUserStorageProviderFactory diff --git a/flintstones-userprovider/src/main/resources/flintstones.csv b/flintstones-userprovider/src/main/resources/flintstones.csv deleted file mode 100644 index 7ffe5d8..0000000 --- a/flintstones-userprovider/src/main/resources/flintstones.csv +++ /dev/null @@ -1,6 +0,0 @@ -fred.flintstone@flintstones.com;Fred;Flintstone;true;stoneage -wilma.flintstone@flintstones.com;Wilma;Flintstone;true;stoneage -pebbles.flintstone@flintstones.com;Pebbles;Flintstone;true;stoneage -barney.rubble@flintstones.com;Barney;Rubble;true;stoneage -betty.rubble@flintstones.com;Betty;Rubble;true -bambam.rubble@flintstones.com;Bam Bam;Rubble;false diff --git a/flintstones-userprovider/src/test/java/dasniko/keycloak/user/flintstones/FlintstonesUserStorageProviderTest.java b/flintstones-userprovider/src/test/java/dasniko/keycloak/user/flintstones/FlintstonesUserStorageProviderTest.java index 7d57281..221e8df 100644 --- a/flintstones-userprovider/src/test/java/dasniko/keycloak/user/flintstones/FlintstonesUserStorageProviderTest.java +++ b/flintstones-userprovider/src/test/java/dasniko/keycloak/user/flintstones/FlintstonesUserStorageProviderTest.java @@ -8,6 +8,7 @@ import io.restassured.response.ValidatableResponse; import lombok.extern.slf4j.Slf4j; import org.junit.jupiter.api.MethodOrderer; +import org.junit.jupiter.api.Order; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.TestMethodOrder; import org.junit.jupiter.params.ParameterizedTest; @@ -39,7 +40,7 @@ */ @Slf4j @Testcontainers -@TestMethodOrder(MethodOrderer.MethodName.class) +@TestMethodOrder(MethodOrderer.OrderAnnotation.class) public class FlintstonesUserStorageProviderTest { static final String REALM = "flintstones"; @@ -50,9 +51,11 @@ public class FlintstonesUserStorageProviderTest { private static final KeycloakContainer keycloak = new KeycloakContainer() .withRealmImportFile("/flintstones-realm.json") .withEnv("KC_SPI_EVENTS_LISTENER_JBOSS_LOGGING_SUCCESS_LEVEL", "info") + .withEnv("KC_LOG_LEVEL", "INFO,dasniko:debug") .withProviderClassesFrom("target/classes"); + @Order(1) @ParameterizedTest @ValueSource(strings = {KeycloakContainer.MASTER_REALM, REALM}) public void testRealms(String realm) { @@ -64,6 +67,7 @@ public void testRealms(String realm) { given().when().get(accountServiceUrl).then().statusCode(200); } + @Order(2) @ParameterizedTest @ValueSource(strings = {"fred.flintstone@flintstones.com", FRED_FLINTSTONE}) public void testLoginAsUserAndCheckAccessToken(String userIdentifier) throws IOException { @@ -83,11 +87,13 @@ public void testLoginAsUserAndCheckAccessToken(String userIdentifier) throws IOE } @Test + @Order(3) public void testLoginAsUserWithInvalidPassword() { requestToken(FRED_FLINTSTONE, "invalid").then().statusCode(401); } @Test + @Order(4) public void testUpdatePassword() { // call update password action directly String authEndpoint = getOpenIDConfiguration().extract().path("authorization_endpoint"); @@ -132,6 +138,7 @@ public void testUpdatePassword() { } @Test + @Order(5) public void testAccessingUsersAsAdmin() { Keycloak kcAdmin = keycloak.getKeycloakAdminClient(); UsersResource usersResource = kcAdmin.realm(REALM).users(); @@ -145,6 +152,7 @@ public void testAccessingUsersAsAdmin() { } @Test + @Order(6) public void testSearchAllUsersAndRemoveUserAsAdmin() { Keycloak kcAdmin = keycloak.getKeycloakAdminClient(); UsersResource usersResource = kcAdmin.realm(REALM).users(); @@ -178,6 +186,7 @@ public void testSearchAllUsersAndRemoveUserAsAdmin() { } @Test + @Order(7) public void testUpdateUserAsAdmin() { Keycloak kcAdmin = keycloak.getKeycloakAdminClient(); UsersResource usersResource = kcAdmin.realm(REALM).users(); diff --git a/flintstones-userprovider/src/test/resources/flintstones-realm.json b/flintstones-userprovider/src/test/resources/flintstones-realm.json index 284c646..532c17f 100644 --- a/flintstones-userprovider/src/test/resources/flintstones-realm.json +++ b/flintstones-userprovider/src/test/resources/flintstones-realm.json @@ -12,6 +12,9 @@ "providerId": "the-flintstones", "subComponents": {}, "config": { + "apiBaseUrl": [ + "http://localhost:8000" + ], "userCreation": [ "true" ], diff --git a/flintstones-userprovider/src/test/resources/user-api.yml b/flintstones-userprovider/src/test/resources/user-api.yml new file mode 100644 index 0000000..89232cc --- /dev/null +++ b/flintstones-userprovider/src/test/resources/user-api.yml @@ -0,0 +1,301 @@ +--- +openapi: 3.0.3 +info: + title: User Storage API + description: User Storage API for usage from within a Keycloak User Storage SPI to connect to an external user storage. + version: 1.0.0 + +externalDocs: + description: Keycloak User Storage SPI Documentation + url: https://www.keycloak.org/docs/latest/server_development/index.html#_user-storage-spi + +security: + - basicAuth: [] + +tags: + - name: users + description: Operations about users + - name: credentials + description: Operations on credentials + +paths: + /users: + get: + summary: List of users + description: Returns a list of users. If no users could be found, an empty list must be returned. + operationId: listUsers + tags: + - users + parameters: + - name: first + in: query + description: number of first result to return + schema: + type: integer + minimum: 0 + default: 0 + - name: max + in: query + description: number of max results to return + schema: + type: integer + minimum: 1 + default: 20 + - name: username + in: query + description: username to search for (exactly) + schema: + type: string + example: fred.flintstone + - name: email + in: query + description: email to search for (exactly) + schema: + type: string + example: fred.flintstone@flintstones.com + - name: search + in: query + description: case insensitive list of strings separated by whitespaces, contained (possibly partly) in username, email, first name or last name + schema: + type: string + responses: + 200: + description: success + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + 401: + $ref: '#/components/responses/UnauthorizedError' + post: + summary: Create a user + operationId: createUser + tags: + - users + requestBody: + content: + applcation/json: + schema: + $ref: '#/components/schemas/User' + responses: + 201: + description: User successfully created + 400: + description: Invalid id supplied + 401: + $ref: '#/components/responses/UnauthorizedError' + 404: + description: User not found + /users/count: + get: + summary: Number of users + operationId: usersCount + tags: + - users + responses: + 200: + description: success + content: + application/json: + schema: + type: object + required: + - count + properties: + count: + type: integer + minimum: 0 + example: + count: 123 + 401: + $ref: '#/components/responses/UnauthorizedError' + /users/{id}: + get: + summary: Find single user by id + operationId: getUser + tags: + - users + parameters: + - name: id + in: path + description: id of user to return + required: true + schema: + type: string + responses: + 200: + description: User found + content: + application/json: + schema: + $ref: '#/components/schemas/User' + 400: + description: Invalid id supplied + 401: + $ref: '#/components/responses/UnauthorizedError' + 404: + description: User not found + put: + summary: Update a user + operationId: updateUser + tags: + - users + parameters: + - name: id + in: path + description: id of user to update + required: true + schema: + type: string + requestBody: + content: + applcation/json: + schema: + $ref: '#/components/schemas/User' + responses: + 201: + description: User successfully updated + 400: + description: Invalid id supplied + 401: + $ref: '#/components/responses/UnauthorizedError' + 404: + description: User not found + delete: + summary: Delete a single user + operationId: deleteUser + tags: + - users + parameters: + - name: id + in: path + description: id of user to delete + required: true + schema: + type: string + responses: + 204: + description: User successfully deleted + 400: + description: Invalid id supplied + 401: + $ref: '#/components/responses/UnauthorizedError' + 404: + description: User not found + /users/{id}/credentials: + put: + summary: Updates users credential model + operationId: updateCredentialModel + tags: + - credentials + parameters: + - name: id + in: path + description: id of the user the credentials should be updated for + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialModel' + responses: + 204: + description: Credential model update successful + 400: + description: Credential model update failed + 401: + $ref: '#/components/responses/UnauthorizedError' + 404: + description: User entity to update the credential model for not found + /users/{id}/credentials/verify: + post: + summary: Send credential for verification + operationId: verifyCredentialModel + tags: + - credentials + parameters: + - name: id + in: path + description: id of the user the credentials should be verified for + required: true + schema: + type: string + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/CredentialModel' + responses: + 200: + description: Credential model verification successful + 400: + description: Credential model verification not successful + 401: + $ref: '#/components/responses/UnauthorizedError' + 404: + description: User not found + +components: + responses: + UnauthorizedError: + description: Authentication information is missing or invalid + headers: + WWW_Authenticate: + schema: + type: string + securitySchemes: + basicAuth: + type: http + scheme: basic + schemas: + User: + type: object + required: + - id + - username + - email + properties: + id: + type: string + example: 27acf34b-c357-498f-9101-bdcd3fc34580 + username: + type: string + example: fred.flintstone + email: + type: string + example: 'fred.flintstone@flintstones.com' + firstName: + type: string + example: Fred + lastName: + type: string + example: Flintstone + enabled: + type: boolean + example: true + created: + type: number + example: 1672531200 + roles: + type: array + items: + type: string + example: stoneage + CredentialModel: + type: object + required: + - value + properties: + value: + type: string + description: credential value, e.g. hashed or cleartext password (base64 encoded), the example value here is "password" un-hashed + example: cGFzc3dvcmQ + type: + type: string + description: the type of this credential, currently here only "password" is available + default: password + example: password