diff --git a/apps/.gitignore b/apps/.gitignore new file mode 100644 index 0000000..4c49bd7 --- /dev/null +++ b/apps/.gitignore @@ -0,0 +1 @@ +.env diff --git a/apps/compose.yaml b/apps/compose.yaml index 7d229cc..bacfccd 100644 --- a/apps/compose.yaml +++ b/apps/compose.yaml @@ -6,4 +6,39 @@ services: frontend: image: jhalitaksoy/mancala-frontend:latest ports: - - "3000:3000" \ No newline at end of file + - "3000:3000" + keycloak-db: + image: postgres:17.6 + environment: + POSTGRES_DB: keycloak + POSTGRES_USER: keycloak + POSTGRES_PASSWORD: ${KEYCLOAK_DB_PASSWORD:?set KEYCLOAK_DB_PASSWORD in apps/.env} + volumes: + - keycloak-db:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U keycloak -d keycloak"] + interval: 5s + timeout: 5s + retries: 10 + keycloak: + image: jhalitaksoy/mancala-keycloak:latest + build: ./keycloak + environment: + KC_DB_URL: jdbc:postgresql://keycloak-db:5432/keycloak + KC_DB_USERNAME: keycloak + KC_DB_PASSWORD: ${KEYCLOAK_DB_PASSWORD} + # Public URL of Keycloak; https://auth.playmancala.app in production + KC_HOSTNAME: ${KEYCLOAK_HOSTNAME:-http://localhost:8080} + KC_HTTP_ENABLED: "true" + KC_PROXY_HEADERS: xforwarded + # Only used to create the master admin on the very first start + KC_BOOTSTRAP_ADMIN_USERNAME: ${KEYCLOAK_ADMIN_USERNAME:-admin} + KC_BOOTSTRAP_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD:?set KEYCLOAK_ADMIN_PASSWORD in apps/.env} + ports: + - "127.0.0.1:8080:8080" + depends_on: + keycloak-db: + condition: service_healthy + +volumes: + keycloak-db: diff --git a/apps/keycloak/.dockerignore b/apps/keycloak/.dockerignore new file mode 100644 index 0000000..4b09d5f --- /dev/null +++ b/apps/keycloak/.dockerignore @@ -0,0 +1 @@ +extension/target diff --git a/apps/keycloak/.gitignore b/apps/keycloak/.gitignore new file mode 100644 index 0000000..b7de373 --- /dev/null +++ b/apps/keycloak/.gitignore @@ -0,0 +1 @@ +extension/target/ diff --git a/apps/keycloak/Dockerfile b/apps/keycloak/Dockerfile new file mode 100644 index 0000000..e3d9541 --- /dev/null +++ b/apps/keycloak/Dockerfile @@ -0,0 +1,22 @@ +# Keycloak with the mancala extension and realm baked in. +# Build context: apps/keycloak + +FROM maven:3.9.11-eclipse-temurin-21 AS extension +WORKDIR /build +COPY extension/pom.xml . +COPY extension/src src +RUN mvn -B -q package -Dmaven.test.skip=true + +# Keep the Keycloak version in sync with keycloak.version in extension/pom.xml +FROM quay.io/keycloak/keycloak:26.7.5 AS builder +ENV KC_DB=postgres +ENV KC_HEALTH_ENABLED=true +COPY --from=extension /build/target/mancala-keycloak-extension.jar /opt/keycloak/providers/ +RUN /opt/keycloak/bin/kc.sh build + +FROM quay.io/keycloak/keycloak:26.7.5 +COPY --from=builder /opt/keycloak/ /opt/keycloak/ +# Imported on startup only if the realm does not exist yet. +COPY realm/mancala-realm.json /opt/keycloak/data/import/ +ENTRYPOINT ["/opt/keycloak/bin/kc.sh"] +CMD ["start", "--optimized", "--import-realm"] diff --git a/apps/keycloak/README.md b/apps/keycloak/README.md new file mode 100644 index 0000000..b4377e2 --- /dev/null +++ b/apps/keycloak/README.md @@ -0,0 +1,62 @@ +# Mancala Keycloak + +Keycloak is the only auth system for Mancala. Players can either log in with an account or play as a guest, +and both come back as normal Keycloak tokens from the `mancala` realm. + +## Guest login + +`extension/` adds a `mancala-guest-authenticator` to Keycloak. When an authorization request has `guest=true`, +it creates a new user named `guest-xxxxxxxx`, gives it the `guest` realm role and logs it in without showing a +form. Requests without the parameter fall through to the normal login form. + +``` +/realms/mancala/protocol/openid-connect/auth?client_id=mancala-web&response_type=code + &scope=openid%20offline_access&redirect_uri=...&code_challenge=...&code_challenge_method=S256 + &guest=true +``` + +- Clients can tell guests apart by `"guest"` in the access token's `realm_access.roles`. +- Guests should request `offline_access` so they keep their identity after the SSO session ends + (offline sessions expire after 30 days without use). +- A scheduled task deletes guests that are older than a day and have no session or offline session left. + Its timing can be changed with server options such as + `KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_INTERVAL_SECONDS` (default 3600), + `..._CLEANUP_MIN_AGE_SECONDS` (default 86400) and `..._CLEANUP_MAX_DELETES_PER_RUN` (default 1000). +- Each guest login creates a user, so the auth endpoint is rate limited per IP in `config/nginx.conf`. + +## Realm + +`realm/mancala-realm.json` holds the realm: the `mancala-web` public client (PKCE S256), the +`browser with guest` flow, the `guest` role, and the required actions with "Verify Profile" turned off so guests +are not asked for an email or a name. + +Keycloak imports the file only if the realm does not exist yet. After changing it, apply the change in the admin +console too, or delete the realm and restart Keycloak. + +## Running + +```sh +cd apps +cat > .env < + + 4.0.0 + + app.playmancala + mancala-keycloak-extension + 0.1.0 + jar + Mancala Keycloak Extension + Guest login authenticator and guest cleanup task for the mancala realm + + + 21 + UTF-8 + + 26.7.5 + 4.3.1 + 5.13.4 + + + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + provided + + + org.keycloak + keycloak-server-spi-private + ${keycloak.version} + provided + + + org.keycloak + keycloak-services + ${keycloak.version} + provided + + + + com.github.dasniko + testcontainers-keycloak + ${testcontainers-keycloak.version} + test + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + mancala-keycloak-extension + + + src/test/resources + + + ../realm + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.3 + + + + diff --git a/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticator.java b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticator.java new file mode 100644 index 0000000..8644a51 --- /dev/null +++ b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticator.java @@ -0,0 +1,98 @@ +package app.playmancala.keycloak.guest; + +import java.security.SecureRandom; +import java.util.HexFormat; + +import org.jboss.logging.Logger; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.Authenticator; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserModel; +import org.keycloak.protocol.oidc.endpoints.AuthorizationEndpoint; + +/** + * Logs the user in as a freshly created guest when the authorization request carries {@code guest=true}. + * Any other request is passed on ({@code attempted}) so the next alternative, normally the login form, runs. + * Guests are marked with the {@value #GUEST_ROLE} realm role, which ends up in the token's realm_access.roles. + */ +public class GuestAuthenticator implements Authenticator { + + public static final String GUEST_PARAM = "guest"; + public static final String GUEST_ROLE = "guest"; + public static final String USERNAME_PREFIX = "guest-"; + + private static final Logger LOG = Logger.getLogger(GuestAuthenticator.class); + private static final SecureRandom RANDOM = new SecureRandom(); + private static final int MAX_USERNAME_ATTEMPTS = 5; + + @Override + public void authenticate(AuthenticationFlowContext context) { + String guestParam = context.getAuthenticationSession().getClientNote( + AuthorizationEndpoint.LOGIN_SESSION_NOTE_ADDITIONAL_REQ_PARAMS_PREFIX + GUEST_PARAM); + if (!"true".equals(guestParam)) { + context.attempted(); + return; + } + + KeycloakSession session = context.getSession(); + RealmModel realm = context.getRealm(); + RoleModel guestRole = realm.getRole(GUEST_ROLE); + if (guestRole == null) { + LOG.errorf("Realm '%s' has no '%s' role, guest login is disabled", realm.getName(), GUEST_ROLE); + context.attempted(); + return; + } + + String username = generateUsername(session, realm); + if (username == null) { + LOG.errorf("Could not find a free guest username in realm '%s'", realm.getName()); + context.attempted(); + return; + } + + UserModel user = session.users().addUser(realm, username); + user.setEnabled(true); + user.grantRole(guestRole); + LOG.debugf("Created guest user '%s' in realm '%s'", username, realm.getName()); + + context.setUser(user); + context.success(); + } + + private String generateUsername(KeycloakSession session, RealmModel realm) { + for (int i = 0; i < MAX_USERNAME_ATTEMPTS; i++) { + byte[] bytes = new byte[4]; + RANDOM.nextBytes(bytes); + String username = USERNAME_PREFIX + HexFormat.of().formatHex(bytes); + if (session.users().getUserByUsername(realm, username) == null) { + return username; + } + } + return null; + } + + @Override + public void action(AuthenticationFlowContext context) { + // No form, nothing to submit. + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) { + } + + @Override + public void close() { + } +} diff --git a/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticatorFactory.java b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticatorFactory.java new file mode 100644 index 0000000..39a054b --- /dev/null +++ b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestAuthenticatorFactory.java @@ -0,0 +1,98 @@ +package app.playmancala.keycloak.guest; + +import java.time.Duration; +import java.util.List; + +import org.keycloak.Config; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.utils.KeycloakModelUtils; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.timer.TimerProvider; + +/** + * Registers {@link GuestAuthenticator} and schedules {@link GuestCleanupTask}. + * + * Cleanup can be tuned with server options, e.g. + * {@code --spi-authenticator--mancala-guest-authenticator--cleanup-interval-seconds=3600}. + */ +public class GuestAuthenticatorFactory implements AuthenticatorFactory { + + public static final String PROVIDER_ID = "mancala-guest-authenticator"; + + private static final GuestAuthenticator SINGLETON = new GuestAuthenticator(); + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENT_CHOICES = { + AuthenticationExecutionModel.Requirement.ALTERNATIVE, + AuthenticationExecutionModel.Requirement.DISABLED + }; + + private long cleanupIntervalMillis; + private long cleanupMinAgeMillis; + private int cleanupMaxDeletesPerRun; + + @Override + public void init(Config.Scope config) { + cleanupIntervalMillis = Duration.ofSeconds(config.getLong("cleanup-interval-seconds", 3600L)).toMillis(); + cleanupMinAgeMillis = Duration.ofSeconds(config.getLong("cleanup-min-age-seconds", 86400L)).toMillis(); + cleanupMaxDeletesPerRun = config.getInt("cleanup-max-deletes-per-run", 1000); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { + GuestCleanupTask task = new GuestCleanupTask(cleanupMinAgeMillis, cleanupMaxDeletesPerRun); + KeycloakModelUtils.runJobInTransaction(factory, session -> + session.getProvider(TimerProvider.class).scheduleTask(task, cleanupIntervalMillis, task.getTaskName())); + } + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getDisplayType() { + return "Mancala guest login"; + } + + @Override + public String getReferenceCategory() { + return "guest"; + } + + @Override + public String getHelpText() { + return "Creates and logs in a new guest user when the authorization request contains guest=true."; + } + + @Override + public boolean isConfigurable() { + return false; + } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public List getConfigProperties() { + return List.of(); + } + + @Override + public void close() { + } +} diff --git a/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestCleanupTask.java b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestCleanupTask.java new file mode 100644 index 0000000..ef18898 --- /dev/null +++ b/apps/keycloak/extension/src/main/java/app/playmancala/keycloak/guest/GuestCleanupTask.java @@ -0,0 +1,73 @@ +package app.playmancala.keycloak.guest; + +import java.util.ArrayList; +import java.util.List; + +import org.jboss.logging.Logger; +import org.keycloak.common.util.Time; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.RoleModel; +import org.keycloak.models.UserManager; +import org.keycloak.models.UserModel; +import org.keycloak.timer.ScheduledTask; + +/** + * Deletes guest users that are older than the minimum age and have neither a regular nor an offline session left. + * Offline sessions expire after the realm's offline session idle timeout, so a guest who stops playing is removed + * roughly that long after their last visit. + */ +public class GuestCleanupTask implements ScheduledTask { + + private static final Logger LOG = Logger.getLogger(GuestCleanupTask.class); + private static final int PAGE_SIZE = 200; + + private final long minAgeMillis; + private final int maxDeletesPerRun; + + public GuestCleanupTask(long minAgeMillis, int maxDeletesPerRun) { + this.minAgeMillis = minAgeMillis; + this.maxDeletesPerRun = maxDeletesPerRun; + } + + @Override + public void run(KeycloakSession session) { + long createdBefore = Time.currentTimeMillis() - minAgeMillis; + session.realms().getRealmsStream().forEach(realm -> cleanupRealm(session, realm, createdBefore)); + } + + private void cleanupRealm(KeycloakSession session, RealmModel realm, long createdBefore) { + RoleModel guestRole = realm.getRole(GuestAuthenticator.GUEST_ROLE); + if (guestRole == null) return; + + // User and session queries require the session to be bound to the realm they run in. + session.getContext().setRealm(realm); + List staleGuests = new ArrayList<>(); + for (int first = 0; staleGuests.size() < maxDeletesPerRun; first += PAGE_SIZE) { + List page = session.users().getRoleMembersStream(realm, guestRole, first, PAGE_SIZE).toList(); + if (page.isEmpty()) break; + page.stream() + .filter(user -> isStale(session, realm, user, createdBefore)) + .limit(maxDeletesPerRun - staleGuests.size()) + .forEach(staleGuests::add); + } + + UserManager userManager = new UserManager(session); + staleGuests.forEach(user -> userManager.removeUser(realm, user)); + if (!staleGuests.isEmpty()) { + LOG.infof("Removed %d inactive guest users from realm '%s'", staleGuests.size(), realm.getName()); + } + } + + private boolean isStale(KeycloakSession session, RealmModel realm, UserModel user, long createdBefore) { + Long created = user.getCreatedTimestamp(); + if (created == null || created > createdBefore) return false; + return session.sessions().getUserSessionsStream(realm, user).findAny().isEmpty() + && session.sessions().getOfflineUserSessionsStream(realm, user).findAny().isEmpty(); + } + + @Override + public String getTaskName() { + return "mancala-guest-cleanup"; + } +} diff --git a/apps/keycloak/extension/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory b/apps/keycloak/extension/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory new file mode 100644 index 0000000..6274ae9 --- /dev/null +++ b/apps/keycloak/extension/src/main/resources/META-INF/services/org.keycloak.authentication.AuthenticatorFactory @@ -0,0 +1 @@ +app.playmancala.keycloak.guest.GuestAuthenticatorFactory diff --git a/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java b/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java new file mode 100644 index 0000000..115c46b --- /dev/null +++ b/apps/keycloak/extension/src/test/java/app/playmancala/keycloak/guest/GuestLoginTest.java @@ -0,0 +1,205 @@ +package app.playmancala.keycloak.guest; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.net.CookieManager; +import java.net.URI; +import java.net.URLEncoder; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.time.Duration; +import java.util.Base64; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import java.util.stream.Collectors; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.keycloak.admin.client.resource.RealmResource; +import org.keycloak.representations.idm.UserRepresentation; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; + +import dasniko.testcontainers.keycloak.KeycloakContainer; + +/** + * Runs the extension and the real realm export in a Keycloak container. Needs Docker. + */ +class GuestLoginTest { + + private static final String REALM = "mancala"; + private static final String CLIENT_ID = "mancala-web"; + private static final String REDIRECT_URI = "http://localhost:1234/auth/callback"; + private static final ObjectMapper JSON = new ObjectMapper(); + + private static KeycloakContainer keycloak; + + @BeforeAll + static void startKeycloak() { + keycloak = new KeycloakContainer("quay.io/keycloak/keycloak:26.7.5") + .withProviderClassesFrom("target/classes") + .withRealmImportFile("/mancala-realm.json") + .withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_INTERVAL_SECONDS", "2") + .withEnv("KC_SPI_AUTHENTICATOR__MANCALA_GUEST_AUTHENTICATOR__CLEANUP_MIN_AGE_SECONDS", "0"); + keycloak.start(); + } + + @AfterAll + static void stopKeycloak() { + if (keycloak != null) keycloak.stop(); + } + + @Test + void guestRequestLogsInAsNewGuestWithoutShowingAForm() throws Exception { + JsonNode tokens = loginAsGuest(); + JsonNode accessToken = decodeJwt(tokens.get("access_token").asText()); + + assertTrue(accessToken.get("preferred_username").asText().startsWith(GuestAuthenticator.USERNAME_PREFIX)); + assertTrue(realmRoles(accessToken).contains(GuestAuthenticator.GUEST_ROLE)); + assertEquals("Offline", decodeJwt(tokens.get("refresh_token").asText()).get("typ").asText()); + } + + @Test + void eachGuestLoginCreatesADifferentUser() throws Exception { + String first = decodeJwt(loginAsGuest().get("access_token").asText()).get("sub").asText(); + String second = decodeJwt(loginAsGuest().get("access_token").asText()).get("sub").asText(); + assertNotEquals(first, second); + } + + @Test + void requestWithoutGuestParamShowsLoginForm() throws Exception { + HttpResponse response = newHttpClient().send( + HttpRequest.newBuilder(authorizationUri(Map.of(), randomUrlSafe(32))).GET().build(), + HttpResponse.BodyHandlers.ofString()); + + assertEquals(200, response.statusCode()); + assertTrue(response.body().contains("id=\"kc-form-login\"")); + } + + @Test + void cleanupRemovesGuestsWithoutSessionsAndKeepsEveryoneElse() throws Exception { + String activeGuest = decodeJwt(loginAsGuest().get("access_token").asText()).get("preferred_username").asText(); + String idleGuest = createUser("guest-idle-" + UUID.randomUUID(), true); + String regularUser = createUser("player-" + UUID.randomUUID(), false); + + RealmResource realm = keycloak.getKeycloakAdminClient().realm(REALM); + long deadline = System.currentTimeMillis() + 20_000; + while (!realm.users().searchByUsername(idleGuest, true).isEmpty()) { + assertTrue(System.currentTimeMillis() < deadline, "idle guest was not cleaned up"); + Thread.sleep(500); + } + + assertFalse(realm.users().searchByUsername(activeGuest, true).isEmpty(), "guest with a session was removed"); + assertFalse(realm.users().searchByUsername(regularUser, true).isEmpty(), "regular user was removed"); + } + + private static String createUser(String username, boolean guest) { + RealmResource realm = keycloak.getKeycloakAdminClient().realm(REALM); + UserRepresentation user = new UserRepresentation(); + user.setUsername(username); + user.setEnabled(true); + realm.users().create(user).close(); + if (guest) { + String id = realm.users().searchByUsername(username, true).get(0).getId(); + realm.users().get(id).roles().realmLevel() + .add(List.of(realm.roles().get(GuestAuthenticator.GUEST_ROLE).toRepresentation())); + } + return username; + } + + /** Runs the authorization code flow with PKCE, as the web client will, and returns the token response. */ + private static JsonNode loginAsGuest() throws Exception { + HttpClient http = newHttpClient(); + String verifier = randomUrlSafe(32); + String challenge = Base64.getUrlEncoder().withoutPadding().encodeToString( + MessageDigest.getInstance("SHA-256").digest(verifier.getBytes(StandardCharsets.US_ASCII))); + + HttpResponse authResponse = http.send( + HttpRequest.newBuilder(authorizationUri(Map.of("guest", "true"), challenge)).GET().build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(302, authResponse.statusCode(), "expected redirect back to the client, got: " + authResponse.body()); + + String location = authResponse.headers().firstValue("Location").orElseThrow(); + assertTrue(location.startsWith(REDIRECT_URI), location); + String code = queryParams(URI.create(location)).get("code"); + assertNotNull(code, location); + + HttpResponse tokenResponse = http.send( + HttpRequest.newBuilder(URI.create(realmUrl() + "/protocol/openid-connect/token")) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(form(Map.of( + "grant_type", "authorization_code", + "client_id", CLIENT_ID, + "redirect_uri", REDIRECT_URI, + "code", code, + "code_verifier", verifier)))) + .build(), + HttpResponse.BodyHandlers.ofString()); + assertEquals(200, tokenResponse.statusCode(), tokenResponse.body()); + return JSON.readTree(tokenResponse.body()); + } + + private static URI authorizationUri(Map extraParams, String codeChallenge) { + Map params = new java.util.HashMap<>(Map.of( + "client_id", CLIENT_ID, + "response_type", "code", + "redirect_uri", REDIRECT_URI, + "scope", "openid offline_access", + "state", randomUrlSafe(8), + "code_challenge", codeChallenge, + "code_challenge_method", "S256")); + params.putAll(extraParams); + return URI.create(realmUrl() + "/protocol/openid-connect/auth?" + form(params)); + } + + private static String realmUrl() { + return keycloak.getAuthServerUrl() + "/realms/" + REALM; + } + + private static HttpClient newHttpClient() { + return HttpClient.newBuilder() + .followRedirects(HttpClient.Redirect.NEVER) + .cookieHandler(new CookieManager()) + .connectTimeout(Duration.ofSeconds(10)) + .build(); + } + + private static JsonNode decodeJwt(String jwt) throws Exception { + return JSON.readTree(Base64.getUrlDecoder().decode(jwt.split("\\.")[1])); + } + + private static List realmRoles(JsonNode accessToken) { + List roles = new java.util.ArrayList<>(); + accessToken.path("realm_access").path("roles").forEach(role -> roles.add(role.asText())); + return roles; + } + + private static Map queryParams(URI uri) { + return java.util.Arrays.stream(uri.getRawQuery().split("&")) + .map(pair -> pair.split("=", 2)) + .collect(Collectors.toMap(p -> p[0], p -> java.net.URLDecoder.decode(p[1], StandardCharsets.UTF_8))); + } + + private static String form(Map params) { + return params.entrySet().stream() + .map(e -> e.getKey() + "=" + URLEncoder.encode(e.getValue(), StandardCharsets.UTF_8)) + .collect(Collectors.joining("&")); + } + + private static String randomUrlSafe(int bytes) { + byte[] buffer = new byte[bytes]; + new SecureRandom().nextBytes(buffer); + return Base64.getUrlEncoder().withoutPadding().encodeToString(buffer); + } +} diff --git a/apps/keycloak/realm/mancala-realm.json b/apps/keycloak/realm/mancala-realm.json new file mode 100644 index 0000000..38007f4 --- /dev/null +++ b/apps/keycloak/realm/mancala-realm.json @@ -0,0 +1,226 @@ +{ + "realm": "mancala", + "displayName": "Mancala", + "enabled": true, + "sslRequired": "external", + "registrationAllowed": true, + "registrationEmailAsUsername": false, + "loginWithEmailAllowed": true, + "duplicateEmailsAllowed": false, + "resetPasswordAllowed": true, + "rememberMe": true, + "verifyEmail": false, + "bruteForceProtected": true, + "offlineSessionIdleTimeout": 2592000, + "browserFlow": "browser with guest", + "requiredActions": [ + { + "alias": "TERMS_AND_CONDITIONS", + "name": "Terms and Conditions", + "providerId": "TERMS_AND_CONDITIONS", + "enabled": false, + "defaultAction": false, + "priority": 20, + "config": {} + }, + { + "alias": "UPDATE_PROFILE", + "name": "Update Profile", + "providerId": "UPDATE_PROFILE", + "enabled": true, + "defaultAction": false, + "priority": 40, + "config": {} + }, + { + "alias": "VERIFY_EMAIL", + "name": "Verify Email", + "providerId": "VERIFY_EMAIL", + "enabled": true, + "defaultAction": false, + "priority": 50, + "config": {} + }, + { + "alias": "CONFIGURE_TOTP", + "name": "Configure OTP", + "providerId": "CONFIGURE_TOTP", + "enabled": true, + "defaultAction": false, + "priority": 54, + "config": {} + }, + { + "alias": "UPDATE_PASSWORD", + "name": "Update Password", + "providerId": "UPDATE_PASSWORD", + "enabled": true, + "defaultAction": false, + "priority": 57, + "config": {} + }, + { + "alias": "delete_account", + "name": "Delete Account", + "providerId": "delete_account", + "enabled": false, + "defaultAction": false, + "priority": 60, + "config": {} + }, + { + "alias": "UPDATE_EMAIL", + "name": "Update Email", + "providerId": "UPDATE_EMAIL", + "enabled": false, + "defaultAction": false, + "priority": 70, + "config": {} + }, + { + "alias": "webauthn-register", + "name": "Webauthn Register", + "providerId": "webauthn-register", + "enabled": true, + "defaultAction": false, + "priority": 80, + "config": {} + }, + { + "alias": "webauthn-register-passwordless", + "name": "Webauthn Register Passwordless", + "providerId": "webauthn-register-passwordless", + "enabled": true, + "defaultAction": false, + "priority": 90, + "config": {} + }, + { + "alias": "VERIFY_PROFILE", + "name": "Verify Profile", + "providerId": "VERIFY_PROFILE", + "enabled": false, + "defaultAction": false, + "priority": 100, + "config": {} + }, + { + "alias": "delete_credential", + "name": "Delete Credential", + "providerId": "delete_credential", + "enabled": true, + "defaultAction": false, + "priority": 110, + "config": {} + }, + { + "alias": "idp_link", + "name": "Linking Identity Provider", + "providerId": "idp_link", + "enabled": true, + "defaultAction": false, + "priority": 120, + "config": {} + }, + { + "alias": "CONFIGURE_RECOVERY_AUTHN_CODES", + "name": "Recovery Authentication Codes", + "providerId": "CONFIGURE_RECOVERY_AUTHN_CODES", + "enabled": true, + "defaultAction": false, + "priority": 130, + "config": {} + }, + { + "alias": "update_user_locale", + "name": "Update User Locale", + "providerId": "update_user_locale", + "enabled": true, + "defaultAction": false, + "priority": 1000, + "config": {} + } + ], + "roles": { + "realm": [ + { + "name": "guest", + "description": "Anonymous player created by the mancala guest authenticator" + } + ] + }, + "clients": [ + { + "clientId": "mancala-web", + "name": "Mancala Web", + "enabled": true, + "publicClient": true, + "protocol": "openid-connect", + "standardFlowEnabled": true, + "implicitFlowEnabled": false, + "directAccessGrantsEnabled": false, + "serviceAccountsEnabled": false, + "frontchannelLogout": true, + "redirectUris": [ + "http://localhost:1234/*", + "https://playmancala.app/*" + ], + "webOrigins": [ + "http://localhost:1234", + "https://playmancala.app" + ], + "attributes": { + "pkce.code.challenge.method": "S256", + "post.logout.redirect.uris": "http://localhost:1234/*##https://playmancala.app/*" + } + } + ], + "authenticationFlows": [ + { + "alias": "browser with guest", + "description": "Browser flow that can log in as a new guest when the request has guest=true", + "providerId": "basic-flow", + "topLevel": true, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "auth-cookie", + "requirement": "ALTERNATIVE", + "priority": 10, + "authenticatorFlow": false, + "userSetupAllowed": false + }, + { + "authenticator": "mancala-guest-authenticator", + "requirement": "ALTERNATIVE", + "priority": 20, + "authenticatorFlow": false, + "userSetupAllowed": false + }, + { + "flowAlias": "browser with guest forms", + "requirement": "ALTERNATIVE", + "priority": 30, + "authenticatorFlow": true, + "userSetupAllowed": false + } + ] + }, + { + "alias": "browser with guest forms", + "description": "Username and password form", + "providerId": "basic-flow", + "topLevel": false, + "builtIn": false, + "authenticationExecutions": [ + { + "authenticator": "auth-username-password-form", + "requirement": "REQUIRED", + "priority": 10, + "authenticatorFlow": false, + "userSetupAllowed": false + } + ] + } + ] +} diff --git a/config/nginx.conf b/config/nginx.conf index 02f14a1..44713a8 100644 --- a/config/nginx.conf +++ b/config/nginx.conf @@ -1,3 +1,5 @@ +limit_req_zone $binary_remote_addr zone=keycloak_auth:10m rate=10r/m; + server { listen 80 default_server; listen [::]:80 default_server; @@ -86,9 +88,27 @@ server { index index.html index.htm index.nginx-debian.html; server_name auth.playmancala.app; # managed by Certbot + # Keycloak sends large cookies and headers. + proxy_buffer_size 128k; + proxy_buffers 4 256k; + proxy_busy_buffers_size 256k; + + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_set_header X-Forwarded-Host $host; + proxy_set_header X-Forwarded-Port $server_port; + + # Every guest login creates a Keycloak user, so limit how often one IP can start a login. + location = /realms/mancala/protocol/openid-connect/auth { + limit_req zone=keycloak_auth burst=20 nodelay; + limit_req_status 429; + proxy_pass http://localhost:8080; + } location / { - try_files $uri $uri/ =404; + proxy_pass http://localhost:8080; } listen [::]:443 ssl; # managed by Certbot