This commit is contained in:
jade 2024-03-23 20:30:47 -07:00
parent 015de8232d
commit 6573c8fbdc
10 changed files with 357 additions and 36 deletions

106
README.md Normal file
View file

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

View file

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

View file

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

18
package.nix Normal file
View file

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

View file

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

View file

@ -1,4 +1,4 @@
package lix.systems.keycloak;
package systems.lix.keycloak;
import org.keycloak.Config;
import org.keycloak.authentication.Authenticator;

View file

@ -1,4 +1,4 @@
package lix.systems.keycloak;
package systems.lix.keycloak;
/**
* A database of whether users are allow-listed or banned.

View file

@ -1,4 +1,4 @@
package lix.systems.keycloak;
package systems.lix.keycloak;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

View file

@ -0,0 +1 @@
systems.lix.keycloak.AllowBanCheckFactory

156
test-nixos.nix Normal file
View file

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