Benjamin Yule Bädorf 2024-04-28 01:46:04 +02:00
commit 0681839596
Signed by: b12f
GPG Key ID: 729956E1124F8F26
12 changed files with 630 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
result

17
README.md Executable file
View File

@ -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.

133
flake.lock Normal file
View File

@ -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
}

44
flake.nix Normal file
View File

@ -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
];
};
};
};
}

83
pom.xml Normal file
View File

@ -0,0 +1,83 @@
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>event-listener</artifactId>
<version>0.0.1</version>
<groupId>pubsolar.keycloak</groupId>
<dependencies>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-core</artifactId>
<version>23.0.6</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi</artifactId>
<version>23.0.6</version>
</dependency>
<dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-server-spi-private</artifactId>
<version>23.0.6</version>
</dependency>
<!--dependency>
<groupId>org.keycloak</groupId>
<artifactId>keycloak-model-storage-private</artifactId>
<version>23.0.6</version>
</dependency-->
<dependency>
<groupId>com.google.auto.service</groupId>
<artifactId>auto-service</artifactId>
<version>1.1.1</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<version>1.18.32</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.0</version>
</dependency>
<dependency>
<groupId>org.testcontainers</groupId>
<artifactId>junit-jupiter</artifactId>
<version>1.19.7</version>
</dependency>
<dependency>
<groupId>com.github.dasniko</groupId>
<artifactId>testcontainers-keycloak</artifactId>
<version>3.3.0</version>
</dependency>
<dependency>
<groupId>io.rest-assured</groupId>
<artifactId>rest-assured</artifactId>
<version>5.4.0</version>
</dependency>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-reload4j</artifactId>
<version>2.0.13</version>
</dependency>
</dependencies>
<build>
<finalName>${project.groupId}-${project.artifactId}</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@ -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<String, Object> 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<String, Object> 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<String, Object> 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());
}
}
}

View File

@ -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<ProviderConfigProperty> 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();
}
}

View File

@ -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() {
}
}

View File

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

View File

@ -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<UserRepresentation> users = admin.realm(REALM).users().searchByUsername("test", true);
UserRepresentation testUser = users.get(0);
Map<String, List<String>> 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);
}
}

View File

@ -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" : [ ]
} ]
}

View File

@ -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