diff --git a/README.md b/README.md index dd5a033..039d089 100644 --- a/README.md +++ b/README.md @@ -46,7 +46,7 @@ This produces three jars (each plugin lives in its own folder so it can be built independently): - `captcha/target/helpwave-captcha-.jar` -- `privacy/target/helpwave-privacy-.jar` +- `policy-acceptance/target/helpwave-policy-acceptance-.jar` - `picture/target/helpwave-picture-.jar` (shaded with AWS SDK + Thumbnailator) Drop all three (alongside the theme jar) into Keycloak's `providers/` directory and run @@ -91,8 +91,9 @@ For nixos users, see [docs/nixos.md](docs/nixos.md) for nix-shell setup instruct - 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` FormAction SPI) +- **Versioned policy consents** (privacy + future forms) via a reusable RequiredAction + SPI (`helpwave-policy-acceptance`). Bumping the version on the realm re-prompts every + user on next login; acceptance metadata is persisted on the user account. - **Profile picture upload** with server-side scaling to multiple sizes and storage in any S3-compatible bucket (`helpwave-picture` Realm Resource SPI) @@ -106,28 +107,42 @@ The release workflow publishes the following jars on every version bump in `pack |--------------------------------------------------|--------------------------------------------------| | `keycloak-theme-for-kc-26.2-and-above.jar` | The login/account theme | | `helpwave-captcha-.jar` | Cloudflare Turnstile registration form action | -| `helpwave-privacy-.jar` | Privacy acceptance form action + attribute store | +| `helpwave-policy-acceptance-.jar` | Versioned policy consents (privacy + future) | | `helpwave-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. Enable the Cloudflare Turnstile form action 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*: +3. In your new copy, add an execution 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: +4. Click the gear on the 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 +### 2. Enable the policy-acceptance required actions + +1. **Authentication** → **Required actions** → enable + **Privacy Policy Acceptance (helpwave)**. +2. **Realm settings** → **General** → **Attributes**: set + - `helpwave.policy.privacy.url` (defaults to `https://helpwave.de/privacy`) + - `helpwave.policy.privacy.version` (defaults to `2024-01`) +3. On first login after registration, users are prompted to accept the policy. Their + acceptance is stored on the user as `privacy_policy_accepted`, + `privacy_policy_accepted_at` and `privacy_policy_version`. Bumping the realm version + re-prompts every user on their next login. + +Add more consent forms (e.g. terms of service, data-processing agreement) by adding a new +`AbstractPolicyAcceptanceRequiredActionFactory` subclass to +`keycloak-extensions/policy-acceptance/` and registering it in the `META-INF/services/` +file. The React `Terms.tsx` page renders the policy variant automatically when a +`policyId` attribute is set. + +### 3. Configure the profile picture storage The profile picture SPI accepts standard AWS S3 or Cloudflare R2 (any S3-compatible backend). It exposes itself at: diff --git a/docs/deployment-nixos.md b/docs/deployment-nixos.md index 291cd27..d7b4122 100644 --- a/docs/deployment-nixos.md +++ b/docs/deployment-nixos.md @@ -14,10 +14,10 @@ Every release attaches the following artifacts to the GitHub release: |--------------------------------------------|----------------------------------------|-----------------------------------------------------------| | `keycloak-theme-for-kc-26.2-and-above.jar` | (root, built by Keycloakify) | Login + account theme `helpwave-id`. | | `helpwave-captcha-.jar` | `captcha/` | `FormAction` SPI: Cloudflare Turnstile CAPTCHA on signup. | -| `helpwave-privacy-.jar` | `privacy/` | `FormAction` SPI: privacy checkbox + acceptance attrs. | +| `helpwave-policy-acceptance-.jar` | `policy-acceptance/` | `RequiredAction` SPI: versioned policy consents (privacy + future forms). | | `helpwave-picture-.jar` | `picture/` | `RealmResourceProvider` SPI: avatar upload to S3 / R2. | -`` is the SPI Maven version (`keycloak-extensions/pom.xml`, currently `0.2.0`), +`` is the SPI Maven version (`keycloak-extensions/pom.xml`, currently `0.3.0`), which is independent from the theme/npm version in `package.json`. All four jars go into Keycloak's `providers/` directory — `services.keycloak.plugins` does that for you. @@ -28,8 +28,8 @@ Keycloak's `providers/` directory — `services.keycloak.plugins` does that for let domain = "id.helpwave.de"; - themeVersion = "0.5.0"; # ⇄ package.json version → release tag v0.5.0 - spiVersion = "0.2.0"; # ⇄ keycloak-extensions/pom.xml + themeVersion = "0.6.0"; # ⇄ package.json version → release tag v0.6.0 + spiVersion = "0.3.0"; # ⇄ keycloak-extensions/pom.xml release = file: sha: pkgs.fetchurl { @@ -42,7 +42,7 @@ let "sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; captchaPlugin = release "helpwave-captcha-${spiVersion}.jar" "sha256-BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB="; - privacyPlugin = release "helpwave-privacy-${spiVersion}.jar" + policyPlugin = release "helpwave-policy-acceptance-${spiVersion}.jar" "sha256-CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC="; picturePlugin = release "helpwave-picture-${spiVersion}.jar" "sha256-DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD="; @@ -72,7 +72,7 @@ in plugins = [ themePlugin captchaPlugin - privacyPlugin + policyPlugin picturePlugin ]; @@ -125,15 +125,34 @@ in > Or just run `nixos-rebuild switch` once with all four `sha256-AAA…` placeholders, copy > each `got: sha256-…` line out of the error message, and paste it back. -## 3. Wiring the auth flow (Turnstile + privacy) +## 3. Wiring the auth flow (Turnstile + policy consents) -The captcha and privacy SPIs plug into the *registration form* flow as `FormAction`s. -Their config is **not** read from `keycloak.conf` — it's per-flow so different realms -can have different keys. +### Turnstile (registration FormAction) + +Turnstile plugs into the *registration form* flow as a `FormAction`. Its config is **not** +read from `keycloak.conf` — it's per-flow so different realms can have different keys. + +### Policy acceptance (RequiredAction) + +The privacy consent (and any future policy form) is a `RequiredAction`, not a registration +form field. The provider auto-evaluates on every login: if the user's stored policy +version is missing or differs from the version configured on the realm, the user is +prompted to re-accept before the login completes. Bumping a policy version is therefore a +one-line realm-attribute change — no migration, no flow surgery. + +User attributes the provider writes on success: + +``` +_policy_accepted = "true" +_policy_accepted_at = ISO-8601 timestamp +_policy_version = the version the user accepted +``` + +…where `` is the policy id (`privacy` for now). ### Option A — Realm export (preferred, declarative) -Ship the flow as part of a realm JSON and load it via +Ship the flow + required-action enablement as part of a realm JSON and load it via `services.keycloak.realmFiles = [ ./customer-realm.json ];`. The interesting bits: ```json @@ -160,9 +179,7 @@ Ship the flow as part of a realm JSON and load it via { "authenticator": "registration-user-creation", "requirement": "REQUIRED" }, { "authenticator": "registration-password-action", "requirement": "REQUIRED" }, { "authenticator": "helpwave-turnstile", "requirement": "REQUIRED", - "authenticatorConfig": "turnstile-config" }, - { "authenticator": "helpwave-privacy-acceptance", "requirement": "REQUIRED", - "authenticatorConfig": "privacy-config" } + "authenticatorConfig": "turnstile-config" } ] } ], @@ -173,15 +190,21 @@ Ship the flow as part of a realm JSON and load it via "turnstile.site.key": "0x4AAAAAAAxxxxxxxxxxxxxxxx", "turnstile.secret": "$${env.TURNSTILE_SECRET}" } - }, + } + ], + "requiredActions": [ { - "alias": "privacy-config", - "config": { - "privacy.policy.url": "https://helpwave.de/privacy", - "privacy.policy.version": "2024-01" - } + "alias": "helpwave-privacy-acceptance", + "name": "Privacy Policy Acceptance (helpwave)", + "providerId": "helpwave-privacy-acceptance", + "enabled": true, + "defaultAction": false } ], + "attributes": { + "helpwave.policy.privacy.url": "https://helpwave.de/privacy", + "helpwave.policy.privacy.version": "2024-01" + }, "registrationFlow": "registration-helpwave" } ``` @@ -192,7 +215,11 @@ disk in cleartext or in the Nix store. ### Option B — Click through the admin console -Same steps as the manual setup section in [README.md](../README.md#1-enable-the-cloudflare-turnstile-and-privacy-form-actions). +1. `Authentication → Required actions →` enable **Privacy Policy Acceptance (helpwave)**. +2. `Realm settings → General → Attributes` (or via the admin API): set + `helpwave.policy.privacy.url` and `helpwave.policy.privacy.version`. + +See also the manual setup section in [README.md](../README.md#1-enable-the-cloudflare-turnstile-and-privacy-form-actions). ## 4. Example `.env` (for local dev with `docker compose`) diff --git a/keycloak-extensions/captcha/pom.xml b/keycloak-extensions/captcha/pom.xml index 4cc9558..1dd66ee 100644 --- a/keycloak-extensions/captcha/pom.xml +++ b/keycloak-extensions/captcha/pom.xml @@ -7,7 +7,7 @@ de.helpwave.keycloak helpwave-keycloak-extensions - 0.2.0 + 0.3.0 helpwave-captcha diff --git a/keycloak-extensions/picture/pom.xml b/keycloak-extensions/picture/pom.xml index 0652259..11bde81 100644 --- a/keycloak-extensions/picture/pom.xml +++ b/keycloak-extensions/picture/pom.xml @@ -7,7 +7,7 @@ de.helpwave.keycloak helpwave-keycloak-extensions - 0.2.0 + 0.3.0 helpwave-picture diff --git a/keycloak-extensions/privacy/pom.xml b/keycloak-extensions/policy-acceptance/pom.xml similarity index 73% rename from keycloak-extensions/privacy/pom.xml rename to keycloak-extensions/policy-acceptance/pom.xml index 2beb3e9..2cad690 100644 --- a/keycloak-extensions/privacy/pom.xml +++ b/keycloak-extensions/policy-acceptance/pom.xml @@ -7,12 +7,17 @@ de.helpwave.keycloak helpwave-keycloak-extensions - 0.2.0 + 0.3.0 - helpwave-privacy + helpwave-policy-acceptance jar - helpwave Privacy Acceptance Form Action + helpwave Policy Acceptance (Required Action) + + Reusable Keycloak Required Action SPI that asks the user to accept a versioned + policy document (privacy, terms, data processing, ...). Stores acceptance metadata + on the user account and re-prompts when the version configured on the realm changes. + diff --git a/keycloak-extensions/policy-acceptance/src/main/java/de/helpwave/keycloak/policy/AbstractPolicyAcceptanceRequiredActionFactory.java b/keycloak-extensions/policy-acceptance/src/main/java/de/helpwave/keycloak/policy/AbstractPolicyAcceptanceRequiredActionFactory.java new file mode 100644 index 0000000..e024bc6 --- /dev/null +++ b/keycloak-extensions/policy-acceptance/src/main/java/de/helpwave/keycloak/policy/AbstractPolicyAcceptanceRequiredActionFactory.java @@ -0,0 +1,46 @@ +package de.helpwave.keycloak.policy; + +import org.keycloak.Config; +import org.keycloak.authentication.RequiredActionFactory; +import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; + +/** + * Base factory for a single concrete policy. Add a new consent by extending this class, + * defining a {@link PolicyDefinition} constant, and registering the subclass in + * {@code META-INF/services/org.keycloak.authentication.RequiredActionFactory}. + */ +public abstract class AbstractPolicyAcceptanceRequiredActionFactory implements RequiredActionFactory { + + protected abstract PolicyDefinition policy(); + + @Override + public RequiredActionProvider create(KeycloakSession session) { + return new PolicyAcceptanceRequiredAction(policy()); + } + + @Override + public String getId() { + return policy().providerId(); + } + + @Override + public String getDisplayText() { + return policy().displayText(); + } + + @Override + public boolean isOneTimeAction() { + return false; + } + + @Override + public void init(Config.Scope config) { } + + @Override + public void postInit(KeycloakSessionFactory factory) { } + + @Override + public void close() { } +} diff --git a/keycloak-extensions/policy-acceptance/src/main/java/de/helpwave/keycloak/policy/PolicyAcceptanceRequiredAction.java b/keycloak-extensions/policy-acceptance/src/main/java/de/helpwave/keycloak/policy/PolicyAcceptanceRequiredAction.java new file mode 100644 index 0000000..74dd6f7 --- /dev/null +++ b/keycloak-extensions/policy-acceptance/src/main/java/de/helpwave/keycloak/policy/PolicyAcceptanceRequiredAction.java @@ -0,0 +1,112 @@ +package de.helpwave.keycloak.policy; + +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import org.keycloak.authentication.RequiredActionContext; +import org.keycloak.authentication.RequiredActionProvider; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; + +import java.time.Instant; + +/** + * Renders a single-checkbox consent page for the configured {@link PolicyDefinition} and + * stores the resulting acceptance metadata on the {@link UserModel}. + * + *

Triggering logic: {@link #evaluateTriggers(RequiredActionContext)} adds the action to + * the user whenever the stored version differs from the version configured on the realm + * (or no version is stored yet). This means rolling out a new policy version is a one-line + * change to a realm attribute — every user gets re-prompted on their next login. + * + *

The challenge form reuses Keycloak's {@code terms.ftl} template. The React Terms page + * picks up the {@code policyId}, {@code policyUrl}, etc. attributes and renders the + * policy-specific UI instead of the built-in terms text. + */ +public class PolicyAcceptanceRequiredAction implements RequiredActionProvider { + + static final String FORM_FIELD = "policy-accepted"; + + private static final String ATTR_POLICY_ID = "policyId"; + private static final String ATTR_POLICY_URL = "policyUrl"; + private static final String ATTR_POLICY_VERSION = "policyVersion"; + private static final String ATTR_POLICY_LABEL_KEY = "policyAcceptanceLabelKey"; + private static final String ATTR_POLICY_LINK_KEY = "policyLinkLabelKey"; + private static final String ATTR_POLICY_REQUIRED_ERROR_KEY = "policyRequiredErrorKey"; + + private final PolicyDefinition policy; + + public PolicyAcceptanceRequiredAction(PolicyDefinition policy) { + this.policy = policy; + } + + @Override + public void evaluateTriggers(RequiredActionContext context) { + UserModel user = context.getUser(); + if (user == null) return; + String currentVersion = currentVersion(context.getRealm()); + String acceptedVersion = user.getFirstAttribute(policy.attrVersion()); + boolean accepted = "true".equalsIgnoreCase(user.getFirstAttribute(policy.attrAccepted())); + if (!accepted || acceptedVersion == null || !acceptedVersion.equals(currentVersion)) { + user.addRequiredAction(policy.providerId()); + } + } + + @Override + public void requiredActionChallenge(RequiredActionContext context) { + RealmModel realm = context.getRealm(); + LoginFormsProvider form = context.form() + .setAttribute(ATTR_POLICY_ID, policy.id()) + .setAttribute(ATTR_POLICY_URL, currentUrl(realm)) + .setAttribute(ATTR_POLICY_VERSION, currentVersion(realm)) + .setAttribute(ATTR_POLICY_LABEL_KEY, policy.acceptanceLabelMessageKey()) + .setAttribute(ATTR_POLICY_LINK_KEY, policy.linkLabelMessageKey()) + .setAttribute(ATTR_POLICY_REQUIRED_ERROR_KEY, policy.requiredErrorMessageKey()); + Response challenge = form.createForm("terms.ftl"); + context.challenge(challenge); + } + + @Override + public void processAction(RequiredActionContext context) { + MultivaluedMap formParams = context.getHttpRequest().getDecodedFormParameters(); + String accepted = formParams.getFirst(FORM_FIELD); + if (!"true".equalsIgnoreCase(accepted) && !"on".equalsIgnoreCase(accepted)) { + // Render the form again with an error flag so the React component can surface it. + RealmModel realm = context.getRealm(); + Response challenge = context.form() + .setAttribute(ATTR_POLICY_ID, policy.id()) + .setAttribute(ATTR_POLICY_URL, currentUrl(realm)) + .setAttribute(ATTR_POLICY_VERSION, currentVersion(realm)) + .setAttribute(ATTR_POLICY_LABEL_KEY, policy.acceptanceLabelMessageKey()) + .setAttribute(ATTR_POLICY_LINK_KEY, policy.linkLabelMessageKey()) + .setAttribute(ATTR_POLICY_REQUIRED_ERROR_KEY, policy.requiredErrorMessageKey()) + .setAttribute("policyRequiredError", true) + .setError(policy.requiredErrorMessageKey()) + .createForm("terms.ftl"); + context.challenge(challenge); + return; + } + + UserModel user = context.getUser(); + user.setSingleAttribute(policy.attrAccepted(), "true"); + user.setSingleAttribute(policy.attrAcceptedAt(), Instant.now().toString()); + user.setSingleAttribute(policy.attrVersion(), currentVersion(context.getRealm())); + user.removeRequiredAction(policy.providerId()); + context.success(); + } + + @Override + public void close() { } + + private String currentUrl(RealmModel realm) { + String override = realm.getAttribute(policy.realmAttrUrl()); + if (override != null && !override.isBlank()) return override; + return policy.defaultUrl(); + } + + private String currentVersion(RealmModel realm) { + String override = realm.getAttribute(policy.realmAttrVersion()); + if (override != null && !override.isBlank()) return override; + return policy.defaultVersion(); + } +} diff --git a/keycloak-extensions/policy-acceptance/src/main/java/de/helpwave/keycloak/policy/PolicyDefinition.java b/keycloak-extensions/policy-acceptance/src/main/java/de/helpwave/keycloak/policy/PolicyDefinition.java new file mode 100644 index 0000000..7e69e21 --- /dev/null +++ b/keycloak-extensions/policy-acceptance/src/main/java/de/helpwave/keycloak/policy/PolicyDefinition.java @@ -0,0 +1,76 @@ +package de.helpwave.keycloak.policy; + +/** + * Immutable description of a policy that users must accept. Each policy gets its own + * provider id, set of user attributes and pair of realm attributes for runtime config + * (URL + version). Add a new policy by constructing another {@code PolicyDefinition} + * and exposing it through a dedicated {@link AbstractPolicyAcceptanceRequiredActionFactory}. + */ +public final class PolicyDefinition { + + private final String id; + private final String providerId; + private final String displayText; + private final String defaultUrl; + private final String defaultVersion; + private final String acceptanceLabelMessageKey; + private final String linkLabelMessageKey; + private final String requiredErrorMessageKey; + + public PolicyDefinition( + String id, + String providerId, + String displayText, + String defaultUrl, + String defaultVersion, + String acceptanceLabelMessageKey, + String linkLabelMessageKey, + String requiredErrorMessageKey + ) { + this.id = id; + this.providerId = providerId; + this.displayText = displayText; + this.defaultUrl = defaultUrl; + this.defaultVersion = defaultVersion; + this.acceptanceLabelMessageKey = acceptanceLabelMessageKey; + this.linkLabelMessageKey = linkLabelMessageKey; + this.requiredErrorMessageKey = requiredErrorMessageKey; + } + + /** Short, code-friendly identifier, e.g. {@code "privacy"}. Used as attribute prefix. */ + public String id() { return id; } + + /** Keycloak required-action provider id, e.g. {@code "helpwave-privacy-acceptance"}. */ + public String providerId() { return providerId; } + + /** Label shown to admins in Keycloak's required-action list. */ + public String displayText() { return displayText; } + + public String defaultUrl() { return defaultUrl; } + + public String defaultVersion() { return defaultVersion; } + + /** i18n key for the checkbox label, e.g. {@code "acceptPrivacy"}. */ + public String acceptanceLabelMessageKey() { return acceptanceLabelMessageKey; } + + /** i18n key for the link text next to the checkbox, e.g. {@code "privacyPolicy"}. */ + public String linkLabelMessageKey() { return linkLabelMessageKey; } + + /** i18n key for the validation error when the checkbox is not ticked. */ + public String requiredErrorMessageKey() { return requiredErrorMessageKey; } + + /** User attribute that flags acceptance. */ + public String attrAccepted() { return id + "_policy_accepted"; } + + /** User attribute that stores the ISO-8601 acceptance timestamp. */ + public String attrAcceptedAt() { return id + "_policy_accepted_at"; } + + /** User attribute that stores the policy version that was accepted. */ + public String attrVersion() { return id + "_policy_version"; } + + /** Realm attribute that overrides {@link #defaultUrl()}. */ + public String realmAttrUrl() { return "helpwave.policy." + id + ".url"; } + + /** Realm attribute that overrides {@link #defaultVersion()}. */ + public String realmAttrVersion() { return "helpwave.policy." + id + ".version"; } +} diff --git a/keycloak-extensions/policy-acceptance/src/main/java/de/helpwave/keycloak/policy/privacy/PrivacyPolicyRequiredActionFactory.java b/keycloak-extensions/policy-acceptance/src/main/java/de/helpwave/keycloak/policy/privacy/PrivacyPolicyRequiredActionFactory.java new file mode 100644 index 0000000..1408fa5 --- /dev/null +++ b/keycloak-extensions/policy-acceptance/src/main/java/de/helpwave/keycloak/policy/privacy/PrivacyPolicyRequiredActionFactory.java @@ -0,0 +1,23 @@ +package de.helpwave.keycloak.policy.privacy; + +import de.helpwave.keycloak.policy.AbstractPolicyAcceptanceRequiredActionFactory; +import de.helpwave.keycloak.policy.PolicyDefinition; + +public class PrivacyPolicyRequiredActionFactory extends AbstractPolicyAcceptanceRequiredActionFactory { + + public static final PolicyDefinition POLICY = new PolicyDefinition( + "privacy", + "helpwave-privacy-acceptance", + "Privacy Policy Acceptance (helpwave)", + "https://helpwave.de/privacy", + "2024-01", + "acceptPrivacy", + "privacyPolicy", + "privacyRequired" + ); + + @Override + protected PolicyDefinition policy() { + return POLICY; + } +} diff --git a/keycloak-extensions/policy-acceptance/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory b/keycloak-extensions/policy-acceptance/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory new file mode 100644 index 0000000..12ec309 --- /dev/null +++ b/keycloak-extensions/policy-acceptance/src/main/resources/META-INF/services/org.keycloak.authentication.RequiredActionFactory @@ -0,0 +1 @@ +de.helpwave.keycloak.policy.privacy.PrivacyPolicyRequiredActionFactory diff --git a/keycloak-extensions/pom.xml b/keycloak-extensions/pom.xml index de88e97..2a43de9 100644 --- a/keycloak-extensions/pom.xml +++ b/keycloak-extensions/pom.xml @@ -6,14 +6,14 @@ de.helpwave.keycloak helpwave-keycloak-extensions - 0.2.0 + 0.3.0 pom helpwave Keycloak Extensions Keycloak SPI extensions used by id.helpwave.de captcha - privacy + policy-acceptance picture diff --git a/keycloak-extensions/privacy/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java b/keycloak-extensions/privacy/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java deleted file mode 100644 index 09f8c6b..0000000 --- a/keycloak-extensions/privacy/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormAction.java +++ /dev/null @@ -1,89 +0,0 @@ -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/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java b/keycloak-extensions/privacy/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java deleted file mode 100644 index 88800e8..0000000 --- a/keycloak-extensions/privacy/src/main/java/de/helpwave/keycloak/privacy/PrivacyAcceptanceFormActionFactory.java +++ /dev/null @@ -1,77 +0,0 @@ -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/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory b/keycloak-extensions/privacy/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory deleted file mode 100644 index 009c5b7..0000000 --- a/keycloak-extensions/privacy/src/main/resources/META-INF/services/org.keycloak.authentication.FormActionFactory +++ /dev/null @@ -1 +0,0 @@ -de.helpwave.keycloak.privacy.PrivacyAcceptanceFormActionFactory diff --git a/package-lock.json b/package-lock.json index a8e002c..0434fad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "id.helpwave.de", - "version": "0.1.13", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "id.helpwave.de", - "version": "0.1.13", + "version": "0.6.0", "hasInstallScript": true, "license": "MPL-2", "dependencies": { @@ -81,7 +81,6 @@ "integrity": "sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.28.6", "@babel/generator": "^7.28.6", @@ -311,6 +310,7 @@ "integrity": "sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -1044,7 +1044,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz", "integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -2584,7 +2583,8 @@ "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/@types/babel__core": { "version": "7.20.5", @@ -2750,7 +2750,6 @@ "integrity": "sha512-BtE0k6cjwjLZoZixN0t5AKP0kSzlGu7FctRXYuPAm//aaiZhmfq1JwdYpYr1brzEspYyFeF+8XF5j2VK6oalrA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.54.0", "@typescript-eslint/types": "8.54.0", @@ -3034,7 +3033,6 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3348,7 +3346,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3751,7 +3748,8 @@ "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/eastasianwidth": { "version": "0.2.0", @@ -3971,7 +3969,6 @@ "dev": true, "hasInstallScript": true, "license": "MIT", - "peer": true, "bin": { "esbuild": "bin/esbuild" }, @@ -4036,7 +4033,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5781,6 +5777,7 @@ "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", "dev": true, "license": "MIT", + "peer": true, "bin": { "lz-string": "bin/bin.js" } @@ -6250,6 +6247,7 @@ "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", @@ -6265,6 +6263,7 @@ "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "dev": true, "license": "MIT", + "peer": true, "engines": { "node": ">=10" }, @@ -6277,7 +6276,8 @@ "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "dev": true, - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/prop-types": { "version": "15.8.1", @@ -6327,7 +6327,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6372,7 +6371,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -6531,7 +6529,6 @@ "integrity": "sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/estree": "1.0.8" }, @@ -6781,7 +6778,6 @@ "integrity": "sha512-885uSIn8NQw2ZG7vy84K45lHCOSyz1DVsDV8pHiHQj3J0riCuWLNeO50lK9z98zE8kjhgTtxAAkMTy5nkmNRKQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@storybook/global": "^5.0.0", "@storybook/icons": "^2.0.1", @@ -7128,7 +7124,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -7316,7 +7311,6 @@ "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7451,7 +7445,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -7545,7 +7538,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, diff --git a/package.json b/package.json index c7f38e2..3087185 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "id.helpwave.de", - "version": "0.5.0", + "version": "0.6.0", "repository": { "type": "git", "url": "git://github.com/helpwave/id.helpwave.de.git" diff --git a/src/login/KcContext.ts b/src/login/KcContext.ts index bd5b8d3..1cc7e28 100644 --- a/src/login/KcContext.ts +++ b/src/login/KcContext.ts @@ -12,6 +12,18 @@ export type KcContextExtensionPerPage = { 'register.ftl': { turnstileSiteKey?: string, }, + 'terms.ftl': { + // Set by the helpwave-policy-acceptance Required Action. When present, the page + // renders policy-specific UI (a link to the policy + an accept checkbox) instead + // of the built-in terms-and-conditions text. + policyId?: string, + policyUrl?: string, + policyVersion?: string, + policyAcceptanceLabelKey?: string, + policyLinkLabelKey?: string, + policyRequiredErrorKey?: string, + policyRequiredError?: boolean, + }, }; export type KcContext = ExtendKcContext; diff --git a/src/login/KcPageStory.tsx b/src/login/KcPageStory.tsx index a693609..128f8d7 100644 --- a/src/login/KcPageStory.tsx +++ b/src/login/KcPageStory.tsx @@ -14,7 +14,8 @@ const kcContextExtension: KcContextExtension = { const kcContextExtensionPerPage: KcContextExtensionPerPage = { 'register.ftl': { turnstileSiteKey: '', - } + }, + 'terms.ftl': {} } export const { getKcContextMock } = createGetKcContextMock({ diff --git a/src/login/components/Branding.tsx b/src/login/components/Branding.tsx index f111f34..00ab64b 100644 --- a/src/login/components/Branding.tsx +++ b/src/login/components/Branding.tsx @@ -1,13 +1,30 @@ +import { useEffect, useState } from 'react' import { HelpwaveLogo } from '@helpwave/hightide' type BrandingProps = { animate?: 'none' | 'loading', } +const ANIMATION_START_DELAY_MS = 3000 + export function Branding({ animate = 'loading' }: BrandingProps) { + const [effectiveAnimate, setEffectiveAnimate] = useState<'none' | 'loading'>( + animate === 'loading' ? 'none' : animate + ) + + useEffect(() => { + if (animate !== 'loading') { + setEffectiveAnimate(animate) + return + } + setEffectiveAnimate('none') + const timeout = window.setTimeout(() => setEffectiveAnimate('loading'), ANIMATION_START_DELAY_MS) + return () => window.clearTimeout(timeout) + }, [animate]) + return (
- +
helpwave id
diff --git a/src/login/components/Footer.tsx b/src/login/components/Footer.tsx index 9f69319..ff0a2e6 100644 --- a/src/login/components/Footer.tsx +++ b/src/login/components/Footer.tsx @@ -7,7 +7,7 @@ export function Footer() {
diff --git a/src/login/pages/Register.tsx b/src/login/pages/Register.tsx index 8760eda..a125495 100644 --- a/src/login/pages/Register.tsx +++ b/src/login/pages/Register.tsx @@ -79,8 +79,6 @@ 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') @@ -107,16 +105,10 @@ export default function Register({ kcContext }: RegisterProps) { } const handleSubmit = (e: React.FormEvent) => { - let ok = true - if (!privacyAccepted) { - setPrivacyError(true) - ok = false - } if (captchaEnabled && !captchaToken) { setCaptchaError(true) - ok = false + e.preventDefault() } - if (!ok) e.preventDefault() } const renderField = (attrName: string) => { @@ -252,52 +244,6 @@ export default function Register({ kcContext }: RegisterProps) {
)} -
- {captchaEnabled && (
, }; +type PolicyKcContext = Extract + +function isTranslationKey(key: string | undefined): key is keyof HelpwaveIdTranslationEntries { + return key !== undefined && key.length > 0 +} + +function PolicyBody({ kcContext }: { kcContext: PolicyKcContext }) { + const t = useTranslation() + const labelKey = kcContext.policyAcceptanceLabelKey + const labelText = isTranslationKey(labelKey) ? t(labelKey) : '' + return ( +
+

{labelText}

+ {kcContext.policyUrl && ( +

+ + {kcContext.policyUrl} + +

+ )} +
+ ) +} + +function PolicyCheckboxLabel({ kcContext }: { kcContext: PolicyKcContext }) { + const t = useTranslation() + const labelKey = kcContext.policyAcceptanceLabelKey + const linkKey = kcContext.policyLinkLabelKey + const labelText = isTranslationKey(labelKey) ? t(labelKey) : '' + const linkText = isTranslationKey(linkKey) ? t(linkKey) : kcContext.policyUrl ?? '' + return ( + <> + {labelText}{' '} + {kcContext.policyUrl ? ( + e.stopPropagation()} + > + {linkText} + + ) : ( + linkText + )} + + ) +} + export default function Terms({ kcContext }: TermsProps) { const { i18n } = useI18n({ kcContext }) const t = useTranslation() const [accepted, setAccepted] = useState(false) + const policyId = kcContext.policyId + const isPolicyVariant = !!policyId + return ( ) -} \ No newline at end of file +}