diff --git a/README.md b/README.md new file mode 100644 index 0000000..da1b8ca --- /dev/null +++ b/README.md @@ -0,0 +1,106 @@ +# Keycloak allow/ban plugin + +This is a plugin for Keycloak that checks an allow-list and a ban list for +users from GitHub. + +## Configuration + +Add the plugin to your keycloak plugins list. The plugin can be obtained via +`nix build .#packages.default`. + +The configuration of this plugin is in a directory of text files with the +format `github-id github-username`, allowing `#` comments. + +Specify a Keycloak config file option +`spi-authenticator-allow-ban-check-authenticator-dbpath` pointing to the +directory with the configuration. Note that the error if you don't configure +this is complete garbage, and is also not printed by default (sorry)! Use +`kc.sh --verbose start` to read your `NullPointerException`. + +There are three notable files in there: +- `banned-users.txt`: contains a list of GitHub IDs which will be rejected + outright on login. + + If you newly ban a user, you have to kill all their sessions across all + infrastructure, including existing Keycloak sessions, since bans only apply + on login. +- `allowed-users.txt`: contains a list of GitHub IDs which will be allowed if the + allow-list is enabled. +- `use-allow-list.txt`: if present, the allow-list mechanism is used. Otherwise + it is bypassed and all logins are allowed. + +The intent of the configuration is that it is synced by a cron job pulling a +git repo. + +## Setup + +1. In the GitHub Identity Provider configuration on Keycloak, set up a mapper with + type "Attribute Importer", importing the JSON field path "id" as a user + profile attribute "githubId". + +2. Create an auth flow for post login on the identity provider, containing one + element "Allow/Ban check". This is necessary since it bypasses the standard + login flow if you log in via the external IdP. + +3. In the identity provider, set the *Post Login Flow* to the flow just + created. + +4. Add the "Allow/Ban check" action to the main login flow as a Required + element at a point *after* the username is determined. + +## Notes + +We are unsure if Store Tokens is necessary to set; it is not for this plugin, +but it might be a good idea to simply have them around. + +We don't think there are ways to ban-evade, since this is managed by a +user-invisible profile attribute that is permanently glued to all accounts +originating from GitHub. + +We have tested this on Keycloak 23 and 24. + +### Test environment! + +There is a test environment included with this plugin to avoid testing in prod. +Run: + +``` +nix run .# +``` + +Then in a separate terminal: + +``` +sudo socat TCP-LISTEN:443,fork,reuseaddr TCP:127.0.0.1:4043 +``` + +and add `127.0.0.1 identity.test.lix.systems` to `/etc/hosts`. Dump Firefox +DNS cache if necessary (`about:networking`), and create a GitHub OAuth app. + +You can ssh into the machine on port 2022 on localhost as root, with no +password. + +Then finally go to `https://identity.test.lix.systems/superadmin`, and log in +with `admin`/`Password1`. + +### Attaching a debugger to Keycloak + +We are so sorry. + +If you are doing this to the VM here, change the last line of the keycloak +startup script to this: + +`DEBUG=true DEBUG_PORT='*:1337' DEBUG_SUSPEND=y kc.sh --verbose start --optimized --debug` + +To actually make that work, you want to copy the file from `systemctl cat +keycloak.service` to `/start-keycloak`, then `systemctl edit --runtime +keycloak`, with the contents: + +``` +[Service] +ExecStart= +ExecStart=/start-keycloak +``` + +Then `systemctl restart keycloak`. Next create a forward like `ssh +-L1337:localhost:1337 localvm` and attach your Java debugger to port 1337. diff --git a/flake.lock b/flake.lock index 81c9a75..5b61ad6 100644 --- a/flake.lock +++ b/flake.lock @@ -15,13 +15,37 @@ "type": "github" } }, + "microvm": { + "inputs": { + "flake-utils": [ + "flake-utils" + ], + "nixpkgs": [ + "nixpkgs" + ], + "spectrum": "spectrum" + }, + "locked": { + "lastModified": 1711159783, + "narHash": "sha256-nwl2Cygq7NrV9QcebJE/T/vXv7w+zLERD7ygHz0F5g8=", + "owner": "astro", + "repo": "microvm.nix", + "rev": "d31f7c7d3194c51372134832a3a2a256773c161a", + "type": "github" + }, + "original": { + "owner": "astro", + "repo": "microvm.nix", + "type": "github" + } + }, "nixpkgs": { "locked": { - "lastModified": 1700612854, - "narHash": "sha256-yrQ8osMD+vDLGFX7pcwsY/Qr5PUd6OmDMYJZzZi0+zc=", + "lastModified": 1711163522, + "narHash": "sha256-YN/Ciidm+A0fmJPWlHBGvVkcarYWSC+s3NTPk/P+q3c=", "owner": "nixos", "repo": "nixpkgs", - "rev": "19cbff58383a4ae384dea4d1d0c823d72b49d614", + "rev": "44d0940ea560dee511026a53f0e2e2cde489b4d4", "type": "github" }, "original": { @@ -34,8 +58,25 @@ "root": { "inputs": { "flake-utils": "flake-utils", + "microvm": "microvm", "nixpkgs": "nixpkgs" } + }, + "spectrum": { + "flake": false, + "locked": { + "lastModified": 1708358594, + "narHash": "sha256-e71YOotu2FYA67HoC/voJDTFsiPpZNRwmiQb4f94OxQ=", + "ref": "refs/heads/main", + "rev": "6d0e73864d28794cdbd26ab7b37259ab0e1e044c", + "revCount": 614, + "type": "git", + "url": "https://spectrum-os.org/git/spectrum" + }, + "original": { + "type": "git", + "url": "https://spectrum-os.org/git/spectrum" + } } }, "root": "root", diff --git a/flake.nix b/flake.nix index 9260d9e..b310316 100644 --- a/flake.nix +++ b/flake.nix @@ -2,9 +2,14 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable"; flake-utils.url = "github:numtide/flake-utils"; + microvm = { + url = "github:astro/microvm.nix"; + inputs.nixpkgs.follows = "nixpkgs"; + inputs.flake-utils.follows = "flake-utils"; + }; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { self, nixpkgs, flake-utils, microvm }: let out = system: let @@ -14,41 +19,32 @@ }; in { - devShells.default = (pkgs.buildFHSEnv { - name = "s4d-env"; - targetPkgs = pkgs: (with pkgs; [ + devShells.default = pkgs.mkShell { + buildInputs = with pkgs; [ jdk zlib - freetype - fontconfig maven - protobuf - ]); - runScript = "zsh"; - }).env; - - packages.default = pkgs.maven.buildMavenPackage { - pname = "keycloak-lists-plugin"; - version = "1.0"; - - src = ./plugin; - - mvnHash = "sha256-UaVCt6KIjR8i3vHVp5YWqu8zzM7mftXyrv5J2jxtw6Q="; - - buildPhase = '' - mvn --offline package; - ''; - - installPhase = '' - mkdir -p $out/share/java - install -Dm644 target/*.jar $out/share/java - ''; + nixos-rebuild + ]; }; + + packages.default = pkgs.callPackage ./package.nix { }; + packages.run = self.nixosConfigurations.test.config.microvm.declaredRunner; }; in flake-utils.lib.eachDefaultSystem out // { - overlays.default = final: prev: { + nixosConfigurations.test = nixpkgs.lib.nixosSystem { + system = "x86_64-linux"; + modules = [ + ({ ... }: { + services.keycloak.plugins = [ self.packages.x86_64-linux.default ]; + }) + ./test-nixos.nix + microvm.nixosModules.microvm + ]; }; + + overlays.default = final: prev: { }; }; } diff --git a/package.nix b/package.nix new file mode 100644 index 0000000..64f6531 --- /dev/null +++ b/package.nix @@ -0,0 +1,18 @@ +{ maven }: +maven.buildMavenPackage { + pname = "keycloak-lists-plugin"; + version = "1.0"; + + src = ./plugin; + + mvnHash = "sha256-UaVCt6KIjR8i3vHVp5YWqu8zzM7mftXyrv5J2jxtw6Q="; + + buildPhase = '' + mvn --offline package; + ''; + + installPhase = '' + mkdir -p $out + install -Dm644 target/*.jar $out + ''; +} diff --git a/plugin/src/main/java/lix/systems/keycloak/AllowBanCheck.java b/plugin/src/main/java/systems/lix/keycloak/AllowBanCheck.java similarity index 91% rename from plugin/src/main/java/lix/systems/keycloak/AllowBanCheck.java rename to plugin/src/main/java/systems/lix/keycloak/AllowBanCheck.java index f206dc8..edac890 100644 --- a/plugin/src/main/java/lix/systems/keycloak/AllowBanCheck.java +++ b/plugin/src/main/java/systems/lix/keycloak/AllowBanCheck.java @@ -1,4 +1,4 @@ -package lix.systems.keycloak; +package systems.lix.keycloak; import jakarta.ws.rs.core.Response; import org.keycloak.authentication.AuthenticationFlowContext; @@ -25,6 +25,9 @@ public class AllowBanCheck implements Authenticator { var attr = context.getUser().getFirstAttribute("githubId"); if (attr == null) { + // We don't think this should be "attempted", because this must be + // a required authenticator, and we want to pass if we don't apply. + context.success(); return; } @@ -69,4 +72,4 @@ public class AllowBanCheck implements Authenticator { public void close() { } -} \ No newline at end of file +} diff --git a/plugin/src/main/java/lix/systems/keycloak/AllowBanCheckFactory.java b/plugin/src/main/java/systems/lix/keycloak/AllowBanCheckFactory.java similarity index 98% rename from plugin/src/main/java/lix/systems/keycloak/AllowBanCheckFactory.java rename to plugin/src/main/java/systems/lix/keycloak/AllowBanCheckFactory.java index 9f34fc2..4890f8b 100644 --- a/plugin/src/main/java/lix/systems/keycloak/AllowBanCheckFactory.java +++ b/plugin/src/main/java/systems/lix/keycloak/AllowBanCheckFactory.java @@ -1,4 +1,4 @@ -package lix.systems.keycloak; +package systems.lix.keycloak; import org.keycloak.Config; import org.keycloak.authentication.Authenticator; diff --git a/plugin/src/main/java/lix/systems/keycloak/AllowBansDB.java b/plugin/src/main/java/systems/lix/keycloak/AllowBansDB.java similarity index 93% rename from plugin/src/main/java/lix/systems/keycloak/AllowBansDB.java rename to plugin/src/main/java/systems/lix/keycloak/AllowBansDB.java index beaa916..b48255f 100644 --- a/plugin/src/main/java/lix/systems/keycloak/AllowBansDB.java +++ b/plugin/src/main/java/systems/lix/keycloak/AllowBansDB.java @@ -1,4 +1,4 @@ -package lix.systems.keycloak; +package systems.lix.keycloak; /** * A database of whether users are allow-listed or banned. diff --git a/plugin/src/main/java/lix/systems/keycloak/FileAllowBansDB.java b/plugin/src/main/java/systems/lix/keycloak/FileAllowBansDB.java similarity index 98% rename from plugin/src/main/java/lix/systems/keycloak/FileAllowBansDB.java rename to plugin/src/main/java/systems/lix/keycloak/FileAllowBansDB.java index 6399eef..ba5831c 100644 --- a/plugin/src/main/java/lix/systems/keycloak/FileAllowBansDB.java +++ b/plugin/src/main/java/systems/lix/keycloak/FileAllowBansDB.java @@ -1,4 +1,4 @@ -package lix.systems.keycloak; +package systems.lix.keycloak; import org.slf4j.Logger; import org.slf4j.LoggerFactory; diff --git a/plugin/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/plugin/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100644 index 0000000..1d8a1e0 --- /dev/null +++ b/plugin/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1 @@ +systems.lix.keycloak.AllowBanCheckFactory diff --git a/test-nixos.nix b/test-nixos.nix new file mode 100644 index 0000000..0d521b4 --- /dev/null +++ b/test-nixos.nix @@ -0,0 +1,156 @@ +{ pkgs, lib, ... }: +let + # Server we're hosting on. + host = "identity.test.lix.systems"; + + # Realm used for services. + realm = "lix-project"; + +in +{ + users.users.root.password = ""; + services.openssh.enable = true; + services.openssh.settings.PermitRootLogin = "yes"; + + services.keycloak = { + enable = true; + + settings = { + hostname = host; + + # Always talk through our reverse proxy. + http-port = 9091; + proxy = "edge"; + }; + + # This will be immediately changed, so no harm in having it here. + initialAdminPassword = "Password1"; + + # Automatically manage our database. + database = { + createLocally = true; + # DO NOT DO THIS IN PROD + passwordFile = builtins.toFile "bad-db-password" "Password1"; + }; + + settings = { + log-level = "INFO"; + spi-authenticator-allow-ban-check-authenticator-dbpath = "/var/keycloak-allow-bans"; + }; + }; + + # Postgres server for the storage backend. + services.postgresql.enable = true; + + # Create a static user, so we can set up our keys beforehand. + # This overrides the dynamic user creation in the base module config. + users.users.keycloak = { + isSystemUser = true; + group = "keycloak"; + }; + users.groups.keycloak = { }; + + # Reverse proxy our data over https. + networking.firewall.allowedTCPPorts = [ 80 443 ]; + services.nginx = { + enable = true; + + virtualHosts = { + "${host}" = { + forceSSL = true; + sslCertificate = "/var/lib/nginx/nc-selfsigned.crt"; + sslCertificateKey = "/var/lib/nginx/nc-selfsigned.key"; + + locations."/" = { + proxyPass = "http://127.0.0.1:9091"; + extraConfig = '' + proxy_ssl_server_name on; + proxy_pass_header Authorization; + proxy_set_header X-Forwarded-For $proxy_protocol_addr; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header Host $host; + + proxy_busy_buffers_size 512k; + proxy_buffers 4 512k; + proxy_buffer_size 256k; + + # Allow clients with Auth hardcoded to use our base path. + # + # XXX: ok so this is horrible. For some reason gerrit explodes if + # it receives a redirect when doing auth. But we need to redirect + # the browser to reuse sessions. Thus, user agent scanning. + if ($http_user_agent ~* "^Java.*$") { + rewrite ^/auth/(.*)$ /$1 last; + } + rewrite ^/auth/(.*)$ /$1 redirect; + + # Hacks to make us compatible with authenticators that expect GitLab's format. + rewrite ^/realms/${realm}/protocol/openid-connect/api/v4/user$ /realms/${realm}/protocol/openid-connect/userinfo; + rewrite ^/realms/${realm}/protocol/openid-connect/oauth/authorize$ /realms/${realm}/protocol/openid-connect/auth?scope=openid%20email%20profile; + rewrite ^/realms/${realm}/protocol/openid-connect/oauth/token$ /realms/${realm}/protocol/openid-connect/token; + ''; + }; + + # Forward our admin address to our default realm. + locations."= /admin".extraConfig = "return 302 https://${host}/admin/lix-project/console/;"; + locations."= /superadmin".extraConfig = "return 302 https://${host}/admin/master/console/;"; + + # Forward our root address to the account management portal. + locations."= /".extraConfig = "return 302 https://${host}/realms/${realm}/account;"; + }; + }; + }; + + systemd.services.cert-setup = { + wantedBy = [ "nginx.service" ]; + before = [ "nginx.service" ]; + serviceConfig = { + ConditionFileExists = "!/var/lib/nginx/nc-selfsigned.crt"; + ExecStart = [ + "${lib.getBin pkgs.openssl}/bin/openssl req -x509 -nodes -days 365 -newkey rsa:2048 -subj /CN=identity.test.lix.systems/ -keyout /var/lib/nginx/nc-selfsigned.key -out /var/lib/nginx/nc-selfsigned.crt" + "${lib.getBin pkgs.coreutils}/bin/chown nginx:nginx /var/lib/nginx/nc-selfsigned.key /var/lib/nginx/nc-selfsigned.crt" + ]; + }; + }; + + systemd.tmpfiles.rules = [ + "d /var/lib/nginx 755 nginx nginx -" + ]; + + microvm = { + hypervisor = "qemu"; + mem = 1024; + interfaces = [{ + type = "user"; + id = "microvm"; + mac = "02:00:00:00:00:01"; + }]; + + forwardPorts = [ + { + from = "host"; + guest.port = 443; + host.port = 4043; + proto = "tcp"; + } + { + from = "host"; + guest.port = 22; + host.port = 2022; + proto = "tcp"; + } + ]; + + volumes = [{ + mountPoint = "/var"; + image = "var.img"; + size = 256; + }]; + shares = [{ + proto = "9p"; + tag = "ro-store"; + source = "/nix/store"; + mountPoint = "/nix/.ro-store"; + }]; + }; +}