Release: Quarkus backend, Keycloak login, game history, clock, bot, tutorial and rematch #3
1
apps/.gitignore
vendored
Normal file
1
apps/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
.env
|
||||||
@ -7,3 +7,38 @@ services:
|
|||||||
image: jhalitaksoy/mancala-frontend:latest
|
image: jhalitaksoy/mancala-frontend:latest
|
||||||
ports:
|
ports:
|
||||||
- "3000:3000"
|
- "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:
|
||||||
|
|||||||
1
apps/keycloak/.dockerignore
Normal file
1
apps/keycloak/.dockerignore
Normal file
@ -0,0 +1 @@
|
|||||||
|
extension/target
|
||||||
1
apps/keycloak/.gitignore
vendored
Normal file
1
apps/keycloak/.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
extension/target/
|
||||||
22
apps/keycloak/Dockerfile
Normal file
22
apps/keycloak/Dockerfile
Normal file
@ -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"]
|
||||||
62
apps/keycloak/README.md
Normal file
62
apps/keycloak/README.md
Normal file
@ -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 <<EOF
|
||||||
|
KEYCLOAK_DB_PASSWORD=change-me
|
||||||
|
KEYCLOAK_ADMIN_PASSWORD=change-me
|
||||||
|
EOF
|
||||||
|
docker compose up -d --build keycloak
|
||||||
|
```
|
||||||
|
|
||||||
|
Keycloak is then at http://localhost:8080 and the admin console at http://localhost:8080/admin.
|
||||||
|
In production set `KEYCLOAK_HOSTNAME=https://auth.playmancala.app`; nginx proxies that host to port 8080.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cd apps/keycloak/extension
|
||||||
|
mvn test
|
||||||
|
```
|
||||||
|
|
||||||
|
The tests start Keycloak with the extension and the real realm file in a container, so they need Docker.
|
||||||
|
|
||||||
|
## Upgrading Keycloak
|
||||||
|
|
||||||
|
The extension uses Keycloak's authenticator SPI, which Keycloak marks as internal. When upgrading, change the
|
||||||
|
version in `extension/pom.xml` and in `Dockerfile` together and run the tests.
|
||||||
75
apps/keycloak/extension/pom.xml
Normal file
75
apps/keycloak/extension/pom.xml
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<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/xsd/maven-4.0.0.xsd">
|
||||||
|
<modelVersion>4.0.0</modelVersion>
|
||||||
|
|
||||||
|
<groupId>app.playmancala</groupId>
|
||||||
|
<artifactId>mancala-keycloak-extension</artifactId>
|
||||||
|
<version>0.1.0</version>
|
||||||
|
<packaging>jar</packaging>
|
||||||
|
<name>Mancala Keycloak Extension</name>
|
||||||
|
<description>Guest login authenticator and guest cleanup task for the mancala realm</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<maven.compiler.release>21</maven.compiler.release>
|
||||||
|
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
|
||||||
|
<!-- Keep in sync with the image tag in ../Dockerfile -->
|
||||||
|
<keycloak.version>26.7.5</keycloak.version>
|
||||||
|
<testcontainers-keycloak.version>4.3.1</testcontainers-keycloak.version>
|
||||||
|
<junit.version>5.13.4</junit.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-server-spi</artifactId>
|
||||||
|
<version>${keycloak.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-server-spi-private</artifactId>
|
||||||
|
<version>${keycloak.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.keycloak</groupId>
|
||||||
|
<artifactId>keycloak-services</artifactId>
|
||||||
|
<version>${keycloak.version}</version>
|
||||||
|
<scope>provided</scope>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.github.dasniko</groupId>
|
||||||
|
<artifactId>testcontainers-keycloak</artifactId>
|
||||||
|
<version>${testcontainers-keycloak.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.junit.jupiter</groupId>
|
||||||
|
<artifactId>junit-jupiter</artifactId>
|
||||||
|
<version>${junit.version}</version>
|
||||||
|
<scope>test</scope>
|
||||||
|
</dependency>
|
||||||
|
</dependencies>
|
||||||
|
|
||||||
|
<build>
|
||||||
|
<finalName>mancala-keycloak-extension</finalName>
|
||||||
|
<testResources>
|
||||||
|
<testResource>
|
||||||
|
<directory>src/test/resources</directory>
|
||||||
|
</testResource>
|
||||||
|
<testResource>
|
||||||
|
<directory>../realm</directory>
|
||||||
|
</testResource>
|
||||||
|
</testResources>
|
||||||
|
<plugins>
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-surefire-plugin</artifactId>
|
||||||
|
<version>3.5.3</version>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
</project>
|
||||||
@ -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() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<ProviderConfigProperty> getConfigProperties() {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void close() {
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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<UserModel> staleGuests = new ArrayList<>();
|
||||||
|
for (int first = 0; staleGuests.size() < maxDeletesPerRun; first += PAGE_SIZE) {
|
||||||
|
List<UserModel> 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";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1 @@
|
|||||||
|
app.playmancala.keycloak.guest.GuestAuthenticatorFactory
|
||||||
@ -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<String> 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<String> 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<String> 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<String, String> extraParams, String codeChallenge) {
|
||||||
|
Map<String, String> 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<String> realmRoles(JsonNode accessToken) {
|
||||||
|
List<String> roles = new java.util.ArrayList<>();
|
||||||
|
accessToken.path("realm_access").path("roles").forEach(role -> roles.add(role.asText()));
|
||||||
|
return roles;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static Map<String, String> 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<String, String> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
226
apps/keycloak/realm/mancala-realm.json
Normal file
226
apps/keycloak/realm/mancala-realm.json
Normal file
@ -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
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@ -1,3 +1,5 @@
|
|||||||
|
limit_req_zone $binary_remote_addr zone=keycloak_auth:10m rate=10r/m;
|
||||||
|
|
||||||
server {
|
server {
|
||||||
listen 80 default_server;
|
listen 80 default_server;
|
||||||
listen [::]:80 default_server;
|
listen [::]:80 default_server;
|
||||||
@ -86,9 +88,27 @@ server {
|
|||||||
index index.html index.htm index.nginx-debian.html;
|
index index.html index.htm index.nginx-debian.html;
|
||||||
server_name auth.playmancala.app; # managed by Certbot
|
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 / {
|
location / {
|
||||||
try_files $uri $uri/ =404;
|
proxy_pass http://localhost:8080;
|
||||||
}
|
}
|
||||||
|
|
||||||
listen [::]:443 ssl; # managed by Certbot
|
listen [::]:443 ssl; # managed by Certbot
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user