From 0681839596e658672ebac1cf91cd63aeb15f3a47 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Benjamin=20Yule=20B=C3=A4dorf?= Date: Sun, 28 Apr 2024 01:46:04 +0200 Subject: [PATCH] Initial commit Adapted from https://github.com/dasniko/keycloak-extensions-demo --- .gitignore | 1 + README.md | 17 +++ flake.lock | 133 ++++++++++++++++++ flake.nix | 44 ++++++ pom.xml | 83 +++++++++++ .../events/JsonEventListenerProvider.java | 114 +++++++++++++++ .../JsonEventListenerProviderFactory.java | 76 ++++++++++ .../events/LastLoginTimeListener.java | 38 +++++ .../events/LastLoginTimeListenerFactory.java | 39 +++++ .../events/LastLoginTimeListenerTest.java | 53 +++++++ src/test/resources/demo-realm.json | 28 ++++ src/test/resources/log4j.properties | 4 + 12 files changed, 630 insertions(+) create mode 100644 .gitignore create mode 100755 README.md create mode 100644 flake.lock create mode 100644 flake.nix create mode 100644 pom.xml create mode 100644 src/main/java/pubsolar/keycloak/events/JsonEventListenerProvider.java create mode 100644 src/main/java/pubsolar/keycloak/events/JsonEventListenerProviderFactory.java create mode 100644 src/main/java/pubsolar/keycloak/events/LastLoginTimeListener.java create mode 100644 src/main/java/pubsolar/keycloak/events/LastLoginTimeListenerFactory.java create mode 100644 src/test/java/pubsolar/keycloak/events/LastLoginTimeListenerTest.java create mode 100644 src/test/resources/demo-realm.json create mode 100644 src/test/resources/log4j.properties diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2be92b --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +result diff --git a/README.md b/README.md new file mode 100755 index 0000000..b324a48 --- /dev/null +++ b/README.md @@ -0,0 +1,17 @@ +# Keycloak Event Listener + +Some demo event listeners for Keycloak. + +## Highlander Session Restrictor + +Allowing only the last session to survive, if a user logs in on multiple browsers/devices. + +I call it the _Highlander_ mode - _there must only be one!_ + +## AWS SNS + +Simply pushing all events to an AWS SNS topic. + +## Last Login Time + +Save the last (most recent) login time in an attribute of the user. diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..60f0df0 --- /dev/null +++ b/flake.lock @@ -0,0 +1,133 @@ +{ + "nodes": { + "devshell": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + }, + "locked": { + "lastModified": 1713532798, + "narHash": "sha256-wtBhsdMJA3Wa32Wtm1eeo84GejtI43pMrFrmwLXrsEc=", + "owner": "numtide", + "repo": "devshell", + "rev": "12e914740a25ea1891ec619bb53cf5e6ca922e40", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "devshell", + "type": "github" + } + }, + "flake-parts": { + "inputs": { + "nixpkgs-lib": "nixpkgs-lib" + }, + "locked": { + "lastModified": 1712014858, + "narHash": "sha256-sB4SWl2lX95bExY2gMFG5HIzvva5AVMJd4Igm+GpZNw=", + "owner": "hercules-ci", + "repo": "flake-parts", + "rev": "9126214d0a59633752a136528f5f3b9aa8565b7d", + "type": "github" + }, + "original": { + "owner": "hercules-ci", + "repo": "flake-parts", + "type": "github" + } + }, + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1701680307, + "narHash": "sha256-kAuep2h5ajznlPMD9rnQyffWG8EM/C73lejGofXvdM8=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "4022d587cbbfd70fe950c1e2083a02621806a725", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1704161960, + "narHash": "sha256-QGua89Pmq+FBAro8NriTuoO/wNaUtugt29/qqA8zeeM=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "63143ac2c9186be6d9da6035fa22620018c85932", + "type": "github" + }, + "original": { + "owner": "NixOS", + "ref": "nixpkgs-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-lib": { + "locked": { + "dir": "lib", + "lastModified": 1711703276, + "narHash": "sha256-iMUFArF0WCatKK6RzfUJknjem0H9m4KgorO/p3Dopkk=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "d8fe5e6c92d0d190646fb9f1056741a229980089", + "type": "github" + }, + "original": { + "dir": "lib", + "owner": "NixOS", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs_2": { + "locked": { + "lastModified": 1714076141, + "narHash": "sha256-Drmja/f5MRHZCskS6mvzFqxEaZMeciScCTFxWVLqWEY=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "7bb2ccd8cdc44c91edba16c48d2c8f331fb3d856", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "devshell": "devshell", + "flake-parts": "flake-parts", + "nixpkgs": "nixpkgs_2" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..3a250f5 --- /dev/null +++ b/flake.nix @@ -0,0 +1,44 @@ +{ + description = "keycloak-event-listener"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; + flake-parts.url = "github:hercules-ci/flake-parts"; + devshell.url = "github:numtide/devshell"; + }; + + outputs = inputs: + inputs.flake-parts.lib.mkFlake { inherit inputs; } { + imports = [ + inputs.devshell.flakeModule + ]; + + systems = [ + "x86_64-linux" + ]; + + perSystem = args@{ system, pkgs, lib, config, ... }: let + keycloak-event-listener = pkgs.maven.buildMavenPackage { + pname = "keycloak-event-listener"; + version = "0.0.1"; + src = ./.; + mvnHash = "sha256-tJgqe1WbVodEoRrDFPyHxsFkHIWHAPp5a2WsvWPb2l8="; + + installPhase = '' + runHook preInstall + install -Dm444 -t "$out" target/pubsolar.keycloak-event-listener.jar + runHook postInstall + ''; + }; + in { + packages.default = keycloak-event-listener; + packages.keycloak-event-listener = keycloak-event-listener; + + devshells.default = { + packages = with pkgs; [ + maven + ]; + }; + }; + }; +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f11ddca --- /dev/null +++ b/pom.xml @@ -0,0 +1,83 @@ + + 4.0.0 + + event-listener + 0.0.1 + pubsolar.keycloak + + + + org.keycloak + keycloak-core + 23.0.6 + + + org.keycloak + keycloak-server-spi + 23.0.6 + + + org.keycloak + keycloak-server-spi-private + 23.0.6 + + + + com.google.auto.service + auto-service + 1.1.1 + + + org.projectlombok + lombok + 1.18.32 + + + org.apache.commons + commons-lang3 + 3.14.0 + + + + org.junit.jupiter + junit-jupiter + 5.10.0 + + + org.testcontainers + junit-jupiter + 1.19.7 + + + com.github.dasniko + testcontainers-keycloak + 3.3.0 + + + io.rest-assured + rest-assured + 5.4.0 + + + org.slf4j + slf4j-reload4j + 2.0.13 + + + + + ${project.groupId}-${project.artifactId} + + + org.apache.maven.plugins + maven-shade-plugin + + + + + diff --git a/src/main/java/pubsolar/keycloak/events/JsonEventListenerProvider.java b/src/main/java/pubsolar/keycloak/events/JsonEventListenerProvider.java new file mode 100644 index 0000000..c91f04c --- /dev/null +++ b/src/main/java/pubsolar/keycloak/events/JsonEventListenerProvider.java @@ -0,0 +1,114 @@ +package pubsolar.keycloak.events; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.UriInfo; +import lombok.extern.slf4j.Slf4j; +import org.apache.commons.lang3.builder.ToStringBuilder; +import org.keycloak.events.Event; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.EventListenerTransaction; +import org.keycloak.events.admin.AdminEvent; +import org.keycloak.models.KeycloakContext; +import org.keycloak.models.KeycloakSession; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.slf4j.event.Level; + +import java.util.Map; + +@Slf4j(topic = "org.keycloak.events") +public class JsonEventListenerProvider implements EventListenerProvider { + + private final KeycloakSession session; + private final ObjectMapper mapper; + private final Level successLevel; + private final Level errorLevel; + private final EventListenerTransaction tx = new EventListenerTransaction(this::sendAdminEvent, this::logEvent); + + public JsonEventListenerProvider(KeycloakSession session, ObjectMapper mapper, Level successLevel, Level errorLevel) { + this.session = session; + this.mapper = mapper; + this.successLevel = successLevel; + this.errorLevel = errorLevel; + + session.getTransactionManager().enlistAfterCompletion(tx); + } + + @Override + public void onEvent(Event event) { + tx.addEvent(event); + } + + @Override + public void onEvent(AdminEvent event, boolean includeRepresentation) { + tx.addAdminEvent(event, includeRepresentation); + } + + @Override + public void close() { + } + + private void logEvent(Event event) { + Level level = event.getError() != null ? errorLevel : successLevel; + + if (log.isEnabledForLevel(level)) { + String s = null; + try { + Map map = mapper.convertValue(event, new TypeReference<>() {}); + + AuthenticationSessionModel authSession = session.getContext().getAuthenticationSession(); + if(authSession!=null) { + map.put("authSessionParentId", authSession.getParentSession().getId()); + map.put("authSessionTabId", authSession.getTabId()); + } + + if (log.isTraceEnabled()) { + setKeycloakContext(map); + } + + s = mapper.writeValueAsString(map); + } catch (JsonProcessingException e) { + log.error("Error while trying to JSONify event %s".formatted(ToStringBuilder.reflectionToString(event)), e); + } + + log.atLevel(log.isTraceEnabled() ? Level.TRACE : level).log(s); + } + } + + private void sendAdminEvent(AdminEvent event, boolean includeRepresentation) { + Level level = event.getError() != null ? errorLevel : successLevel; + + if (log.isEnabledForLevel(level)) { + String s = null; + try { + Map map = mapper.convertValue(event, new TypeReference<>() { + }); + + if (log.isTraceEnabled()) { + setKeycloakContext(map); + } + + s = mapper.writeValueAsString(map); + } catch (JsonProcessingException e) { + log.error("Error while trying to JSONify admin event %s".formatted(ToStringBuilder.reflectionToString(event)), e); + } + + log.atLevel(log.isTraceEnabled() ? Level.TRACE : level).log(s); + } + } + + private void setKeycloakContext(Map map) { + KeycloakContext context = session.getContext(); + UriInfo uriInfo = context.getUri(); + if (uriInfo != null) { + map.put("requestUri", uriInfo.getRequestUri().toString()); + } + HttpHeaders headers = context.getRequestHeaders(); + if (headers != null) { + map.put("cookies", headers.getCookies()); + } + } + +} diff --git a/src/main/java/pubsolar/keycloak/events/JsonEventListenerProviderFactory.java b/src/main/java/pubsolar/keycloak/events/JsonEventListenerProviderFactory.java new file mode 100644 index 0000000..6038591 --- /dev/null +++ b/src/main/java/pubsolar/keycloak/events/JsonEventListenerProviderFactory.java @@ -0,0 +1,76 @@ +package pubsolar.keycloak.events; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.google.auto.service.AutoService; +import org.keycloak.Config; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.EventListenerProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.provider.ProviderConfigurationBuilder; +import org.slf4j.event.Level; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; + +@AutoService(EventListenerProviderFactory.class) +public class JsonEventListenerProviderFactory implements EventListenerProviderFactory { + + public static final String PROVIDER_ID = "json-logging"; + + private static final ObjectMapper mapper = new ObjectMapper(); + + private Level successLevel; + private Level errorLevel; + + @Override + public EventListenerProvider create(KeycloakSession keycloakSession) { + return new JsonEventListenerProvider(keycloakSession, mapper, successLevel, errorLevel); + } + + @Override + public void init(Config.Scope config) { + successLevel = Level.valueOf(config.get("success-level", "debug").toUpperCase()); + errorLevel = Level.valueOf(config.get("error-level", "warn").toUpperCase()); + } + + @Override + public void postInit(KeycloakSessionFactory keycloakSessionFactory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public List getConfigMetadata() { + String[] logLevels = Arrays.stream(Level.values()) + .map(Level::name) + .map(String::toLowerCase) + .sorted(Comparator.naturalOrder()) + .toArray(String[]::new); + return ProviderConfigurationBuilder.create() + .property() + .name("success-level") + .type("string") + .helpText("The log level for success messages.") + .options(logLevels) + .defaultValue("debug") + .add() + .property() + .name("error-level") + .type("string") + .helpText("The log level for error messages.") + .options(logLevels) + .defaultValue("warn") + .add() + .build(); + } +} diff --git a/src/main/java/pubsolar/keycloak/events/LastLoginTimeListener.java b/src/main/java/pubsolar/keycloak/events/LastLoginTimeListener.java new file mode 100644 index 0000000..941d291 --- /dev/null +++ b/src/main/java/pubsolar/keycloak/events/LastLoginTimeListener.java @@ -0,0 +1,38 @@ +package dasniko.keycloak.events; + +import lombok.RequiredArgsConstructor; +import org.keycloak.common.util.Time; +import org.keycloak.events.Event; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.EventType; +import org.keycloak.events.admin.AdminEvent; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; + +/** + * @author Niko Köbler, https://www.n-k.de, @dasniko + */ +@RequiredArgsConstructor +public class LastLoginTimeListener implements EventListenerProvider { + + private final KeycloakSession session; + + @Override + public void onEvent(Event event) { + if (event.getType().equals(EventType.LOGIN)) { + UserModel user = session.users().getUserById(session.getContext().getRealm(), event.getUserId()); + if (user != null) { + user.setSingleAttribute(LastLoginTimeListenerFactory.attributeName, Integer.toString(Time.currentTime())); + } + } + } + + @Override + public void onEvent(AdminEvent event, boolean includeRepresentation) { + } + + @Override + public void close() { + } + +} diff --git a/src/main/java/pubsolar/keycloak/events/LastLoginTimeListenerFactory.java b/src/main/java/pubsolar/keycloak/events/LastLoginTimeListenerFactory.java new file mode 100644 index 0000000..19871bf --- /dev/null +++ b/src/main/java/pubsolar/keycloak/events/LastLoginTimeListenerFactory.java @@ -0,0 +1,39 @@ +package pubsolar.keycloak.events; + +import com.google.auto.service.AutoService; +import org.keycloak.Config; +import org.keycloak.events.EventListenerProvider; +import org.keycloak.events.EventListenerProviderFactory; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +@AutoService(EventListenerProviderFactory.class) +public class LastLoginTimeListenerFactory implements EventListenerProviderFactory { + + public static final String PROVIDER_ID = "last-login-time"; + + static String attributeName; + + @Override + public EventListenerProvider create(KeycloakSession session) { + return new LastLoginTimeListener(session); + } + + @Override + public void init(Config.Scope config) { + attributeName = config.get("attribute-name", "lastLoginTime"); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + } + + @Override + public void close() { + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/src/test/java/pubsolar/keycloak/events/LastLoginTimeListenerTest.java b/src/test/java/pubsolar/keycloak/events/LastLoginTimeListenerTest.java new file mode 100644 index 0000000..ecd1b80 --- /dev/null +++ b/src/test/java/pubsolar/keycloak/events/LastLoginTimeListenerTest.java @@ -0,0 +1,53 @@ +package pubsolar.keycloak.events; + +import pubsolar.testcontainers.keycloak.KeycloakContainer; +import de.keycloak.test.TestBase; +import org.junit.jupiter.api.Test; +import org.keycloak.admin.client.Keycloak; +import org.keycloak.representations.idm.RealmEventsConfigRepresentation; +import org.keycloak.representations.idm.UserRepresentation; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +import java.util.List; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; + +@Testcontainers +public class LastLoginTimeListenerTest extends TestBase { + + private static final String REALM = "demo"; + + @Container + private static final KeycloakContainer keycloak = new KeycloakContainer() + .withRealmImportFile("demo-realm.json") + .withEnv("KC_SPI_EVENTS_LISTENER_LAST_LOGIN_TIME_ATTRIBUTE_NAME", "lastLogin") + .withProviderClassesFrom("target/classes"); + + @Test + public void testLastLoginTime() { + Keycloak admin = keycloak.getKeycloakAdminClient(); + + // check user has no attributes + List users = admin.realm(REALM).users().searchByUsername("test", true); + UserRepresentation testUser = users.get(0); + Map> attributes = testUser.getAttributes(); + assertNull(attributes); + + // configure custom events listener + RealmEventsConfigRepresentation eventsConfig = new RealmEventsConfigRepresentation(); + eventsConfig.setEventsListeners(List.of(LastLoginTimeListenerFactory.PROVIDER_ID)); + admin.realm(REALM).updateRealmEventsConfig(eventsConfig); + + // "login" user + requestToken(keycloak, REALM, "test", "test"); + + // check user has last-login-time attribute + testUser = admin.realm(REALM).users().searchByUsername("test", true).get(0); + String lastLoginTime = testUser.firstAttribute("lastLogin"); + assertNotNull(lastLoginTime); + } + +} diff --git a/src/test/resources/demo-realm.json b/src/test/resources/demo-realm.json new file mode 100644 index 0000000..53f035e --- /dev/null +++ b/src/test/resources/demo-realm.json @@ -0,0 +1,28 @@ +{ + "id" : "demo", + "realm" : "demo", + "enabled" : true, + "users" : [ { + "id" : "49fcf95e-6fb3-4430-a29a-506a8b20e77c", + "createdTimestamp" : 1673444664000, + "username" : "test", + "enabled" : true, + "totp" : false, + "emailVerified" : true, + "firstName" : "Theo", + "lastName" : "Tester", + "email" : "test@keycloak.de", + "credentials" : [ { + "id" : "b9fbc30d-4269-49cc-a0ea-9170dc44a30c", + "type" : "password", + "createdDate" : 1673444664000, + "secretData" : "{\"value\":\"yxEZKVTeZlKufE5q4v0Hvxlggg2EaRta5zBtIMxialgwOHrQ3h4Hmre//uk9SlrEv2eqo4aH4bFgPDoktOTyHQ==\",\"salt\":\"d3mk1F43bvQrbV1D+jC1NQ==\",\"additionalParameters\":{}}", + "credentialData" : "{\"hashIterations\":27500,\"algorithm\":\"pbkdf2-sha256\",\"additionalParameters\":{}}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "default-roles-demo" ], + "notBefore" : 0, + "groups" : [ ] + } ] +} diff --git a/src/test/resources/log4j.properties b/src/test/resources/log4j.properties new file mode 100644 index 0000000..ce0b86f --- /dev/null +++ b/src/test/resources/log4j.properties @@ -0,0 +1,4 @@ +log4j.rootLogger=INFO, stdout +log4j.appender.stdout=org.apache.log4j.ConsoleAppender +log4j.appender.stdout.layout=org.apache.log4j.PatternLayout +log4j.appender.stdout.layout.ConversionPattern=%-4r [%t] %-5p %c %x - %m%n