From 65bdd4524473fa2ba18558b745334f64707ba262 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 20 May 2026 08:14:03 +0000 Subject: [PATCH 1/3] add captcha + privacy on signup, profile picture upload, deployment docs Frontend (Keycloakify theme): - Register.tsx now renders a Cloudflare Turnstile widget and a privacy policy checkbox linking to https://helpwave.de/privacy. Submission is blocked until both are satisfied. - AccountSettings.tsx replaces the "coming soon" placeholder with a real profile picture upload (and remove) flow that talks to the new SPI. - Adds translation keys for the captcha + privacy + picture UI in en-US and de-DE. - Extends KcContext with turnstileSiteKey (register) and profilePictureApiUrl / profilePictureUrl (account); exposes TURNSTILE_SITE_KEY and PROFILE_PICTURE_API_URL as Keycloakify env vars. Backend (new keycloak-extensions/ Maven multi-module): - helpwave-turnstile-authenticator: FormAction SPI that renders the Turnstile widget server-side and verifies the token against Cloudflare's siteverify endpoint. Site key + secret are configured per registration flow in the Keycloak admin console. - helpwave-privacy-acceptance: FormAction SPI that requires the privacy checkbox and stores acceptance metadata on the user as privacy_policy_accepted / privacy_policy_accepted_at / privacy_policy_version attributes. - helpwave-profile-picture: RealmResource SPI exposing /realms/{realm}/helpwave-picture. Authenticates with the standard bearer token, scales uploads to 512/256/128/64 px square JPEGs with Thumbnailator and stores them in any S3-compatible bucket (AWS S3 or Cloudflare R2) via AWS SDK v2. Primary URL is written to the standard OIDC `picture` attribute; thumbnails to picture_thumb_*. Deployment: - README rewritten with build instructions, jar inventory, admin-console steps for the form actions and full storage configuration matrix (SPI keys + env vars) for R2 and AWS S3 setups. - CI workflow now builds the Maven modules in addition to the theme, runs lint + typecheck, and attaches all four jars to the release. Version bumped to 0.2.0 to trigger publish. --- .github/workflows/ci.yaml | 28 ++- .gitignore | 4 + README.md | 133 +++++++++++++- keycloak-extensions/pom.xml | 92 ++++++++++ .../privacy-acceptance/pom.xml | 39 ++++ .../privacy/PrivacyAcceptanceFormAction.java | 89 ++++++++++ .../PrivacyAcceptanceFormActionFactory.java | 77 ++++++++ ....keycloak.authentication.FormActionFactory | 1 + keycloak-extensions/profile-picture/pom.xml | 95 ++++++++++ .../keycloak/picture/ImageProcessor.java | 51 ++++++ .../keycloak/picture/MultipartParser.java | 72 ++++++++ .../keycloak/picture/PictureConfig.java | 57 ++++++ .../picture/ProfilePictureResource.java | 166 ++++++++++++++++++ .../ProfilePictureResourceProvider.java | 25 +++ ...ProfilePictureResourceProviderFactory.java | 44 +++++ .../helpwave/keycloak/picture/S3Storage.java | 57 ++++++ ...ices.resource.RealmResourceProviderFactory | 1 + .../turnstile-authenticator/pom.xml | 43 +++++ .../turnstile/TurnstileFormAction.java | 130 ++++++++++++++ .../turnstile/TurnstileFormActionFactory.java | 76 ++++++++ ....keycloak.authentication.FormActionFactory | 1 + locales/de-DE.arb | 48 +++++ locales/en-US.arb | 48 +++++ package.json | 2 +- src/account/KcContext.ts | 8 +- src/account/KcPageStory.tsx | 7 +- src/account/pages/AccountSettings.tsx | 125 ++++++++++++- src/i18n/translations.ts | 36 ++++ src/kc.gen.tsx | 9 +- src/login/KcContext.ts | 7 +- src/login/KcPageStory.tsx | 6 +- src/login/pages/Register.tsx | 129 +++++++++++++- 32 files changed, 1684 insertions(+), 22 deletions(-) create mode 100644 keycloak-extensions/pom.xml create mode 100644 keycloak-extensions/privacy-acceptance/pom.xml create mode 100644 keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java create mode 100644 keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java create mode 100644 keycloak-extensions/privacy-acceptance/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory create mode 100644 keycloak-extensions/profile-picture/pom.xml create mode 100644 keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java create mode 100644 keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java create mode 100644 keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java create mode 100644 keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java create mode 100644 keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java create mode 100644 keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java create mode 100644 keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java create mode 100644 keycloak-extensions/profile-picture/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory create mode 100644 keycloak-extensions/turnstile-authenticator/pom.xml create mode 100644 keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormAction.java create mode 100644 keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormActionFactory.java create mode 100644 keycloak-extensions/turnstile-authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index 3851214..c2e1acd 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -13,9 +13,19 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven - uses: bahmutov/npm-install@v1 - run: npm run check-translations + - run: npm run lint + - run: npm run typecheck - run: npm run build-keycloak-theme + - name: Build Keycloak SPIs + working-directory: keycloak-extensions + run: mvn -B -DskipTests package check_if_version_upgraded: name: Check if version upgrade @@ -41,8 +51,24 @@ jobs: steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '17' + cache: maven - uses: bahmutov/npm-install@v1 - run: npm run build-keycloak-theme + - name: Build Keycloak SPIs + working-directory: keycloak-extensions + run: mvn -B -DskipTests package + - name: Collect release artifacts + run: | + mkdir -p release-artifacts + cp dist_keycloak/keycloak-theme-*.jar release-artifacts/ + cp keycloak-extensions/turnstile-authenticator/target/helpwave-turnstile-authenticator-*.jar release-artifacts/ + cp keycloak-extensions/privacy-acceptance/target/helpwave-privacy-acceptance-*.jar release-artifacts/ + cp keycloak-extensions/profile-picture/target/helpwave-profile-picture-*.jar release-artifacts/ + rm -f release-artifacts/original-*.jar - uses: softprops/action-gh-release@v2 with: name: Release v${{ needs.check_if_version_upgraded.outputs.to_version }} @@ -51,6 +77,6 @@ jobs: generate_release_notes: true draft: false prerelease: ${{ needs.check_if_version_upgraded.outputs.is_pre_release == 'true' }} - files: dist_keycloak/keycloak-theme-*.jar + files: release-artifacts/*.jar env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore index e185cde..3192460 100644 --- a/.gitignore +++ b/.gitignore @@ -59,3 +59,7 @@ jspm_packages # build output of `jsx-email` /.rendered .cursor + +# Maven build output for keycloak-extensions +keycloak-extensions/**/target/ +keycloak-extensions/**/dependency-reduced-pom.xml diff --git a/README.md b/README.md index f417725..acb52d4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,8 @@ # [id.helpwave.de](https://id.helpwave.de) -Keycloak login theme using helpwave hightide components. +Keycloak login theme using helpwave hightide components, plus the Keycloak SPI +extensions that power the registration flow (Cloudflare Turnstile + privacy +acceptance) and the profile picture upload. ## Quick start @@ -25,12 +27,30 @@ npm run build-keycloak-theme This will generate the theme jar files in `dist_keycloak/`. -Note: You need [Maven](https://maven.apache.org/) installed to build the theme (Maven >= 3.1.1, Java >= 7). +Note: You need [Maven](https://maven.apache.org/) installed to build the theme (Maven >= 3.1.1, Java >= 17). - On macOS: `brew install maven` - On Debian/Ubuntu: `sudo apt-get install maven` - On Windows: `choco install openjdk` and `choco install maven` +### Building the Keycloak SPIs + +The Java extensions live under `keycloak-extensions/` and are built with Maven: + +```bash +cd keycloak-extensions +mvn -DskipTests package +``` + +This produces three jars: + +- `turnstile-authenticator/target/helpwave-turnstile-authenticator-.jar` +- `privacy-acceptance/target/helpwave-privacy-acceptance-.jar` +- `profile-picture/target/helpwave-profile-picture-.jar` (shaded with AWS SDK + Thumbnailator) + +Drop all three (alongside the theme jar) into Keycloak's `providers/` directory and run +`kc.sh build`. + ## Local development with Docker Start keycloak and postgres services: @@ -40,12 +60,14 @@ docker compose up ``` This will: + - Start postgres database - Start keycloak on port 8080 - Import realms from `keycloak/import/` - Mount the theme jar from `dist_keycloak/` Default admin credentials: + - Username: `admin` - Password: `admin` @@ -67,3 +89,110 @@ For nixos users, see [docs/nixos.md](docs/nixos.md) for nix-shell setup instruct - Realm indicator chip with deterministic color mapping - Custom login, register, and forgot password pages - Field-level validation matching hightide patterns +- **Cloudflare Turnstile** CAPTCHA on signup (`helpwave-turnstile` FormAction SPI) +- **Privacy policy** checkbox on signup with acceptance metadata stored on the user + (`helpwave-privacy-acceptance` FormAction SPI) +- **Profile picture upload** with server-side scaling to multiple sizes and storage in any + S3-compatible bucket (`helpwave-picture` Realm Resource SPI) + +--- + +## Deployment + +The release workflow publishes the following jars on every version bump in `package.json`: + +| Jar | Purpose | +|--------------------------------------------------|--------------------------------------------------| +| `keycloak-theme-for-kc-26.2-and-above.jar` | The login/account theme | +| `helpwave-turnstile-authenticator-.jar` | Cloudflare Turnstile registration form action | +| `helpwave-privacy-acceptance-.jar` | Privacy acceptance form action + attribute store | +| `helpwave-profile-picture-.jar` | Profile picture REST endpoint + R2/S3 upload | + +Copy all jars into Keycloak's `providers/` directory (or mount them into the container) +and run `kc.sh build` to rebuild the runtime, then start Keycloak normally. + +### 1. Enable the Cloudflare Turnstile and Privacy form actions + +1. Open the Keycloak admin console. +2. Go to **Authentication** → **Flows** and duplicate the built-in **registration** flow. +3. In your new copy, add two executions to the *registration form*: + - `Cloudflare Turnstile (helpwave)` — set to **Required** + - `Privacy Policy Acceptance (helpwave)` — set to **Required** +4. Click the gear on each execution to configure it: + - **Turnstile**: set the `Turnstile site key` (public) and `Turnstile secret` (private). + Get these from . + - **Privacy**: set the `Privacy policy URL` (defaults to `https://helpwave.de/privacy`) + and an optional `Privacy policy version` string. Both are persisted on the user as + `privacy_policy_accepted_at` and `privacy_policy_version` attributes. +5. Set this flow as the realm's **Registration flow** binding. + +### 2. Configure the profile picture storage + +The profile picture SPI accepts standard AWS S3 or Cloudflare R2 (any S3-compatible +backend). It exposes itself at: + +``` +/realms/{realm}/helpwave-picture +``` + +`POST` the raw image bytes (`Content-Type: image/jpeg|png|webp`, or `multipart/form-data` +from a ``) with a Bearer access token. The endpoint scales the image to +512/256/128/64 px JPEGs and writes them to the bucket. The primary URL is stored on the +user as the standard OpenID Connect `picture` attribute; thumbnail URLs as +`picture_thumb_64|128|256`. `DELETE` removes both the bucket objects and the attributes. + +Configuration is read from Keycloak SPI settings (preferred) or environment variables: + +| SPI key (under `spi-helpwave-picture-default-*`) | Env var | Required | +|--------------------------------------------------|----------------------------------------|----------| +| `endpoint` | `HELPWAVE_PICTURE_ENDPOINT` | R2 only | +| `region` | `HELPWAVE_PICTURE_REGION` (def `auto`) | no | +| `bucket` | `HELPWAVE_PICTURE_BUCKET` | yes | +| `accessKey` | `HELPWAVE_PICTURE_ACCESS_KEY` | yes | +| `secretKey` | `HELPWAVE_PICTURE_SECRET_KEY` | yes | +| `publicBaseUrl` | `HELPWAVE_PICTURE_PUBLIC_BASE_URL` | yes | +| `maxBytes` | `HELPWAVE_PICTURE_MAX_BYTES` (5 MiB) | no | + +#### Example: Cloudflare R2 + +```bash +KC_SPI_HELPWAVE_PICTURE_DEFAULT_ENDPOINT=https://.r2.cloudflarestorage.com +KC_SPI_HELPWAVE_PICTURE_DEFAULT_REGION=auto +KC_SPI_HELPWAVE_PICTURE_DEFAULT_BUCKET=helpwave-id-avatars +KC_SPI_HELPWAVE_PICTURE_DEFAULT_ACCESS_KEY=... +KC_SPI_HELPWAVE_PICTURE_DEFAULT_SECRET_KEY=... +KC_SPI_HELPWAVE_PICTURE_DEFAULT_PUBLIC_BASE_URL=https://cdn.helpwave.de/avatars +``` + +#### Example: AWS S3 + +```bash +KC_SPI_HELPWAVE_PICTURE_DEFAULT_REGION=eu-central-1 +KC_SPI_HELPWAVE_PICTURE_DEFAULT_BUCKET=helpwave-id-avatars +KC_SPI_HELPWAVE_PICTURE_DEFAULT_ACCESS_KEY=... +KC_SPI_HELPWAVE_PICTURE_DEFAULT_SECRET_KEY=... +KC_SPI_HELPWAVE_PICTURE_DEFAULT_PUBLIC_BASE_URL=https://avatars.helpwave.de +``` + +### 3. Wire the theme to the SPIs + +Two Keycloakify env vars expose the SPI to the theme at render time: + +| Env var | Purpose | +|--------------------------|-----------------------------------------------------------------------------| +| `TURNSTILE_SITE_KEY` | Optional fallback when the Turnstile authenticator config is not yet bound | +| `PROFILE_PICTURE_API_URL`| Full URL to `/realms//helpwave-picture` | + +Set them in the Keycloak container, e.g.: + +```bash +KC_HELPWAVE_TURNSTILE_SITE_KEY=0x4AAAAAAA... +KC_HELPWAVE_PROFILE_PICTURE_API_URL=https://id.helpwave.de/realms/customer/helpwave-picture +``` + +(Keycloakify reads `KC_` and exposes it as `kcContext.properties.`.) + +### 4. Releases + +Bump `version` in `package.json` on `main`. The CI workflow builds the theme + SPIs and +publishes a GitHub release with all four jars attached. diff --git a/keycloak-extensions/pom.xml b/keycloak-extensions/pom.xml new file mode 100644 index 0000000..f03f7b2 --- /dev/null +++ b/keycloak-extensions/pom.xml @@ -0,0 +1,92 @@ + + + 4.0.0 + + de.helpwave.keycloak + helpwave-keycloak-extensions + 0.1.0 + pom + helpwave Keycloak Extensions + Keycloak SPI extensions used by id.helpwave.de + + + turnstile-authenticator + privacy-acceptance + profile-picture + + + + UTF-8 + 17 + 17 + 26.6.0 + 2.29.9 + 0.4.20 + 2.18.2 + + + + + + org.keycloak + keycloak-server-spi + ${keycloak.version} + provided + + + org.keycloak + keycloak-server-spi-private + ${keycloak.version} + provided + + + org.keycloak + keycloak-services + ${keycloak.version} + provided + + + org.keycloak + keycloak-core + ${keycloak.version} + provided + + + jakarta.ws.rs + jakarta.ws.rs-api + 4.0.0 + provided + + + com.fasterxml.jackson.core + jackson-databind + ${jackson.version} + provided + + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-shade-plugin + 3.6.0 + + + org.apache.maven.plugins + maven-jar-plugin + 3.4.2 + + + + + diff --git a/keycloak-extensions/privacy-acceptance/pom.xml b/keycloak-extensions/privacy-acceptance/pom.xml new file mode 100644 index 0000000..de7a95b --- /dev/null +++ b/keycloak-extensions/privacy-acceptance/pom.xml @@ -0,0 +1,39 @@ + + + 4.0.0 + + + de.helpwave.keycloak + helpwave-keycloak-extensions + 0.1.0 + + + helpwave-privacy-acceptance + jar + helpwave Privacy Acceptance Form Action + + + + org.keycloak + keycloak-server-spi + + + org.keycloak + keycloak-server-spi-private + + + org.keycloak + keycloak-services + + + org.keycloak + keycloak-core + + + jakarta.ws.rs + jakarta.ws.rs-api + + + diff --git a/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java b/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java new file mode 100644 index 0000000..09f8c6b --- /dev/null +++ b/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java @@ -0,0 +1,89 @@ +package de.helpwave.keycloak.privacy; + +import jakarta.ws.rs.core.MultivaluedMap; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormContext; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.events.Errors; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.FormMessage; + +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; + +/** + * Enforces acceptance of the helpwave privacy policy during registration and stores the + * acceptance metadata as user attributes: + *
    + *
  • {@code privacy_policy_accepted} = "true"
  • + *
  • {@code privacy_policy_accepted_at} = ISO-8601 timestamp
  • + *
  • {@code privacy_policy_version} = configured policy version (e.g. "2024-01")
  • + *
+ */ +public class PrivacyAcceptanceFormAction implements FormAction { + + private static final String FORM_FIELD = "privacy-accepted"; + + static final String CFG_VERSION = "privacy.policy.version"; + static final String CFG_URL = "privacy.policy.url"; + + public static final String ATTR_ACCEPTED = "privacy_policy_accepted"; + public static final String ATTR_ACCEPTED_AT = "privacy_policy_accepted_at"; + public static final String ATTR_VERSION = "privacy_policy_version"; + + @Override + public void buildPage(FormContext context, org.keycloak.forms.login.LoginFormsProvider form) { + String url = getConfig(context, CFG_URL); + if (url != null && !url.isBlank()) { + form.setAttribute("privacyPolicyUrl", url); + } + } + + @Override + public void validate(ValidationContext context) { + MultivaluedMap form = context.getHttpRequest().getDecodedFormParameters(); + String accepted = form.getFirst(FORM_FIELD); + if (!"true".equalsIgnoreCase(accepted) && !"on".equalsIgnoreCase(accepted)) { + context.getEvent().error(Errors.INVALID_REGISTRATION); + List errors = new ArrayList<>(); + errors.add(new FormMessage(FORM_FIELD, "privacyRequired")); + context.validationError(form, errors); + return; + } + context.success(); + } + + @Override + public void success(FormContext context) { + UserModel user = context.getUser(); + if (user == null) return; + user.setSingleAttribute(ATTR_ACCEPTED, "true"); + user.setSingleAttribute(ATTR_ACCEPTED_AT, Instant.now().toString()); + String version = getConfig(context, CFG_VERSION); + if (version != null && !version.isBlank()) { + user.setSingleAttribute(ATTR_VERSION, version); + } + } + + private static String getConfig(FormContext ctx, String key) { + AuthenticatorConfigModel cfg = ctx.getAuthenticatorConfig(); + if (cfg == null) return null; + return cfg.getConfig().get(key); + } + + @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/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java b/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java new file mode 100644 index 0000000..88800e8 --- /dev/null +++ b/keycloak-extensions/privacy-acceptance/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java @@ -0,0 +1,77 @@ +package de.helpwave.keycloak.privacy; + +import org.keycloak.Config; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormActionFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +public class PrivacyAcceptanceFormActionFactory implements FormActionFactory { + + public static final String PROVIDER_ID = "helpwave-privacy-acceptance"; + + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENTS = { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED + }; + + private static final List CONFIG; + static { + ProviderConfigProperty url = new ProviderConfigProperty(); + url.setName(PrivacyAcceptanceFormAction.CFG_URL); + url.setLabel("Privacy policy URL"); + url.setType(ProviderConfigProperty.STRING_TYPE); + url.setDefaultValue("https://helpwave.de/privacy"); + url.setHelpText("URL of the privacy policy that the user accepts."); + + ProviderConfigProperty version = new ProviderConfigProperty(); + version.setName(PrivacyAcceptanceFormAction.CFG_VERSION); + version.setLabel("Privacy policy version"); + version.setType(ProviderConfigProperty.STRING_TYPE); + version.setHelpText("Optional version identifier stored on the user account, e.g. '2024-01'."); + + CONFIG = List.of(url, version); + } + + @Override + public String getDisplayType() { return "Privacy Policy Acceptance (helpwave)"; } + + @Override + public String getReferenceCategory() { return "terms"; } + + @Override + public boolean isConfigurable() { return true; } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { return REQUIREMENTS; } + + @Override + public boolean isUserSetupAllowed() { return false; } + + @Override + public String getHelpText() { + return "Requires the user to accept the privacy policy and stores acceptance metadata on the user account."; + } + + @Override + public List getConfigProperties() { return CONFIG; } + + @Override + public FormAction create(KeycloakSession session) { return new PrivacyAcceptanceFormAction(); } + + @Override + public void init(Config.Scope config) { } + + @Override + public void postInit(KeycloakSessionFactory factory) { } + + @Override + public void close() { } + + @Override + public String getId() { return PROVIDER_ID; } +} diff --git a/keycloak-extensions/privacy-acceptance/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory b/keycloak-extensions/privacy-acceptance/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory new file mode 100644 index 0000000..009c5b7 --- /dev/null +++ b/keycloak-extensions/privacy-acceptance/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory @@ -0,0 +1 @@ +de.helpwave.keycloak.privacy.PrivacyAcceptanceFormActionFactory diff --git a/keycloak-extensions/profile-picture/pom.xml b/keycloak-extensions/profile-picture/pom.xml new file mode 100644 index 0000000..ddf33bc --- /dev/null +++ b/keycloak-extensions/profile-picture/pom.xml @@ -0,0 +1,95 @@ + + + 4.0.0 + + + de.helpwave.keycloak + helpwave-keycloak-extensions + 0.1.0 + + + helpwave-profile-picture + jar + helpwave Profile Picture SPI + + + + org.keycloak + keycloak-server-spi + + + org.keycloak + keycloak-server-spi-private + + + org.keycloak + keycloak-services + + + org.keycloak + keycloak-core + + + jakarta.ws.rs + jakarta.ws.rs-api + + + com.fasterxml.jackson.core + jackson-databind + + + + + software.amazon.awssdk + s3 + ${aws.sdk.version} + + + net.coobird + thumbnailator + ${thumbnailator.version} + + + + + + + org.apache.maven.plugins + maven-shade-plugin + + + package + shade + + false + false + + + *:* + + META-INF/*.SF + META-INF/*.DSA + META-INF/*.RSA + module-info.class + + + + + + org.keycloak:* + jakarta.ws.rs:* + com.fasterxml.jackson.core:* + + + + + + + + + + + + diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java new file mode 100644 index 0000000..841aa3e --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ImageProcessor.java @@ -0,0 +1,51 @@ +package de.helpwave.keycloak.picture; + +import net.coobird.thumbnailator.Thumbnails; + +import javax.imageio.ImageIO; +import java.awt.image.BufferedImage; +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.IOException; +import java.util.LinkedHashMap; +import java.util.Map; + +/** + * Decodes uploaded images, strips metadata (re-encoding to PNG/JPEG via Thumbnailator) and + * produces a fixed set of square thumbnails for the avatar use case. + */ +public final class ImageProcessor { + + /** Output sizes (pixels). Keys are used as filename suffixes. */ + static final Map SIZES = new LinkedHashMap<>(); + static { + SIZES.put("original", 512); + SIZES.put("256", 256); + SIZES.put("128", 128); + SIZES.put("64", 64); + } + + public static final String OUTPUT_CONTENT_TYPE = "image/jpeg"; + private static final String OUTPUT_FORMAT = "jpg"; + + private ImageProcessor() {} + + /** Validates that the bytes decode as an image and returns a sanitized image. */ + public static BufferedImage decode(byte[] bytes) throws IOException { + BufferedImage img = ImageIO.read(new ByteArrayInputStream(bytes)); + if (img == null) throw new IOException("not a valid image"); + return img; + } + + /** Returns the encoded JPEG bytes for the given square size. */ + public static byte[] toSquareJpeg(BufferedImage source, int size) throws IOException { + ByteArrayOutputStream out = new ByteArrayOutputStream(); + Thumbnails.of(source) + .size(size, size) + .crop(net.coobird.thumbnailator.geometry.Positions.CENTER) + .outputFormat(OUTPUT_FORMAT) + .outputQuality(0.88f) + .toOutputStream(out); + return out.toByteArray(); + } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java new file mode 100644 index 0000000..4b76d57 --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/MultipartParser.java @@ -0,0 +1,72 @@ +package de.helpwave.keycloak.picture; + +import java.nio.charset.StandardCharsets; + +/** + * Minimal RFC 2046 multipart/form-data parser used to extract the first file part out of + * the request body. We do not depend on RESTEasy's MultipartFormDataInput because the type + * is not on Keycloak's classpath in 26.x. + */ +final class MultipartParser { + + private MultipartParser() {} + + static byte[] extractFirstFile(byte[] body, String contentType) { + String boundary = boundaryOf(contentType); + if (boundary == null) return null; + byte[] sep = ("--" + boundary).getBytes(StandardCharsets.US_ASCII); + byte[] crlfCrlf = {0x0D, 0x0A, 0x0D, 0x0A}; + + int idx = indexOf(body, sep, 0); + while (idx >= 0) { + int partStart = idx + sep.length; + // Skip CRLF after boundary or "--" end marker + if (partStart + 2 <= body.length && body[partStart] == '-' && body[partStart + 1] == '-') break; + if (partStart + 2 <= body.length && body[partStart] == 0x0D && body[partStart + 1] == 0x0A) { + partStart += 2; + } + int headersEnd = indexOf(body, crlfCrlf, partStart); + if (headersEnd < 0) return null; + int contentStart = headersEnd + crlfCrlf.length; + int nextBoundary = indexOf(body, sep, contentStart); + if (nextBoundary < 0) return null; + // Trim trailing CRLF before boundary + int contentEnd = nextBoundary; + if (contentEnd >= 2 && body[contentEnd - 2] == 0x0D && body[contentEnd - 1] == 0x0A) { + contentEnd -= 2; + } + String headers = new String(body, partStart, headersEnd - partStart, StandardCharsets.UTF_8); + if (headers.toLowerCase().contains("filename=")) { + byte[] out = new byte[contentEnd - contentStart]; + System.arraycopy(body, contentStart, out, 0, out.length); + return out; + } + idx = nextBoundary; + } + return null; + } + + private static String boundaryOf(String contentType) { + if (contentType == null) return null; + for (String part : contentType.split(";")) { + String p = part.trim(); + if (p.toLowerCase().startsWith("boundary=")) { + String v = p.substring("boundary=".length()).trim(); + if (v.startsWith("\"") && v.endsWith("\"")) v = v.substring(1, v.length() - 1); + return v; + } + } + return null; + } + + private static int indexOf(byte[] hay, byte[] needle, int from) { + outer: + for (int i = from; i <= hay.length - needle.length; i++) { + for (int j = 0; j < needle.length; j++) { + if (hay[i + j] != needle[j]) continue outer; + } + return i; + } + return -1; + } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java new file mode 100644 index 0000000..e126f3e --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/PictureConfig.java @@ -0,0 +1,57 @@ +package de.helpwave.keycloak.picture; + +/** + * Configuration for the profile-picture storage backend. All values are read from + * Keycloak's SPI configuration ({@code spi-helpwave-picture-default-*}) or environment + * variables, whichever is set first. + * + *

Example for Cloudflare R2: + *

+ * KC_SPI_HELPWAVE_PICTURE_DEFAULT_ENDPOINT=https://<account>.r2.cloudflarestorage.com
+ * KC_SPI_HELPWAVE_PICTURE_DEFAULT_REGION=auto
+ * KC_SPI_HELPWAVE_PICTURE_DEFAULT_BUCKET=helpwave-id-avatars
+ * KC_SPI_HELPWAVE_PICTURE_DEFAULT_ACCESS_KEY=...
+ * KC_SPI_HELPWAVE_PICTURE_DEFAULT_SECRET_KEY=...
+ * KC_SPI_HELPWAVE_PICTURE_DEFAULT_PUBLIC_BASE_URL=https://cdn.helpwave.de/avatars
+ * 
+ */ +public record PictureConfig( + String endpoint, + String region, + String bucket, + String accessKey, + String secretKey, + String publicBaseUrl, + int maxBytes +) { + public static PictureConfig fromEnv(org.keycloak.Config.Scope scope) { + return new PictureConfig( + read(scope, "endpoint", "HELPWAVE_PICTURE_ENDPOINT"), + orDefault(read(scope, "region", "HELPWAVE_PICTURE_REGION"), "auto"), + read(scope, "bucket", "HELPWAVE_PICTURE_BUCKET"), + read(scope, "accessKey", "HELPWAVE_PICTURE_ACCESS_KEY"), + read(scope, "secretKey", "HELPWAVE_PICTURE_SECRET_KEY"), + orDefault(read(scope, "publicBaseUrl", "HELPWAVE_PICTURE_PUBLIC_BASE_URL"), ""), + Integer.parseInt(orDefault(read(scope, "maxBytes", "HELPWAVE_PICTURE_MAX_BYTES"), "5242880")) + ); + } + + public boolean isValid() { + return bucket != null && !bucket.isBlank() + && accessKey != null && !accessKey.isBlank() + && secretKey != null && !secretKey.isBlank() + && publicBaseUrl != null && !publicBaseUrl.isBlank(); + } + + private static String read(org.keycloak.Config.Scope scope, String key, String env) { + if (scope != null) { + String v = scope.get(key); + if (v != null && !v.isBlank()) return v; + } + return System.getenv(env); + } + + private static String orDefault(String v, String def) { + return (v == null || v.isBlank()) ? def : v; + } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java new file mode 100644 index 0000000..8cdbd2f --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResource.java @@ -0,0 +1,166 @@ +package de.helpwave.keycloak.picture; + +import jakarta.ws.rs.Consumes; +import jakarta.ws.rs.DELETE; +import jakarta.ws.rs.HeaderParam; +import jakarta.ws.rs.OPTIONS; +import jakarta.ws.rs.POST; +import jakarta.ws.rs.Path; +import jakarta.ws.rs.Produces; +import jakarta.ws.rs.core.HttpHeaders; +import jakarta.ws.rs.core.MediaType; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.UserModel; +import org.keycloak.services.managers.AppAuthManager; +import org.keycloak.services.managers.AuthenticationManager.AuthResult; + +import java.awt.image.BufferedImage; +import java.io.IOException; +import java.io.InputStream; +import java.util.HashMap; +import java.util.Map; +import java.util.Set; +import java.util.UUID; + +/** + * REST endpoint exposed at {@code /realms/{realm}/helpwave-picture}. The browser sends the + * raw image bytes as the request body with the corresponding {@code Content-Type} header + * (no multipart wrapper needed). Authentication is the standard Keycloak bearer token. + */ +@Path("/") +public class ProfilePictureResource { + + private static final Logger log = Logger.getLogger(ProfilePictureResource.class); + public static final String ATTR_PICTURE_URL = "picture"; + + private static final Set ALLOWED_TYPES = Set.of("image/jpeg", "image/png", "image/webp"); + + private final KeycloakSession session; + private final PictureConfig config; + private final S3Storage storage; + + public ProfilePictureResource(KeycloakSession session, PictureConfig config, S3Storage storage) { + this.session = session; + this.config = config; + this.storage = storage; + } + + @OPTIONS + public Response preflight() { + return Response.ok().build(); + } + + @POST + @Consumes({"image/jpeg", "image/png", "image/webp", MediaType.APPLICATION_OCTET_STREAM, MediaType.MULTIPART_FORM_DATA}) + @Produces(MediaType.APPLICATION_JSON) + public Response upload(@HeaderParam(HttpHeaders.CONTENT_TYPE) String contentType, InputStream body) { + if (storage == null) { + return Response.status(Response.Status.SERVICE_UNAVAILABLE) + .entity(Map.of("error", "storage not configured")).build(); + } + UserModel user = authenticate(); + if (user == null) return unauthorized(); + + byte[] bytes; + try { + bytes = body.readAllBytes(); + } catch (IOException e) { + return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "read failed")).build(); + } + + // If sent as multipart, extract the first file part. + if (contentType != null && contentType.toLowerCase().startsWith("multipart/")) { + byte[] extracted = MultipartParser.extractFirstFile(bytes, contentType); + if (extracted != null) bytes = extracted; + } + + if (bytes.length > config.maxBytes()) { + return Response.status(Response.Status.REQUEST_ENTITY_TOO_LARGE) + .entity(Map.of("error", "file too large")).build(); + } + + BufferedImage source; + try { + source = ImageProcessor.decode(bytes); + } catch (Exception e) { + return Response.status(Response.Status.BAD_REQUEST).entity(Map.of("error", "invalid image")).build(); + } + + String userId = user.getId(); + String version = UUID.randomUUID().toString().substring(0, 8); + String primaryUrl = null; + Map generated = new HashMap<>(); + try { + for (Map.Entry entry : ImageProcessor.SIZES.entrySet()) { + String label = entry.getKey(); + byte[] scaled = ImageProcessor.toSquareJpeg(source, entry.getValue()); + String key = "users/" + userId + "/" + version + "/" + label + ".jpg"; + String url = storage.put(key, scaled, ImageProcessor.OUTPUT_CONTENT_TYPE); + generated.put(label, url); + if ("original".equals(label)) primaryUrl = url; + } + } catch (Exception e) { + log.error("Profile picture upload failed", e); + return Response.serverError().entity(Map.of("error", "upload failed")).build(); + } + + String previous = user.getFirstAttribute(ATTR_PICTURE_URL); + user.setSingleAttribute(ATTR_PICTURE_URL, primaryUrl); + user.setSingleAttribute("picture_thumb_64", generated.get("64")); + user.setSingleAttribute("picture_thumb_128", generated.get("128")); + user.setSingleAttribute("picture_thumb_256", generated.get("256")); + + if (previous != null) tryDeletePrevious(previous); + + return Response.ok(Map.of("url", primaryUrl, "variants", generated)).build(); + } + + @DELETE + @Produces(MediaType.APPLICATION_JSON) + public Response delete() { + UserModel user = authenticate(); + if (user == null) return unauthorized(); + + String previous = user.getFirstAttribute(ATTR_PICTURE_URL); + user.removeAttribute(ATTR_PICTURE_URL); + user.removeAttribute("picture_thumb_64"); + user.removeAttribute("picture_thumb_128"); + user.removeAttribute("picture_thumb_256"); + if (previous != null) tryDeletePrevious(previous); + return Response.ok(Map.of("status", "removed")).build(); + } + + private void tryDeletePrevious(String url) { + try { + String prefix = config.publicBaseUrl().replaceAll("/+$", "") + "/"; + if (!url.startsWith(prefix)) return; + String relative = url.substring(prefix.length()); + int folder = relative.lastIndexOf('/'); + if (folder < 0) return; + String base = relative.substring(0, folder); + for (String label : ImageProcessor.SIZES.keySet()) { + storage.delete(base + "/" + label + ".jpg"); + } + } catch (Exception e) { + log.debugf("Failed to delete previous picture: %s", e.getMessage()); + } + } + + private UserModel authenticate() { + HttpRequest req = session.getContext().getHttpRequest(); + AuthResult auth = new AppAuthManager.BearerTokenAuthenticator(session) + .setRealm(session.getContext().getRealm()) + .setUriInfo(session.getContext().getUri()) + .setConnection(session.getContext().getConnection()) + .setHeaders(req.getHttpHeaders()) + .authenticate(); + return auth == null ? null : auth.getUser(); + } + + private Response unauthorized() { + return Response.status(Response.Status.UNAUTHORIZED).entity(Map.of("error", "unauthorized")).build(); + } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java new file mode 100644 index 0000000..b6f96b4 --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProvider.java @@ -0,0 +1,25 @@ +package de.helpwave.keycloak.picture; + +import org.keycloak.models.KeycloakSession; +import org.keycloak.services.resource.RealmResourceProvider; + +public class ProfilePictureResourceProvider implements RealmResourceProvider { + + private final KeycloakSession session; + private final PictureConfig config; + private final S3Storage storage; + + public ProfilePictureResourceProvider(KeycloakSession session, PictureConfig config, S3Storage storage) { + this.session = session; + this.config = config; + this.storage = storage; + } + + @Override + public Object getResource() { + return new ProfilePictureResource(session, config, storage); + } + + @Override + public void close() { } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java new file mode 100644 index 0000000..6ce4d81 --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/ProfilePictureResourceProviderFactory.java @@ -0,0 +1,44 @@ +package de.helpwave.keycloak.picture; + +import org.jboss.logging.Logger; +import org.keycloak.Config; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.services.resource.RealmResourceProvider; +import org.keycloak.services.resource.RealmResourceProviderFactory; + +public class ProfilePictureResourceProviderFactory implements RealmResourceProviderFactory { + + public static final String ID = "helpwave-picture"; + private static final Logger log = Logger.getLogger(ProfilePictureResourceProviderFactory.class); + + private PictureConfig config; + private S3Storage storage; + + @Override + public RealmResourceProvider create(KeycloakSession session) { + return new ProfilePictureResourceProvider(session, config, storage); + } + + @Override + public void init(Config.Scope scope) { + this.config = PictureConfig.fromEnv(scope); + if (!config.isValid()) { + log.warn("helpwave-picture: storage config is incomplete; uploads will return 503"); + return; + } + this.storage = new S3Storage(config); + log.infof("helpwave-picture initialized (bucket=%s, region=%s)", config.bucket(), config.region()); + } + + @Override + public void postInit(KeycloakSessionFactory factory) { } + + @Override + public void close() { + if (storage != null) storage.close(); + } + + @Override + public String getId() { return ID; } +} diff --git a/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java new file mode 100644 index 0000000..d17a212 --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/java/de/helpwave/keycloak/picture/S3Storage.java @@ -0,0 +1,57 @@ +package de.helpwave.keycloak.picture; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3ClientBuilder; +import software.amazon.awssdk.services.s3.S3Configuration; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +import java.net.URI; + +/** Tiny wrapper around the AWS S3 client that works with Cloudflare R2 via a custom endpoint. */ +public final class S3Storage { + + private final S3Client client; + private final String bucket; + private final String publicBaseUrl; + + public S3Storage(PictureConfig config) { + this.bucket = config.bucket(); + this.publicBaseUrl = config.publicBaseUrl().replaceAll("/+$", ""); + + S3ClientBuilder builder = S3Client.builder() + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(config.accessKey(), config.secretKey()))) + .region(Region.of(config.region())) + // R2 only supports path-style addressing. + .serviceConfiguration(S3Configuration.builder().pathStyleAccessEnabled(true).build()); + + if (config.endpoint() != null && !config.endpoint().isBlank()) { + builder = builder.endpointOverride(URI.create(config.endpoint())); + } + this.client = builder.build(); + } + + public String put(String key, byte[] data, String contentType) { + client.putObject(PutObjectRequest.builder() + .bucket(bucket) + .key(key) + .contentType(contentType) + .cacheControl("public, max-age=31536000, immutable") + .build(), RequestBody.fromBytes(data)); + return publicBaseUrl + "/" + key; + } + + public void delete(String key) { + client.deleteObject(DeleteObjectRequest.builder() + .bucket(bucket) + .key(key) + .build()); + } + + public void close() { client.close(); } +} diff --git a/keycloak-extensions/profile-picture/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory b/keycloak-extensions/profile-picture/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory new file mode 100644 index 0000000..21f461f --- /dev/null +++ b/keycloak-extensions/profile-picture/src/main/resources/META-INF/services/org.keycloak.services.resource.RealmResourceProviderFactory @@ -0,0 +1 @@ +de.helpwave.keycloak.picture.ProfilePictureResourceProviderFactory diff --git a/keycloak-extensions/turnstile-authenticator/pom.xml b/keycloak-extensions/turnstile-authenticator/pom.xml new file mode 100644 index 0000000..9c7286d --- /dev/null +++ b/keycloak-extensions/turnstile-authenticator/pom.xml @@ -0,0 +1,43 @@ + + + 4.0.0 + + + de.helpwave.keycloak + helpwave-keycloak-extensions + 0.1.0 + + + helpwave-turnstile-authenticator + jar + helpwave Cloudflare Turnstile Authenticator + + + + org.keycloak + keycloak-server-spi + + + org.keycloak + keycloak-server-spi-private + + + org.keycloak + keycloak-services + + + org.keycloak + keycloak-core + + + jakarta.ws.rs + jakarta.ws.rs-api + + + com.fasterxml.jackson.core + jackson-databind + + + diff --git a/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormAction.java b/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormAction.java new file mode 100644 index 0000000..fc38683 --- /dev/null +++ b/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormAction.java @@ -0,0 +1,130 @@ +package de.helpwave.keycloak.turnstile; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import org.jboss.logging.Logger; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormContext; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.events.Errors; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.utils.FormMessage; + +import java.net.URI; +import java.net.http.HttpClient; +import java.net.http.HttpRequest; +import java.net.http.HttpResponse; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +/** + * Server-side Cloudflare Turnstile verification. Runs as part of the registration form flow. + * Reads the token from form field {@code cf-turnstile-response} and validates it against + * Cloudflare's siteverify endpoint using the configured secret. + */ +public class TurnstileFormAction implements FormAction { + + private static final Logger log = Logger.getLogger(TurnstileFormAction.class); + private static final String FORM_FIELD = "cf-turnstile-response"; + private static final String VERIFY_URL = "https://challenges.cloudflare.com/turnstile/v0/siteverify"; + + static final String CFG_SITE_KEY = "turnstile.site.key"; + static final String CFG_SECRET = "turnstile.secret"; + + private static final HttpClient HTTP = HttpClient.newBuilder() + .connectTimeout(Duration.ofSeconds(5)) + .build(); + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Override + public void buildPage(FormContext context, org.keycloak.forms.login.LoginFormsProvider form) { + String siteKey = getConfig(context, CFG_SITE_KEY); + if (siteKey != null && !siteKey.isBlank()) { + form.setAttribute("turnstileSiteKey", siteKey); + } + } + + @Override + public void validate(ValidationContext context) { + String secret = getConfig(context, CFG_SECRET); + if (secret == null || secret.isBlank()) { + // Misconfigured. Fail closed in production; here we log and skip to avoid lockouts. + log.warn("Turnstile secret not configured; skipping verification"); + context.success(); + return; + } + + MultivaluedMap form = context.getHttpRequest().getDecodedFormParameters(); + String token = form.getFirst(FORM_FIELD); + if (token == null || token.isBlank()) { + failed(context, "captchaFailed"); + return; + } + + try { + String remoteIp = context.getConnection() != null ? context.getConnection().getRemoteAddr() : null; + String body = "secret=" + url(secret) + + "&response=" + url(token) + + (remoteIp != null ? "&remoteip=" + url(remoteIp) : ""); + HttpRequest req = HttpRequest.newBuilder() + .uri(URI.create(VERIFY_URL)) + .timeout(Duration.ofSeconds(5)) + .header("Content-Type", "application/x-www-form-urlencoded") + .POST(HttpRequest.BodyPublishers.ofString(body)) + .build(); + HttpResponse res = HTTP.send(req, HttpResponse.BodyHandlers.ofString()); + JsonNode root = MAPPER.readTree(res.body()); + if (root.path("success").asBoolean(false)) { + context.success(); + } else { + log.debugf("Turnstile verification failed: %s", res.body()); + failed(context, "captchaFailed"); + } + } catch (Exception e) { + log.warn("Turnstile verification call failed", e); + failed(context, "captchaFailed"); + } + } + + private static String url(String v) { + return java.net.URLEncoder.encode(v, StandardCharsets.UTF_8); + } + + private void failed(ValidationContext context, String messageKey) { + context.getEvent().error(Errors.INVALID_REGISTRATION); + List errors = new ArrayList<>(); + errors.add(new FormMessage(FORM_FIELD, messageKey)); + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + context.validationError(formData, errors); + } + + private static String getConfig(FormContext ctx, String key) { + AuthenticatorConfigModel cfg = ctx.getAuthenticatorConfig(); + if (cfg == null) return null; + return cfg.getConfig().get(key); + } + + @Override + public void success(FormContext context) { + // nothing to do + } + + @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/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormActionFactory.java b/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormActionFactory.java new file mode 100644 index 0000000..b62e965 --- /dev/null +++ b/keycloak-extensions/turnstile-authenticator/src/main/java/de/helpwave/keycloak/turnstile/TurnstileFormActionFactory.java @@ -0,0 +1,76 @@ +package de.helpwave.keycloak.turnstile; + +import org.keycloak.Config; +import org.keycloak.authentication.FormAction; +import org.keycloak.authentication.FormActionFactory; +import org.keycloak.models.AuthenticationExecutionModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.provider.ProviderConfigProperty; + +import java.util.List; + +public class TurnstileFormActionFactory implements FormActionFactory { + + public static final String PROVIDER_ID = "helpwave-turnstile"; + + private static final AuthenticationExecutionModel.Requirement[] REQUIREMENTS = { + AuthenticationExecutionModel.Requirement.REQUIRED, + AuthenticationExecutionModel.Requirement.DISABLED + }; + + private static final List CONFIG; + static { + ProviderConfigProperty siteKey = new ProviderConfigProperty(); + siteKey.setName(TurnstileFormAction.CFG_SITE_KEY); + siteKey.setLabel("Turnstile site key"); + siteKey.setType(ProviderConfigProperty.STRING_TYPE); + siteKey.setHelpText("Public Cloudflare Turnstile site key, rendered in the registration form."); + + ProviderConfigProperty secret = new ProviderConfigProperty(); + secret.setName(TurnstileFormAction.CFG_SECRET); + secret.setLabel("Turnstile secret"); + secret.setType(ProviderConfigProperty.PASSWORD); + secret.setHelpText("Private Cloudflare Turnstile secret used for server-side verification."); + + CONFIG = List.of(siteKey, secret); + } + + @Override + public String getDisplayType() { return "Cloudflare Turnstile (helpwave)"; } + + @Override + public String getReferenceCategory() { return "captcha"; } + + @Override + public boolean isConfigurable() { return true; } + + @Override + public AuthenticationExecutionModel.Requirement[] getRequirementChoices() { return REQUIREMENTS; } + + @Override + public boolean isUserSetupAllowed() { return false; } + + @Override + public String getHelpText() { + return "Validates Cloudflare Turnstile CAPTCHA during registration."; + } + + @Override + public List getConfigProperties() { return CONFIG; } + + @Override + public FormAction create(KeycloakSession session) { return new TurnstileFormAction(); } + + @Override + public void init(Config.Scope config) { } + + @Override + public void postInit(KeycloakSessionFactory factory) { } + + @Override + public void close() { } + + @Override + public String getId() { return PROVIDER_ID; } +} diff --git a/keycloak-extensions/turnstile-authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory b/keycloak-extensions/turnstile-authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory new file mode 100644 index 0000000..4a5bbc6 --- /dev/null +++ b/keycloak-extensions/turnstile-authenticator/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory @@ -0,0 +1 @@ +de.helpwave.keycloak.turnstile.TurnstileFormActionFactory diff --git a/locales/de-DE.arb b/locales/de-DE.arb index 5fbc501..c919a28 100644 --- a/locales/de-DE.arb +++ b/locales/de-DE.arb @@ -72,6 +72,26 @@ "@acceptTerms": { "description": "Text für AGB-Akzeptanz Checkbox" }, + "acceptPrivacy": "Ich habe die", + "@acceptPrivacy": { + "description": "Datenschutz-Akzeptanz Prefix" + }, + "privacyPolicy": "Datenschutzerklärung gelesen und akzeptiere sie", + "@privacyPolicy": { + "description": "Link-Text Datenschutzerklärung" + }, + "privacyRequired": "Bitte akzeptieren Sie die Datenschutzerklärung.", + "@privacyRequired": { + "description": "Fehler wenn Datenschutz nicht akzeptiert" + }, + "captchaFailed": "Captcha-Prüfung fehlgeschlagen. Bitte versuchen Sie es erneut.", + "@captchaFailed": { + "description": "Fehler bei Captcha-Prüfung" + }, + "captchaLoading": "Captcha wird geladen…", + "@captchaLoading": { + "description": "Captcha Ladezustand" + }, "passwordNew": "Neues Passwort", "@passwordNew": { "description": "Label für neues Passwort Eingabefeld" @@ -136,6 +156,34 @@ "@profilePictureComingSoon": { "description": "Platzhalter für Profilbild-Upload" }, + "uploadProfilePicture": "Bild hochladen", + "@uploadProfilePicture": { + "description": "Button zum Hochladen des Profilbilds" + }, + "removeProfilePicture": "Bild entfernen", + "@removeProfilePicture": { + "description": "Button zum Entfernen des Profilbilds" + }, + "profilePictureUploading": "Wird hochgeladen…", + "@profilePictureUploading": { + "description": "Status während des Uploads" + }, + "profilePictureUploadFailed": "Upload fehlgeschlagen. Bitte erneut versuchen.", + "@profilePictureUploadFailed": { + "description": "Fehler beim Upload des Profilbilds" + }, + "profilePictureTooLarge": "Die gewählte Datei ist größer als 5 MB.", + "@profilePictureTooLarge": { + "description": "Fehler wenn Bild zu groß ist" + }, + "profilePictureWrongType": "Nur JPEG, PNG oder WebP werden unterstützt.", + "@profilePictureWrongType": { + "description": "Fehler bei nicht unterstütztem Bildformat" + }, + "profilePictureHelp": "JPEG, PNG oder WebP, max. 5 MB. Das Bild wird skaliert und sicher gespeichert.", + "@profilePictureHelp": { + "description": "Hinweis unter Upload-Button" + }, "accountSectionProfile": "Profil", "@accountSectionProfile": { "description": "Profil-Abschnittsüberschrift" diff --git a/locales/en-US.arb b/locales/en-US.arb index d560ec7..265eb4d 100644 --- a/locales/en-US.arb +++ b/locales/en-US.arb @@ -72,6 +72,26 @@ "@acceptTerms": { "description": "Text for terms acceptance checkbox" }, + "acceptPrivacy": "I have read and accept the", + "@acceptPrivacy": { + "description": "Privacy policy acceptance prefix" + }, + "privacyPolicy": "privacy policy", + "@privacyPolicy": { + "description": "Privacy policy link text" + }, + "privacyRequired": "You must accept the privacy policy to continue.", + "@privacyRequired": { + "description": "Error when privacy not accepted" + }, + "captchaFailed": "Captcha verification failed. Please try again.", + "@captchaFailed": { + "description": "Error when captcha verification fails" + }, + "captchaLoading": "Loading captcha…", + "@captchaLoading": { + "description": "Captcha loading placeholder" + }, "passwordNew": "New Password", "@passwordNew": { "description": "Label for new password input field" @@ -136,6 +156,34 @@ "@profilePictureComingSoon": { "description": "Placeholder for profile picture upload" }, + "uploadProfilePicture": "Upload picture", + "@uploadProfilePicture": { + "description": "Button to upload profile picture" + }, + "removeProfilePicture": "Remove picture", + "@removeProfilePicture": { + "description": "Button to remove profile picture" + }, + "profilePictureUploading": "Uploading…", + "@profilePictureUploading": { + "description": "Status while profile picture is being uploaded" + }, + "profilePictureUploadFailed": "Upload failed. Please try again.", + "@profilePictureUploadFailed": { + "description": "Error when profile picture upload fails" + }, + "profilePictureTooLarge": "The selected file is larger than 5 MB.", + "@profilePictureTooLarge": { + "description": "Error when picture exceeds size limit" + }, + "profilePictureWrongType": "Only JPEG, PNG or WebP images are supported.", + "@profilePictureWrongType": { + "description": "Error when picture has unsupported type" + }, + "profilePictureHelp": "JPEG, PNG or WebP, max. 5 MB. The image is scaled and stored securely.", + "@profilePictureHelp": { + "description": "Helper text below upload button" + }, "accountSectionProfile": "Profile", "@accountSectionProfile": { "description": "Account profile section title" diff --git a/package.json b/package.json index 88ee475..80b2023 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "id.helpwave.de", - "version": "0.1.14", + "version": "0.2.0", "repository": { "type": "git", "url": "git://github.com/helpwave/id.helpwave.de.git" diff --git a/src/account/KcContext.ts b/src/account/KcContext.ts index 25d257d..0865273 100644 --- a/src/account/KcContext.ts +++ b/src/account/KcContext.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-empty-object-type */ import type { ExtendKcContext } from 'keycloakify/account' import type { KcEnvName, ThemeName } from '../kc.gen' @@ -7,6 +6,11 @@ export type KcContextExtension = { properties: Record & {}, } -export type KcContextExtensionPerPage = {} +export type KcContextExtensionPerPage = { + 'account.ftl': { + profilePictureApiUrl?: string, + profilePictureUrl?: string, + }, +} export type KcContext = ExtendKcContext diff --git a/src/account/KcPageStory.tsx b/src/account/KcPageStory.tsx index 9a2c8c8..52442ab 100644 --- a/src/account/KcPageStory.tsx +++ b/src/account/KcPageStory.tsx @@ -11,7 +11,12 @@ const kcContextExtension: KcContextExtension = { ...kcEnvDefaults } } -const kcContextExtensionPerPage: KcContextExtensionPerPage = {} +const kcContextExtensionPerPage: KcContextExtensionPerPage = { + 'account.ftl': { + profilePictureApiUrl: '', + profilePictureUrl: undefined, + } +} export const { getKcContextMock } = createGetKcContextMock({ kcContextExtension, diff --git a/src/account/pages/AccountSettings.tsx b/src/account/pages/AccountSettings.tsx index fb9c73c..eb72f0f 100644 --- a/src/account/pages/AccountSettings.tsx +++ b/src/account/pages/AccountSettings.tsx @@ -1,11 +1,14 @@ -import { useState } from 'react' -import { Key, Save, Trash2 } from 'lucide-react' +import { useRef, useState } from 'react' +import { Key, Save, Trash2, Upload } from 'lucide-react' import { Avatar, Button, Chip, ConfirmDialog, DialogRoot, Input, FormFieldLayout } from '@helpwave/hightide' import type { KcContext } from '../KcContext' import { useTranslation } from '../../i18n/useTranslation' import { useTranslatedFieldError } from '../../login/utils/translateFieldError' import { AlertBox } from '../../login/components/AlertBox' +const MAX_PICTURE_BYTES = 5 * 1024 * 1024 +const ACCEPTED_PICTURE_TYPES = ['image/jpeg', 'image/png', 'image/webp'] + type AccountSettingsProps = { kcContext: Extract, } @@ -42,8 +45,67 @@ export default function AccountSettings({ kcContext }: AccountSettingsProps) { const [lastName, setLastName] = useState(account.lastName ?? '') const [deleteAccountDialogOpen, setDeleteAccountDialogOpen] = useState(false) + const profilePictureApiUrl = kcContext.profilePictureApiUrl ?? kcContext.properties?.PROFILE_PICTURE_API_URL ?? '' + const [pictureUrl, setPictureUrl] = useState(kcContext.profilePictureUrl) + const [pictureUploading, setPictureUploading] = useState(false) + const [pictureError, setPictureError] = useState() + const fileInputRef = useRef(null) + const displayName = getDisplayName(kcContext) - const avatarImage = undefined + const avatarImage = pictureUrl ? { avatarUrl: pictureUrl, alt: displayName || 'Profile picture' } : undefined + + const handlePictureSelect = async (file: File | undefined) => { + if (!file) return + setPictureError(undefined) + if (!ACCEPTED_PICTURE_TYPES.includes(file.type)) { + setPictureError(t('profilePictureWrongType')) + return + } + if (file.size > MAX_PICTURE_BYTES) { + setPictureError(t('profilePictureTooLarge')) + return + } + if (!profilePictureApiUrl) { + setPictureError(t('profilePictureUploadFailed')) + return + } + setPictureUploading(true) + try { + const body = new FormData() + body.append('file', file) + const res = await fetch(profilePictureApiUrl, { + method: 'POST', + body, + credentials: 'include', + }) + if (!res.ok) throw new Error(`upload failed: ${res.status}`) + const data = await res.json() as { url?: string } + if (data.url) setPictureUrl(data.url) + } catch { + setPictureError(t('profilePictureUploadFailed')) + } finally { + setPictureUploading(false) + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + + const handlePictureRemove = async () => { + if (!profilePictureApiUrl) return + setPictureError(undefined) + setPictureUploading(true) + try { + const res = await fetch(profilePictureApiUrl, { + method: 'DELETE', + credentials: 'include', + }) + if (!res.ok) throw new Error(`remove failed: ${res.status}`) + setPictureUrl(undefined) + } catch { + setPictureError(t('profilePictureUploadFailed')) + } finally { + setPictureUploading(false) + } + } const getFieldError = (fieldName: string) => messagesPerField.existsError(fieldName) ? messagesPerField.get(fieldName) : undefined @@ -233,13 +295,62 @@ export default function AccountSettings({ kcContext }: AccountSettingsProps) {
-
+

{t('profilePicture')}

-

- {t('profilePictureComingSoon')} -

+ {profilePictureApiUrl ? ( +
+
+ +
+ handlePictureSelect(e.target.files?.[0])} + /> +
+ + {pictureUrl && ( + + )} +
+

+ {t('profilePictureHelp')} +

+ {pictureError && ( +

+ {pictureError} +

+ )} +
+
+
+ ) : ( +

+ {t('profilePictureComingSoon')} +

+ )}
) diff --git a/src/i18n/translations.ts b/src/i18n/translations.ts index b9216e9..3a8e877 100644 --- a/src/i18n/translations.ts +++ b/src/i18n/translations.ts @@ -10,12 +10,15 @@ export const helpwaveIdTranslationLocales = ['de-DE', 'en-US'] as const export type HelpwaveIdTranslationLocales = typeof helpwaveIdTranslationLocales[number] export type HelpwaveIdTranslationEntries = { + 'acceptPrivacy': string, 'acceptTerms': string, 'accountSectionProfile': string, 'accountStatusActive': string, 'backToAccount': string, 'backToApplication': string, 'backToLogin': string, + 'captchaFailed': string, + 'captchaLoading': string, 'dangerZoneTitle': string, 'deleteAccountConfirm': string, 'deleteCredentialConfirm': string, @@ -138,12 +141,20 @@ export type HelpwaveIdTranslationEntries = { 'passwordSectionTitle': string, 'personalInfoTitle': string, 'privacy': string, + 'privacyPolicy': string, + 'privacyRequired': string, 'profilePicture': string, 'profilePictureComingSoon': string, + 'profilePictureHelp': string, + 'profilePictureTooLarge': string, + 'profilePictureUploadFailed': string, + 'profilePictureUploading': string, + 'profilePictureWrongType': string, 'recoveryAuthnCodeConfigMessage': string, 'recoveryCode': string, 'register': string, 'rememberMe': string, + 'removeProfilePicture': string, 'resetOtpMessage': string, 'samlPostFormMessage': string, 'securitySectionTitle': string, @@ -157,6 +168,7 @@ export type HelpwaveIdTranslationEntries = { 'successPasswordUpdated': string, 'termsText': string, 'updatePassword': string, + 'uploadProfilePicture': string, 'userCode': string, 'username': string, 'usernameOrEmail': string, @@ -169,12 +181,15 @@ export type HelpwaveIdTranslationEntries = { export const helpwaveIdTranslation: Translation> = { 'de-DE': { + 'acceptPrivacy': `Ich habe die`, 'acceptTerms': `Ich akzeptiere die Allgemeinen Geschäftsbedingungen`, 'accountSectionProfile': `Profil`, 'accountStatusActive': `Aktiv`, 'backToAccount': `Zurück zum Konto`, 'backToApplication': `Zurück zur Anwendung`, 'backToLogin': `Zurück zur Anmeldung`, + 'captchaFailed': `Captcha-Prüfung fehlgeschlagen. Bitte versuchen Sie es erneut.`, + 'captchaLoading': `Captcha wird geladen…`, 'dangerZoneTitle': `Gefahrenzone`, 'deleteAccountConfirm': `Möchten Sie Ihr Konto wirklich löschen?`, 'deleteCredentialConfirm': `Möchten Sie diese Anmeldedaten wirklich löschen?`, @@ -302,12 +317,20 @@ export const helpwaveIdTranslation: Translation = {}; +export const kcEnvDefaults: Record = { + TURNSTILE_SITE_KEY: "", + PROFILE_PICTURE_API_URL: "" +}; /** * NOTE: Do not import this type except maybe in your entrypoint. diff --git a/src/login/KcContext.ts b/src/login/KcContext.ts index 6c8d05a..bd5b8d3 100644 --- a/src/login/KcContext.ts +++ b/src/login/KcContext.ts @@ -1,4 +1,3 @@ -/* eslint-disable @typescript-eslint/no-empty-object-type */ import type { ExtendKcContext } from 'keycloakify/login' import type { KcEnvName, ThemeName } from '../kc.gen' @@ -9,6 +8,10 @@ export type KcContextExtension = { // See: https://docs.keycloakify.dev/faq-and-help/some-values-you-need-are-missing-from-in-kccontext }; -export type KcContextExtensionPerPage = {}; +export type KcContextExtensionPerPage = { + 'register.ftl': { + turnstileSiteKey?: string, + }, +}; export type KcContext = ExtendKcContext; diff --git a/src/login/KcPageStory.tsx b/src/login/KcPageStory.tsx index 968da66..a693609 100644 --- a/src/login/KcPageStory.tsx +++ b/src/login/KcPageStory.tsx @@ -11,7 +11,11 @@ const kcContextExtension: KcContextExtension = { ...kcEnvDefaults } } -const kcContextExtensionPerPage: KcContextExtensionPerPage = {} +const kcContextExtensionPerPage: KcContextExtensionPerPage = { + 'register.ftl': { + turnstileSiteKey: '', + } +} export const { getKcContextMock } = createGetKcContextMock({ kcContextExtension, diff --git a/src/login/pages/Register.tsx b/src/login/pages/Register.tsx index c356784..8760eda 100644 --- a/src/login/pages/Register.tsx +++ b/src/login/pages/Register.tsx @@ -1,4 +1,4 @@ -import { useState } from 'react' +import { useEffect, useRef, useState } from 'react' import { Button, Input, FormFieldLayout, Checkbox } from '@helpwave/hightide' import type { KcContext } from '../KcContext' import { useI18n } from '../i18n' @@ -15,6 +15,49 @@ type RegisterProps = { kcContext: Extract, }; +const TURNSTILE_SCRIPT_SRC = 'https://challenges.cloudflare.com/turnstile/v0/api.js' + +// Renders the Cloudflare Turnstile widget. The widget writes the token into a hidden +// input named `cf-turnstile-response`, which the FormAction SPI validates server-side. +function useTurnstile(siteKey: string | undefined, containerId: string) { + const [ready, setReady] = useState(false) + const [token, setToken] = useState(null) + const renderedRef = useRef(false) + + useEffect(() => { + if (!siteKey) return + if (document.querySelector(`script[src="${TURNSTILE_SCRIPT_SRC}"]`)) { + setReady(true) + return + } + const script = document.createElement('script') + script.src = TURNSTILE_SCRIPT_SRC + script.async = true + script.defer = true + script.onload = () => setReady(true) + document.head.appendChild(script) + }, [siteKey]) + + useEffect(() => { + if (!ready || !siteKey || renderedRef.current) return + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const turnstile = (window as any).turnstile + if (!turnstile) return + const el = document.getElementById(containerId) + if (!el) return + turnstile.render(`#${containerId}`, { + 'sitekey': siteKey, + 'callback': (t: string) => setToken(t), + 'error-callback': () => setToken(null), + 'expired-callback': () => setToken(null), + 'timeout-callback': () => setToken(null), + }) + renderedRef.current = true + }, [ready, siteKey, containerId]) + + return { ready, token } +} + export default function Register({ kcContext }: RegisterProps) { const { i18n } = useI18n({ kcContext }) const t = useTranslation() @@ -23,6 +66,9 @@ export default function Register({ kcContext }: RegisterProps) { const profile = kcContext.profile const attributes = profile?.attributesByName ?? {} + const turnstileSiteKey = kcContext.turnstileSiteKey ?? kcContext.properties?.TURNSTILE_SITE_KEY ?? '' + const captchaEnabled = !!turnstileSiteKey + const [formData, setFormData] = useState>(() => { const initial: Record = {} Object.values(attributes).forEach((attr) => { @@ -33,6 +79,11 @@ export default function Register({ kcContext }: RegisterProps) { return initial }) const [termsAccepted, setTermsAccepted] = useState(false) + const [privacyAccepted, setPrivacyAccepted] = useState(false) + const [privacyError, setPrivacyError] = useState(false) + const [captchaError, setCaptchaError] = useState(false) + + const { token: captchaToken } = useTurnstile(turnstileSiteKey, 'cf-turnstile-container') const getFieldLabel = (attrName: string, displayName: string | undefined): string => { const keyMap: Record = { @@ -55,6 +106,19 @@ export default function Register({ kcContext }: RegisterProps) { : undefined } + const handleSubmit = (e: React.FormEvent) => { + let ok = true + if (!privacyAccepted) { + setPrivacyError(true) + ok = false + } + if (captchaEnabled && !captchaToken) { + setCaptchaError(true) + ok = false + } + if (!ok) e.preventDefault() + } + const renderField = (attrName: string) => { const attr = attributes[attrName] if (!attr) return null @@ -106,6 +170,7 @@ export default function Register({ kcContext }: RegisterProps) { id="kc-register-form" action={kcContext.url.registrationAction} method="post" + onSubmit={handleSubmit} style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }} > {Object.keys(attributes).map((attrName) => { @@ -187,6 +252,66 @@ export default function Register({ kcContext }: RegisterProps) { )} +
+
+ { + setPrivacyAccepted(v) + if (v) setPrivacyError(false) + }} + onEditComplete={() => {}} + size="md" + /> + + +
+ {privacyError && ( +
+ {t('privacyRequired')} +
+ )} +
+ + {captchaEnabled && ( +
+
+ {captchaError && !captchaToken && ( +
+ {t('captchaFailed')} +
+ )} +
+ )} +