From 6fa5801218afa0d8f66e4eb81b70deab3e0f2809 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?F=C3=A9lix=20Robles?= Date: Sat, 25 Jul 2026 08:25:12 -0500 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Voting=20Portal:=204=20PIN=20Lo?= =?UTF-8?q?gin=20Page=20(#2909)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parent issue: https://github.com/sequentech/meta/issues/12622 --- .github/workflows/tests.yml | 2 +- .../06-keycloak/structured_pin_login.md | 169 +++++ packages/Dockerfile.keycloak | 1 + .../UsernamePasswordFormWithExpiry.java | 5 +- .../sequent-theme/package.json | 12 + .../sequent-theme/package.json.license | 3 + .../login/messages/messages_ca.properties | 8 + .../login/messages/messages_en.properties | 8 + .../login/messages/messages_es.properties | 8 + .../login/messages/messages_eu.properties | 10 +- .../login/messages/messages_fr.properties | 12 +- .../login/messages/messages_gl.properties | 10 +- .../login/messages/messages_nl.properties | 10 +- .../login/messages/messages_tl.properties | 8 + .../sequent.admin-portal/login/register.ftl | 45 +- .../login/resources/css/custom.css | 102 +++ .../resources/js/structured-credential.js | 707 ++++++++++++++++++ .../sequent.voting-portal/login/login.ftl | 51 +- .../keycloak/theme/LoginTemplateTest.java | 69 ++ .../keycloak/theme/MessageBundleTest.java | 76 ++ .../keycloak/theme/RegisterTemplateTest.java | 45 ++ .../theme/StructuredCredentialAssetTest.java | 91 +++ .../keycloak/theme/TemplateSyntaxTest.java | 198 +++++ .../test/js/structured-credential.test.cjs | 430 +++++++++++ .../voter-enrollment/pom.xml | 6 + .../DeferredRegistrationUserCreation.java | 195 ++++- .../DeferredRegistrationUserCreationTest.java | 433 +++++++++++ packages/package.json | 1 + .../src/services/keycloak/realm_attributes.rs | 103 +++ packages/sequent-core/src/types/keycloak.rs | 30 + 30 files changed, 2779 insertions(+), 69 deletions(-) create mode 100644 docs/docusaurus/docs/07-developers/06-keycloak/structured_pin_login.md create mode 100644 packages/keycloak-extensions/sequent-theme/package.json create mode 100644 packages/keycloak-extensions/sequent-theme/package.json.license create mode 100644 packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/resources/js/structured-credential.js create mode 100644 packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/LoginTemplateTest.java create mode 100644 packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/MessageBundleTest.java create mode 100644 packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/StructuredCredentialAssetTest.java create mode 100644 packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/TemplateSyntaxTest.java create mode 100644 packages/keycloak-extensions/sequent-theme/src/test/js/structured-credential.test.cjs diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 564692a89be..530acba3896 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -103,7 +103,7 @@ jobs: timeout-minutes: 20 strategy: matrix: - package: [ui-core, ui-essentials] + package: [ui-core, ui-essentials, keycloak-extensions/sequent-theme] fail-fast: false steps: - name: Check out code diff --git a/docs/docusaurus/docs/07-developers/06-keycloak/structured_pin_login.md b/docs/docusaurus/docs/07-developers/06-keycloak/structured_pin_login.md new file mode 100644 index 00000000000..9e22811f400 --- /dev/null +++ b/docs/docusaurus/docs/07-developers/06-keycloak/structured_pin_login.md @@ -0,0 +1,169 @@ +--- +id: structured_pin_login +title: Structured PIN login +--- + + + +The structured PIN input is an optional presentation for a voter's ordinary +Keycloak password. The other login value remains the username. It does not add +a credential type, change how Keycloak stores or verifies credentials, or add a +date-of-birth field. + +## Realm configuration + +Set these Keycloak realm attributes on the election event realm: + +| Attribute | Values | Default | +| --- | --- | --- | +| `credential-input-policy` | `standard` or `structured` | `standard` | +| `credential-input-pattern` | A structured digit pattern such as `dddd-dddd-dddd-dddd` | `dddd-dddd-dddd-dddd` | + +In the current pattern grammar, each `d` represents one required ASCII digit +and a hyphen separates editable groups. A pattern may contain 1–8 groups, each +group may contain 1–12 `d` tokens, and the combined credential length may not +exceed 64 digits. Other characters and future-reserved operators are rejected. + +The application validates these values when realm attributes are saved. If +malformed configuration reaches Keycloak through another route, the theme +leaves the ordinary password field usable. + +The attributes can be included in deployment realm configuration or entered in +the permission-gated **Keycloak realm attributes** JSON editor for the election +event. For example: + +```json +{ + "credential-input-policy": "structured", + "credential-input-pattern": "dddd-dddd-dddd-dddd" +} +``` + +Removing `credential-input-policy`, or setting it to `standard`, restores the +ordinary password field. The setting is realm-wide; this implementation has no +client-level override. + +Events configured for the earlier prototype must replace +`credential-input-policy=segmented-numeric` with `structured`, replace +`credential-segment-layout` with `credential-input-pattern`, convert the value +from group sizes to digit tokens (for example, `4-4-4-4` becomes +`dddd-dddd-dddd-dddd`), and remove the old layout attribute. There is +intentionally no compatibility alias. + +## Input behavior + +The pattern renders as one textbox with the visibility button inside its outer +border. Empty positions display `d`. Entered positions display `*`, except for +the most recently entered digit, which remains visible for one second. The +visibility button reveals or hides every entered digit. + +Left and Right select the previous or next group, while Home and End select the +first or last group. Typing after selecting a group replaces it, and completing +a group advances to the next one. Paste accepts ASCII digits with optional +hyphens or ASCII whitespace. Copy, cut, and drop are disabled so the displayed +mask cannot leak or desynchronize the submitted value. +Rejected paste is left unapplied and announced through the control's polite +status region. +Rejected browser replacement or autofill values use a separate, neutral format +message. The message is shown in the credential error area and announced by its +alert role, so both sighted voters and assistive-technology users receive the +same explanation. Submission remains blocked until the voter makes a valid PIN +edit, import, or deletion; editing only the username cannot submit a stale PIN. + +The visible control has no form name. On submit, its digit model is copied into +the one ordinary `password` form value without separators. Incomplete input is +blocked in the browser and focuses the first incomplete group. Invalid input, +leading zeroes, and paste never change the Keycloak credential type. + +## Accessibility + +The structured control is designed and tested against WCAG 2.1 Level AA for +the component and both supported flows. It retains native textbox and button +semantics, associates the visible PIN label and hint with the textbox, exposes +required and invalid states, and announces group progress and validation +errors. Incomplete submission focuses the first incomplete group. Username and +PIN inputs expose the standard `username` and `current-password` purposes. + +Every interaction is keyboard-operable. Tab order is PIN textbox, visibility +button, and then the remaining form controls; Left, Right, Home, End, +Backspace, Delete, typing, paste, reveal, and submission do not require a +pointer or keystroke timing. The field and visibility button have distinct +visible focus states, including in forced-colors mode. + +Normal text and icons meet their applicable contrast thresholds. The authored +field border and focus/error states have at least 3:1 contrast against the +field background, while text has at least 4.5:1. The 44 CSS-pixel visibility +target exceeds WCAG 2.1 Level AA and also meets its Level AAA target-size +criterion. The control is tested within a 320 CSS-pixel layout (the WCAG 2.1 +reflow width) and with the WCAG text-spacing overrides, without loss of content +or functionality. + +## Supported login forms + +The policy applies to both: + +- the normal `sequent.voting-portal` Keycloak login page; and +- the deferred voter-registration form when its authenticator has + `form-mode=LOGIN` and `password-required=true`. + +Actual registration (`form-mode=REGISTRATION`) is unchanged: it continues to +show password confirmation, the strength indicator, and Keycloak's password +creation-policy validation. A deferred LOGIN form with +`password-required=false` also remains unchanged. + +Authentication failures use the same generic message for an unknown username, +incorrect PIN, disabled voter, lockout, or expired credential. Operational +errors that require a different action, such as a failed reCAPTCHA challenge, +retain their specific message. +Early password failures perform Keycloak's configured dummy hash so user lookup, +disabled-user, empty-password, and temporary-lockout paths do not omit the +realm's password-hashing cost. + +## Password policy and provisioning + +Configure a numeric-compatible Keycloak password policy for realms that create +PIN credentials, for example `length(16) and maxLength(16)` for +`dddd-dddd-dddd-dddd`. Password policies are creation-time rules; the deferred +LOGIN flow does not re-apply them after Keycloak has validated an existing +credential. This applies to every deferred LOGIN form, whether its presentation +is `standard` or `structured`. Credential imports that supply password hashes may bypass +creation-time policy checks, so provisioning must validate the configured +length and format. + +Keep Keycloak brute-force protection enabled. The deferred LOGIN flow records +the attempted username so its failures participate in Keycloak's normal +failure counting and lockout behavior. + +## Localization overrides + +The theme supplies defaults for these message keys: + +- `structuredCredentialLabel` +- `structuredCredentialHint` +- `structuredCredentialError` +- `structuredCredentialGroupStatus` +- `structuredCredentialPasteError` +- `structuredCredentialFormatError` +- `showStructuredCredential` +- `hideStructuredCredential` + +Override them per locale using **Realm Settings → Localization** in Keycloak. +`structuredCredentialGroupStatus` receives the one-based group number as `{0}`, +the number of groups as `{1}`, entered digits as `{2}`, and group size as `{3}`. +Realm localization overrides take precedence over theme defaults. + +## Rollout checklist + +1. Confirm the event realm uses the `sequent.voting-portal` login theme. +2. Confirm provisioned usernames and numeric PIN passwords match the selected + pattern. +3. Configure a PIN-compatible realm password policy. +4. If using registration-as-login, confirm the deferred authenticator uses + `form-mode=LOGIN` and `password-required=true`. +5. Enable the two realm attributes and test keyboard navigation, leading + zeroes, paste, timed masking, show/hide, generic failures, and lockout before + opening voting. diff --git a/packages/Dockerfile.keycloak b/packages/Dockerfile.keycloak index eb8e4b59bfb..20f297b0fb7 100644 --- a/packages/Dockerfile.keycloak +++ b/packages/Dockerfile.keycloak @@ -18,6 +18,7 @@ ARG OID4VP_SHA256=f6cd968bc77fec4f8e1417d3caeefe4920941e01b119f152f1c9186bbd2b2e # Start with a Maven base image to compile the Keycloak provider extensions. FROM maven:3-amazoncorretto-17 AS spis-build +RUN dnf install -y findutils && dnf clean all COPY ./keycloak-extensions/ /build/context/packages/keycloak-extensions/ WORKDIR /build/context RUN --mount=from=beyond,source=.,target=/build/beyond-context,readonly \ diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/UsernamePasswordFormWithExpiry.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/UsernamePasswordFormWithExpiry.java index 04c697a1a0d..6877b4abf29 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/UsernamePasswordFormWithExpiry.java +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/UsernamePasswordFormWithExpiry.java @@ -173,10 +173,7 @@ protected boolean validateForm( currentTime, passwordExpirationInt); context.failureChallenge( AuthenticationFlowError.INVALID_CREDENTIALS, - context - .form() - .setError(Messages.INVALID_PASSWORD) - .createErrorPage(Response.Status.BAD_REQUEST)); + challenge(context, Messages.INVALID_PASSWORD, Validation.FIELD_PASSWORD)); return false; } diff --git a/packages/keycloak-extensions/sequent-theme/package.json b/packages/keycloak-extensions/sequent-theme/package.json new file mode 100644 index 00000000000..eea849bd269 --- /dev/null +++ b/packages/keycloak-extensions/sequent-theme/package.json @@ -0,0 +1,12 @@ +{ + "private": true, + "name": "@sequentech/keycloak-theme-tests", + "version": "0.0.0", + "license": "AGPL-3.0-only", + "scripts": { + "test": "node --test src/test/js/*.test.cjs" + }, + "devDependencies": { + "jsdom": "^24.1.0" + } +} diff --git a/packages/keycloak-extensions/sequent-theme/package.json.license b/packages/keycloak-extensions/sequent-theme/package.json.license new file mode 100644 index 00000000000..e164ef0b445 --- /dev/null +++ b/packages/keycloak-extensions/sequent-theme/package.json.license @@ -0,0 +1,3 @@ +SPDX-FileCopyrightText: 2026 Sequent Tech + +SPDX-License-Identifier: AGPL-3.0-only diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_ca.properties b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_ca.properties index e9f52b8059b..d65a34a336e 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_ca.properties +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_ca.properties @@ -38,3 +38,11 @@ locale_tl=Tagalog locale_tr=Türkçe locale_uk=Українська locale_zh-CN=中文简体 +structuredCredentialLabel=PIN +structuredCredentialHint=Introduïu el PIN de la vostra carta d''informació electoral. +structuredCredentialError=El nom d''usuari o el PIN són incorrectes. +structuredCredentialGroupStatus=Grup de PIN {0} de {1}, {2} de {3} dígits introduïts +structuredCredentialPasteError=El valor enganxat no coincideix amb el format del PIN. +structuredCredentialFormatError=El valor introduït no coincideix amb el format del PIN. +showStructuredCredential=Mostra el PIN +hideStructuredCredential=Amaga el PIN diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_en.properties b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_en.properties index 6b0a2bde933..6d2b1f663ad 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_en.properties +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_en.properties @@ -38,3 +38,11 @@ locale_tl=Tagalog locale_tr=Türkçe locale_uk=Українська locale_zh-CN=中文简体 +structuredCredentialLabel=PIN +structuredCredentialHint=Enter the PIN from your Voter Information Letter. +structuredCredentialError=Incorrect username or PIN. +structuredCredentialGroupStatus=PIN group {0} of {1}, {2} of {3} digits entered +structuredCredentialPasteError=The pasted value does not match the PIN format. +structuredCredentialFormatError=The entered value does not match the PIN format. +showStructuredCredential=Show PIN +hideStructuredCredential=Hide PIN diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_es.properties b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_es.properties index 61bc9fa314f..0dae438cee8 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_es.properties +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_es.properties @@ -38,3 +38,11 @@ locale_tl=Tagalog locale_tr=Türkçe locale_uk=Українська locale_zh-CN=中文简体 +structuredCredentialLabel=PIN +structuredCredentialHint=Introduzca el PIN de su carta de información para votantes. +structuredCredentialError=El nombre de usuario o el PIN son incorrectos. +structuredCredentialGroupStatus=Grupo de PIN {0} de {1}, {2} de {3} dígitos introducidos +structuredCredentialPasteError=El valor pegado no coincide con el formato del PIN. +structuredCredentialFormatError=El valor introducido no coincide con el formato del PIN. +showStructuredCredential=Mostrar PIN +hideStructuredCredential=Ocultar PIN diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_eu.properties b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_eu.properties index 4c0eefff183..44bf292e934 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_eu.properties +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_eu.properties @@ -525,4 +525,12 @@ logoutConfirmHeader=Saioa itxi nahi duzu? doLogout=Itxi saioa readOnlyUsernameMessage=Ezin duzu zure erabiltzaile-izena eguneratu irakurtzeko soilik baita. -error-invalid-multivalued-size={0} atributuak gutxienez {1} eta gehienez {2} balio izan behar ditu. \ No newline at end of file +error-invalid-multivalued-size={0} atributuak gutxienez {1} eta gehienez {2} balio izan behar ditu. +structuredCredentialLabel=PINa +structuredCredentialHint=Sartu hauteslearen informazio-gutuneko PINa. +structuredCredentialError=Erabiltzaile-izena edo PINa ez da zuzena. +structuredCredentialGroupStatus=PINaren {0}. taldea, {1} taldetik; {3} digitutik {2} sartuta +structuredCredentialPasteError=Itsatsitako balioa ez dator bat PINaren formatuarekin. +structuredCredentialFormatError=Sartutako balioa ez dator bat PINaren formatuarekin. +showStructuredCredential=Erakutsi PINa +hideStructuredCredential=Ezkutatu PINa diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_fr.properties b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_fr.properties index d17cb8459e2..cf41ad88530 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_fr.properties +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_fr.properties @@ -1,7 +1,7 @@ -loginAccountTitle=Connectez-vous au Portail d'Administration +loginAccountTitle=Connectez-vous au Portail d''Administration identity-provider-login-label=OU SE CONNECTER AVEC backToLogin=RETOUR À LA CONNEXION -backToApplication=RETOUR À L'APPLICATION +backToApplication=RETOUR À L''APPLICATION doLogIn=SE CONNECTER loginTitle=Connectez-vous à {0} loginFooter=Propulsé par Sequent Tech Inc @@ -38,3 +38,11 @@ locale_tl=Tagalog locale_tr=Türkçe locale_uk=Українська locale_zh-CN=中文简体 +structuredCredentialLabel=Code PIN +structuredCredentialHint=Saisissez le code PIN figurant sur votre lettre d''information électorale. +structuredCredentialError=Le nom d''utilisateur ou le code PIN est incorrect. +structuredCredentialGroupStatus=Groupe de code PIN {0} sur {1}, {2} chiffres saisis sur {3} +structuredCredentialPasteError=La valeur collée ne correspond pas au format du code PIN. +structuredCredentialFormatError=La valeur saisie ne correspond pas au format du code PIN. +showStructuredCredential=Afficher le code PIN +hideStructuredCredential=Masquer le code PIN diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_gl.properties b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_gl.properties index fa12de164df..e98c9b248cf 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_gl.properties +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_gl.properties @@ -537,4 +537,12 @@ logoutConfirmHeader=Queres saír? doLogout=Pechar sesión readOnlyUsernameMessage=Non podes actualizar o teu nome de usuario xa que é só de lectura. -error-invalid-multivalued-size=O atributo {0} debe ter polo menos {1} e como máximo {2} valores. \ No newline at end of file +error-invalid-multivalued-size=O atributo {0} debe ter polo menos {1} e como máximo {2} valores. +structuredCredentialLabel=PIN +structuredCredentialHint=Introduza o PIN da súa carta de información electoral. +structuredCredentialError=O nome de usuario ou o PIN son incorrectos. +structuredCredentialGroupStatus=Grupo de PIN {0} de {1}, {2} de {3} díxitos introducidos +structuredCredentialPasteError=O valor pegado non coincide co formato do PIN. +structuredCredentialFormatError=O valor introducido non coincide co formato do PIN. +showStructuredCredential=Mostrar o PIN +hideStructuredCredential=Agochar o PIN diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_nl.properties b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_nl.properties index 8e2430839dc..8ec77995820 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_nl.properties +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_nl.properties @@ -37,4 +37,12 @@ locale_th=ไทย locale_tl=Tagalog locale_tr=Türkçe locale_uk=Українська -locale_zh-CN=中文简体 \ No newline at end of file +locale_zh-CN=中文简体 +structuredCredentialLabel=PIN +structuredCredentialHint=Voer de PIN uit uw kiezersinformatiebrief in. +structuredCredentialError=De gebruikersnaam of PIN is onjuist. +structuredCredentialGroupStatus=PIN-groep {0} van {1}, {2} van {3} cijfers ingevoerd +structuredCredentialPasteError=De geplakte waarde komt niet overeen met het PIN-formaat. +structuredCredentialFormatError=De ingevoerde waarde komt niet overeen met het PIN-formaat. +showStructuredCredential=PIN tonen +hideStructuredCredential=PIN verbergen diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_tl.properties b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_tl.properties index b5d827fba24..7931e71dd8d 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_tl.properties +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/messages/messages_tl.properties @@ -38,3 +38,11 @@ locale_tl=Tagalog locale_tr=Türkçe locale_uk=Українська locale_zh-CN=中文简体 +structuredCredentialLabel=PIN +structuredCredentialHint=Ilagay ang PIN mula sa liham ng impormasyon para sa botante. +structuredCredentialError=Mali ang username o PIN. +structuredCredentialGroupStatus=Grupo ng PIN {0} sa {1}, {2} sa {3} digit ang nailagay +structuredCredentialPasteError=Hindi tugma sa format ng PIN ang na-paste na halaga. +structuredCredentialFormatError=Hindi tugma sa format ng PIN ang inilagay na halaga. +showStructuredCredential=Ipakita ang PIN +hideStructuredCredential=Itago ang PIN diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/register.ftl b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/register.ftl index 8e76433ae7b..82e6274cf4a 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/register.ftl +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/register.ftl @@ -10,6 +10,11 @@ SPDX-License-Identifier: AGPL-3.0-only <#import "user-profile-commons.ftl" as userProfileCommons> <#import "register-commons.ftl" as registerCommons> <#include "intl-tel-input.ftl"> +<#assign loginMode = formMode?? && formMode == 'LOGIN'> +<#assign passwordRequired = passwordRequired!false> +<#assign structuredCredentialLogin = loginMode && passwordRequired && (realm.attributes['credential-input-policy']!'standard') == 'structured'> +<#assign credentialFieldError = messagesPerField.existsError('username','password')> +<#assign structuredCredentialHasError = structuredCredentialLogin && credentialFieldError> <@layout.registrationLayout displayMessage=messagesPerField.exists('global') displayRequiredFields=true displaySocialProviders=(formMode?? && formMode = 'LOGIN' && (social.providers)?has_content); section> <#if section = "header"> <#if formMode?? && formMode = 'LOGIN'> @@ -36,7 +41,7 @@ SPDX-License-Identifier: AGPL-3.0-only <#if passwordRequired && (attribute.name == 'username' || (attribute.name == 'email' && realm.registrationEmailAsUsername)) && (attribute.annotations.showPasswordAfterThis!'true') != 'false' || (attribute.annotations.showPasswordAfterThis!'false') == 'true'>
- * + *
<#-- You can add a custom passwordHelperTextBefore to either username or email depending on realm.registrationEmailAsUsername settings to add a helpertext --> @@ -44,19 +49,37 @@ SPDX-License-Identifier: AGPL-3.0-only
${kcSanitize(advancedMsg(attribute.annotations.passwordHelperTextBefore))?no_esc}
-
+
+ data-structured-credential + data-credential-pattern="${realm.attributes['credential-input-pattern']!'dddd-dddd-dddd-dddd'}" + data-group-status="${msg('structuredCredentialGroupStatus')}" + data-paste-error="${msg('structuredCredentialPasteError')}" + data-format-error="${msg('structuredCredentialFormatError')}" + data-label-id="structured-credential-label" + data-hint-id="structured-credential-hint" + data-error-id="structured-credential-error"> autocomplete="current-password"<#else>autocomplete="new-password" + <#if structuredCredentialLogin>inputmode="numeric" + <#if structuredCredentialLogin>aria-describedby="structured-credential-hint structured-credential-error" + <#if structuredCredentialHasError || messagesPerField.existsError('password','password-confirm')>aria-invalid="true" /> -
+ <#if structuredCredentialLogin> +
${msg("structuredCredentialHint")}
+ hidden> + ${msg("structuredCredentialError")} + + + <#-- You can add a password strength bar if passwordStrengthBar is set to either username or email depending on realm.registrationEmailAsUsername settings to add a strength bar --> <#if attribute.annotations.passwordStrengthBar?? && formMode?? && (formMode!"REGISTRATION") != "LOGIN">
@@ -66,7 +89,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- <#if messagesPerField.existsError('password')> + <#if messagesPerField.existsError('password') && !structuredCredentialLogin> ${kcSanitize(messagesPerField.get('password'))?no_esc} @@ -141,7 +164,11 @@ SPDX-License-Identifier: AGPL-3.0-only
- + <#if structuredCredentialLogin> + + <#else> + + <#-- Adding intel-tel-input --> <#-- https://github.com/jackocnr/intl-tel-input/tree/master --> diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/resources/css/custom.css b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/resources/css/custom.css index 595042e8dd4..ab4b1fe4107 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/resources/css/custom.css +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/resources/css/custom.css @@ -669,3 +669,105 @@ button#resend-otp-btn:hover { text-decoration: none; color: inherit; } + +/* Structured credentials progressively enhance the ordinary password input. */ +[data-structured-credential][data-structured-credential-enhanced="true"] { + align-items: center; + background: var(--pf-global--BackgroundColor--100); + border: 1px solid var(--pf-global--palette--black-500); + border-radius: 4px; + display: flex; + overflow: hidden; + position: relative; +} + +[data-structured-credential][data-structured-credential-enhanced="true"]:focus-within { + border-color: var(--pf-global--primary-color--100); + box-shadow: 0 0 0 1px var(--pf-global--primary-color--100); +} + +[data-structured-credential][data-structured-credential-enhanced="true"] + .structured-credential__input { + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + box-sizing: border-box; + caret-color: transparent; + direction: ltr; + flex: 1 1 auto; + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace; + height: 44px; + letter-spacing: 0.08em; + line-height: 24px; + min-width: 0; + padding-block: 10px; +} + +[data-structured-credential][data-structured-credential-enhanced="true"] + .structured-credential__input:focus { + outline: 0; +} + +[data-structured-credential][data-structured-credential-enhanced="true"] + [data-structured-credential-toggle] { + align-items: center; + background: transparent; + border: 0; + border-radius: 0; + box-shadow: none; + display: inline-flex; + flex: 0 0 44px; + height: 44px; + justify-content: center; + margin-left: 0; + min-height: 44px; + min-width: 44px; + padding: 0; +} + +[data-structured-credential][data-structured-credential-enhanced="true"] + [data-structured-credential-toggle]:focus-visible { + outline: 2px solid var(--pf-global--primary-color--100); + outline-offset: -4px; +} + +[data-structured-credential][data-structured-credential-enhanced="true"] + [data-structured-credential-toggle]::after { + border: 0; +} + +[data-structured-credential][data-structured-credential-invalid="true"] { + border-color: var(--pf-global--danger-color--100); +} + +.structured-credential__hint { + margin-top: 6px; +} + +[data-structured-credential-error][hidden] { + display: none; +} + +.structured-credential__status { + clip: rect(0, 0, 0, 0); + clip-path: inset(50%); + height: 1px; + overflow: hidden; + position: absolute; + white-space: nowrap; + width: 1px; +} + +@media (forced-colors: active) { + [data-structured-credential][data-structured-credential-enhanced="true"]:focus-within { + box-shadow: none; + outline: 2px solid Highlight; + outline-offset: 1px; + } + + [data-structured-credential][data-structured-credential-enhanced="true"] + [data-structured-credential-toggle]:focus-visible { + outline-color: Highlight; + } +} diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/resources/js/structured-credential.js b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/resources/js/structured-credential.js new file mode 100644 index 00000000000..e7efa66109a --- /dev/null +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/resources/js/structured-credential.js @@ -0,0 +1,707 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only + +const DIGIT_TOKEN = "d"; +const MASK_CHARACTER = "*"; +const REVEAL_DURATION_MS = 1000; +const MAX_GROUPS = 8; +const MAX_GROUP_SIZE = 12; +const MAX_TOTAL_SIZE = 64; + +const onlyAsciiDigits = (value) => (/^[0-9]+$/.test(value) ? value : null); + +const pastedDigits = (value) => { + if (!/^[0-9 \t\r\n\f-]+$/.test(value)) { + return null; + } + const digits = value.replace(/[ \t\r\n\f-]/g, ""); + return digits ? digits : null; +}; + +const parsePattern = (value) => { + const tokens = value.split("-"); + if (tokens.length < 1 || tokens.length > MAX_GROUPS) { + return null; + } + + const groups = []; + let digitStart = 0; + let displayStart = 0; + for (const token of tokens) { + if ( + token.length < 1 || + token.length > MAX_GROUP_SIZE || + !Array.from(token).every((character) => character === DIGIT_TOKEN) + ) { + return null; + } + + if (digitStart + token.length > MAX_TOTAL_SIZE) { + return null; + } + + groups.push({ + digitStart, + displayStart, + length: token.length, + }); + digitStart += token.length; + displayStart += token.length + 1; + } + + return { groups, source: value, totalSize: digitStart }; +}; + +const formatGroupStatus = (template, groupNumber, groupCount, entered, size) => + template + .split("{0}") + .join(String(groupNumber)) + .split("{1}") + .join(String(groupCount)) + .split("{2}") + .join(String(entered)) + .split("{3}") + .join(String(size)); + +const setupVisibilityToggle = (toggle, onChange) => { + if (!toggle) { + return; + } + + const icon = toggle.querySelector("i"); + const labelShow = toggle.dataset.labelShow || ""; + const labelHide = toggle.dataset.labelHide || ""; + const iconShow = toggle.dataset.iconShow || ""; + const iconHide = toggle.dataset.iconHide || ""; + let visible = false; + + const update = () => { + toggle.setAttribute("aria-label", visible ? labelHide : labelShow); + toggle.setAttribute("aria-pressed", String(visible)); + if (icon) { + icon.className = visible ? iconHide : iconShow; + } + onChange(visible); + }; + + toggle.addEventListener("click", () => { + visible = !visible; + update(); + }); + update(); + + return () => { + visible = false; + update(); + }; +}; + +const setupNativeVisibilityToggle = (realInput, toggle) => { + setupVisibilityToggle(toggle, (visible) => { + realInput.type = visible ? "text" : "password"; + }); +}; + +const container = document.querySelector("[data-structured-credential]"); + +if (container) { + const realInput = container.querySelector('input[name="password"]'); + const toggle = container.querySelector("[data-structured-credential-toggle]"); + const pattern = parsePattern(container.dataset.credentialPattern || ""); + + if (!pattern) { + if (realInput) { + setupNativeVisibilityToggle(realInput, toggle); + } + } else if (realInput && realInput.form) { + const form = realInput.form; + const usernameInput = form.querySelector('input[name="username"]'); + const label = document.getElementById(container.dataset.labelId || ""); + const error = document.getElementById(container.dataset.errorId || ""); + const hintId = container.dataset.hintId || ""; + const errorId = container.dataset.errorId || ""; + const groupStatusTemplate = + container.dataset.groupStatus || "PIN group {0} of {1}, {2} of {3} digits entered"; + const pasteErrorMessage = + container.dataset.pasteError || "The pasted value does not match the PIN format."; + const formatErrorMessage = + container.dataset.formatError || "The entered value does not match the PIN format."; + const prefilledValue = onlyAsciiDigits(realInput.value); + const initialValue = + prefilledValue && prefilledValue.length <= pattern.totalSize ? prefilledValue : ""; + const hadServerError = Boolean(error && !error.hidden); + const defaultErrorMessage = error?.textContent || ""; + const originalTabIndex = realInput.tabIndex; + const displayInput = document.createElement("input"); + const status = document.createElement("span"); + const digits = Array(pattern.totalSize).fill(null); + let activeGroup = 0; + let replaceGroupOnNextDigit = true; + let passwordVisible = false; + let revealedIndex = -1; + let revealTimer = null; + let submitting = false; + let activeSubmitter = null; + let credentialFormatInvalid = false; + let hiddenClearPending = false; + + for (let index = 0; index < initialValue.length; index += 1) { + digits[index] = initialValue[index]; + } + + displayInput.id = "structured-password"; + displayInput.className = `${realInput.className} structured-credential__input`; + displayInput.type = "text"; + displayInput.inputMode = "numeric"; + displayInput.autocomplete = "current-password"; + displayInput.autocapitalize = "none"; + displayInput.spellcheck = false; + displayInput.tabIndex = originalTabIndex; + displayInput.setAttribute("aria-required", "true"); + if (usernameInput) { + usernameInput.autocomplete = "username"; + } + if (label) { + displayInput.setAttribute("aria-labelledby", label.id); + } + + status.id = "structured-credential-status"; + status.className = "structured-credential__status"; + status.setAttribute("role", "status"); + status.setAttribute("aria-live", "polite"); + + const describedBy = [hintId, errorId, status.id].filter(Boolean).join(" "); + if (describedBy) { + displayInput.setAttribute("aria-describedby", describedBy); + } + + const groupEnd = (group) => group.digitStart + group.length; + + const clearReveal = () => { + if (revealTimer !== null) { + window.clearTimeout(revealTimer); + revealTimer = null; + } + revealedIndex = -1; + }; + + const displayValue = () => { + let digitIndex = 0; + return Array.from(pattern.source) + .map((token) => { + if (token !== DIGIT_TOKEN) { + return token; + } + const digit = digits[digitIndex]; + const value = + digit === null + ? DIGIT_TOKEN + : passwordVisible || digitIndex === revealedIndex + ? digit + : MASK_CHARACTER; + digitIndex += 1; + return value; + }) + .join(""); + }; + + const syncPassword = () => { + realInput.value = digits.map((digit) => digit || "").join(""); + }; + + const updateStatus = () => { + const group = pattern.groups[activeGroup]; + const entered = digits + .slice(group.digitStart, groupEnd(group)) + .filter((digit) => digit !== null).length; + status.textContent = formatGroupStatus( + groupStatusTemplate, + activeGroup + 1, + pattern.groups.length, + entered, + group.length, + ); + }; + + const applySelection = () => { + const group = pattern.groups[activeGroup]; + displayInput.setSelectionRange(group.displayStart, group.displayStart + group.length); + }; + + const render = () => { + displayInput.value = displayValue(); + updateStatus(); + if (document.activeElement === displayInput) { + applySelection(); + } + }; + + const selectGroup = (index, replaceOnInput = true) => { + activeGroup = Math.max(0, Math.min(index, pattern.groups.length - 1)); + replaceGroupOnNextDigit = replaceOnInput; + updateStatus(); + applySelection(); + }; + + const firstIncompleteGroup = () => { + const index = pattern.groups.findIndex((group) => + digits.slice(group.digitStart, groupEnd(group)).some((digit) => digit === null), + ); + return index === -1 ? 0 : index; + }; + + const clearError = () => { + if (error) { + error.hidden = true; + error.textContent = defaultErrorMessage; + } + displayInput.removeAttribute("aria-invalid"); + if (usernameInput) { + usernameInput.removeAttribute("aria-invalid"); + } + delete container.dataset.structuredCredentialInvalid; + }; + + const showError = (message = defaultErrorMessage) => { + if (error) { + error.textContent = message; + error.hidden = false; + } else { + status.textContent = message; + } + displayInput.setAttribute("aria-invalid", "true"); + container.dataset.structuredCredentialInvalid = "true"; + }; + + const clearCredentialError = () => { + credentialFormatInvalid = false; + hiddenClearPending = false; + clearError(); + }; + + const showFormatError = () => { + credentialFormatInvalid = true; + showError(formatErrorMessage); + }; + + const revealDigit = (index) => { + clearReveal(); + if (passwordVisible) { + return; + } + revealedIndex = index; + revealTimer = window.setTimeout(() => { + if (revealedIndex === index) { + revealedIndex = -1; + revealTimer = null; + render(); + } + }, REVEAL_DURATION_MS); + }; + + const clearGroup = (index) => { + const group = pattern.groups[index]; + digits.fill(null, group.digitStart, groupEnd(group)); + }; + + const groupForDigit = (index) => + pattern.groups.findIndex( + (group) => index >= group.digitStart && index < groupEnd(group), + ); + + const enterDigits = (value) => { + const group = pattern.groups[activeGroup]; + if (replaceGroupOnNextDigit) { + clearGroup(activeGroup); + replaceGroupOnNextDigit = false; + } + + let target = digits.findIndex( + (digit, index) => index >= group.digitStart && index < groupEnd(group) && digit === null, + ); + if (target === -1) { + render(); + return; + } + + let lastEntered = -1; + for (const digit of value) { + if (target >= pattern.totalSize) { + break; + } + digits[target] = digit; + lastEntered = target; + target += 1; + } + + if (lastEntered !== -1) { + const enteredGroup = groupForDigit(lastEntered); + const enteredGroupDefinition = pattern.groups[enteredGroup]; + if ( + lastEntered === groupEnd(enteredGroupDefinition) - 1 && + enteredGroup < pattern.groups.length - 1 + ) { + activeGroup = enteredGroup + 1; + replaceGroupOnNextDigit = true; + } else { + activeGroup = enteredGroup; + } + revealDigit(lastEntered); + } + + syncPassword(); + clearCredentialError(); + render(); + }; + + const pasteFromActiveGroup = (value) => { + const start = value.length === pattern.totalSize ? 0 : pattern.groups[activeGroup].digitStart; + if (value.length > pattern.totalSize - start) { + return false; + } + + const lastEntered = start + value.length - 1; + const enteredGroup = groupForDigit(lastEntered); + const clearEnd = + value.length === pattern.totalSize + ? pattern.totalSize + : groupEnd(pattern.groups[enteredGroup]); + digits.fill(null, start, clearEnd); + for (let index = 0; index < value.length; index += 1) { + digits[start + index] = value[index]; + } + activeGroup = + lastEntered === groupEnd(pattern.groups[enteredGroup]) - 1 && + enteredGroup < pattern.groups.length - 1 + ? enteredGroup + 1 + : enteredGroup; + replaceGroupOnNextDigit = digits + .slice( + pattern.groups[activeGroup].digitStart, + groupEnd(pattern.groups[activeGroup]), + ) + .every((digit) => digit !== null); + revealDigit(lastEntered); + syncPassword(); + clearCredentialError(); + render(); + return true; + }; + + const announcePasteError = () => { + status.textContent = pasteErrorMessage; + }; + + const deleteBackward = () => { + const group = pattern.groups[activeGroup]; + const groupIsEmpty = digits + .slice(group.digitStart, groupEnd(group)) + .every((digit) => digit === null); + if (groupIsEmpty && activeGroup > 0) { + clearReveal(); + selectGroup(activeGroup - 1); + return; + } + + if (replaceGroupOnNextDigit) { + clearGroup(activeGroup); + replaceGroupOnNextDigit = false; + } else { + let target = -1; + for (let index = groupEnd(group) - 1; index >= group.digitStart; index -= 1) { + if (digits[index] !== null) { + target = index; + break; + } + } + if (target === -1 && activeGroup > 0) { + selectGroup(activeGroup - 1); + return; + } + if (target !== -1) { + digits[target] = null; + } + } + clearReveal(); + syncPassword(); + clearCredentialError(); + render(); + }; + + const deleteGroup = () => { + clearGroup(activeGroup); + replaceGroupOnNextDigit = false; + clearReveal(); + syncPassword(); + clearCredentialError(); + render(); + }; + + const groupAtDisplayPosition = (position) => { + for (let index = 0; index < pattern.groups.length; index += 1) { + const group = pattern.groups[index]; + if (position <= group.displayStart + group.length) { + return index; + } + } + return pattern.groups.length - 1; + }; + + displayInput.addEventListener("focus", () => { + applySelection(); + updateStatus(); + }); + + displayInput.addEventListener("click", () => { + selectGroup(groupAtDisplayPosition(displayInput.selectionStart || 0)); + }); + + displayInput.addEventListener("keydown", (event) => { + const navigation = { + ArrowLeft: activeGroup - 1, + ArrowRight: activeGroup + 1, + Home: 0, + End: pattern.groups.length - 1, + }; + if (Object.prototype.hasOwnProperty.call(navigation, event.key)) { + event.preventDefault(); + selectGroup(navigation[event.key]); + } else if (event.key === "Backspace") { + event.preventDefault(); + deleteBackward(); + } else if (event.key === "Delete") { + event.preventDefault(); + deleteGroup(); + } + }); + + displayInput.addEventListener("beforeinput", (event) => { + if (event.inputType === "deleteContentBackward") { + event.preventDefault(); + deleteBackward(); + return; + } + if (event.inputType === "deleteContentForward") { + event.preventDefault(); + deleteGroup(); + return; + } + if (event.inputType.startsWith("insert")) { + // Browser autofill uses replacement input without exposing the value here. + // Other null-data insertions must not be allowed to overwrite the mask. + if (event.data == null) { + if (event.inputType !== "insertReplacementText") { + event.preventDefault(); + } + return; + } + event.preventDefault(); + const value = onlyAsciiDigits(event.data); + if (value) { + enterDigits(value); + } + return; + } + event.preventDefault(); + }); + + displayInput.addEventListener("input", (event) => { + if (event.data == null) { + const replacement = pastedDigits(displayInput.value); + if ( + !replacement || + replacement.length !== pattern.totalSize || + !pasteFromActiveGroup(replacement) + ) { + render(); + showFormatError(); + } + return; + } + + const value = onlyAsciiDigits(event.data); + if (value) { + enterDigits(value); + } else { + render(); + } + }); + + displayInput.addEventListener("paste", (event) => { + event.preventDefault(); + const value = pastedDigits(event.clipboardData?.getData("text") || ""); + if (!value || !pasteFromActiveGroup(value)) { + announcePasteError(); + } + }); + displayInput.addEventListener("copy", (event) => event.preventDefault()); + displayInput.addEventListener("cut", (event) => event.preventDefault()); + displayInput.addEventListener("drop", (event) => event.preventDefault()); + displayInput.addEventListener("blur", () => { + clearReveal(); + render(); + }); + + const importHiddenAutofill = () => { + const value = onlyAsciiDigits(realInput.value); + const currentValue = digits.map((digit) => digit || "").join(""); + return Boolean( + value && + value.length === pattern.totalSize && + value !== currentValue && + pasteFromActiveGroup(value), + ); + }; + + const reconcileHiddenAutofill = (duringSubmit = false) => { + const suppliedValue = realInput.value; + const currentValue = digits.map((digit) => digit || "").join(""); + if (suppliedValue === currentValue) { + if (!duringSubmit && hiddenClearPending && currentValue.length === pattern.totalSize) { + clearCredentialError(); + } + return false; + } + if (!suppliedValue) { + if (currentValue) { + credentialFormatInvalid = true; + hiddenClearPending = true; + } + return duringSubmit; + } + if (!importHiddenAutofill()) { + if (!hiddenClearPending) { + syncPassword(); + } + if (!duringSubmit) { + showFormatError(); + } + return true; + } + return false; + }; + + realInput.addEventListener("input", () => reconcileHiddenAutofill()); + realInput.addEventListener("change", () => reconcileHiddenAutofill()); + + activeGroup = firstIncompleteGroup(); + container.insertBefore(displayInput, realInput); + container.append(status); + realInput.type = "hidden"; + realInput.hidden = true; + realInput.removeAttribute("aria-invalid"); + realInput.removeAttribute("aria-describedby"); + realInput.tabIndex = -1; + container.dataset.structuredCredentialEnhanced = "true"; + + if (label) { + label.htmlFor = displayInput.id; + } + let hideCredential = () => { + passwordVisible = false; + clearReveal(); + render(); + }; + if (toggle) { + toggle.setAttribute("aria-controls", displayInput.id); + toggle.tabIndex = originalTabIndex; + toggle.addEventListener("pointerdown", (event) => event.preventDefault()); + hideCredential = + setupVisibilityToggle(toggle, (visible) => { + passwordVisible = visible; + clearReveal(); + render(); + }) || hideCredential; + } + + document.addEventListener("visibilitychange", () => { + if (document.hidden) { + hideCredential(); + } + }); + window.addEventListener("pagehide", hideCredential); + + const resetSubmission = () => { + submitting = false; + if (activeSubmitter) { + activeSubmitter.disabled = false; + activeSubmitter = null; + } + }; + window.addEventListener("pageshow", resetSubmission); + + syncPassword(); + render(); + if (hadServerError) { + showError(); + } + + form.addEventListener( + "submit", + (event) => { + const visibleReplacement = displayInput.value !== displayValue(); + const visibleAutofill = visibleReplacement ? pastedDigits(displayInput.value) : null; + let invalidReplacement = false; + if ( + visibleReplacement && + visibleAutofill && + visibleAutofill.length === pattern.totalSize + ) { + pasteFromActiveGroup(visibleAutofill); + } else if (visibleReplacement) { + render(); + invalidReplacement = true; + } else { + invalidReplacement = reconcileHiddenAutofill(true); + } + const incomplete = pattern.groups.findIndex((group) => + digits.slice(group.digitStart, groupEnd(group)).some((digit) => digit === null), + ); + if (incomplete !== -1 || invalidReplacement || credentialFormatInvalid) { + event.preventDefault(); + event.stopImmediatePropagation(); + displayInput.focus(); + selectGroup(incomplete !== -1 ? incomplete : activeGroup); + if (invalidReplacement || credentialFormatInvalid) { + showFormatError(); + } else { + showError(); + } + return; + } + + syncPassword(); + + if (submitting) { + event.preventDefault(); + event.stopImmediatePropagation(); + return; + } + submitting = true; + activeSubmitter = + event.submitter instanceof HTMLButtonElement || + event.submitter instanceof HTMLInputElement + ? event.submitter + : null; + window.setTimeout(() => { + if (event.defaultPrevented) { + resetSubmission(); + } else if (submitting && activeSubmitter) { + activeSubmitter.disabled = true; + } + }, 0); + }, + true, + ); + + form.addEventListener("input", (event) => { + if (event.target === usernameInput) { + usernameInput.removeAttribute("aria-invalid"); + if (!credentialFormatInvalid) { + clearError(); + } + } + }); + } +} diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.voting-portal/login/login.ftl b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.voting-portal/login/login.ftl index cbe677b846d..d1ff6a426ec 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.voting-portal/login/login.ftl +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.voting-portal/login/login.ftl @@ -5,23 +5,26 @@ SPDX-License-Identifier: AGPL-3.0-only --> <#import "template.ftl" as layout> -<@layout.registrationLayout displayMessage=!messagesPerField.existsError('username','password') displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled?? displaySocialProviders=social.providers?has_content; section> +<#assign structuredCredential = (realm.attributes['credential-input-policy']!'standard') == 'structured'> +<#assign credentialFieldError = messagesPerField.existsError('username','password')> +<#assign structuredCredentialHasError = structuredCredential && credentialFieldError> +<@layout.registrationLayout displayMessage=!credentialFieldError displayInfo=realm.password && realm.registrationAllowed && !registrationDisabled?? displaySocialProviders=social.providers?has_content; section> <#if section = "header"> ${msg("loginAccountTitle")} <#elseif section = "form">
<#if realm.password> -
+ onsubmit="login.disabled = true; return true;" action="${url.loginAction}" method="post"> <#if !usernameHidden??>
- aria-invalid="true" /> - <#if messagesPerField.existsError('username','password')> + <#if credentialFieldError && !structuredCredential> ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} @@ -31,22 +34,38 @@ SPDX-License-Identifier: AGPL-3.0-only
- + -
+
+ data-structured-credential + data-credential-pattern="${realm.attributes['credential-input-pattern']!'dddd-dddd-dddd-dddd'}" + data-group-status="${msg('structuredCredentialGroupStatus')}" + data-paste-error="${msg('structuredCredentialPasteError')}" + data-format-error="${msg('structuredCredentialFormatError')}" + data-label-id="structured-credential-label" + data-hint-id="structured-credential-hint" + data-error-id="structured-credential-error"> inputmode="numeric" + <#if structuredCredential>aria-describedby="structured-credential-hint structured-credential-error" + <#if structuredCredentialHasError || credentialFieldError>aria-invalid="true" /> -
- <#if usernameHidden?? && messagesPerField.existsError('username','password')> + <#if structuredCredential> +
${msg("structuredCredentialHint")}
+ hidden> + ${msg("structuredCredentialError")} + + <#elseif usernameHidden?? && credentialFieldError> ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} @@ -113,7 +132,11 @@ SPDX-License-Identifier: AGPL-3.0-only
- + <#if structuredCredential> + + <#else> + + <#elseif section = "info" > <#if realm.password && realm.registrationAllowed && !registrationDisabled??>
diff --git a/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/LoginTemplateTest.java b/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/LoginTemplateTest.java new file mode 100644 index 00000000000..74bb7881055 --- /dev/null +++ b/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/LoginTemplateTest.java @@ -0,0 +1,69 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only + +package sequent.keycloak.theme; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +class LoginTemplateTest { + + private static final Path LOGIN_TEMPLATE = + Path.of("src/main/resources/theme/sequent.voting-portal/login/login.ftl"); + + @Test + void structuredCredentialIsOptInAndConfigurable() throws IOException { + String template = Files.readString(LOGIN_TEMPLATE); + + assertTrue( + template.contains( + "<#assign structuredCredential = (realm.attributes['credential-input-policy']!'standard') == 'structured'>")); + assertTrue(template.contains("data-structured-credential")); + assertTrue( + template.contains("realm.attributes['credential-input-pattern']!'dddd-dddd-dddd-dddd'")); + assertFalse(template.contains("?html")); + assertTrue(template.contains("msg(\"structuredCredentialError\")")); + assertTrue(template.contains("data-paste-error=\"${msg('structuredCredentialPasteError')}\"")); + assertTrue( + template.contains("data-format-error=\"${msg('structuredCredentialFormatError')}\"")); + assertTrue(template.contains("<#if structuredCredential>inputmode=\"numeric\"")); + assertTrue(template.contains("src=\"${url.resourcesPath}/js/structured-credential.js\"")); + assertFalse(template.contains("segmentedCredential")); + assertFalse(template.contains("credential-segment-layout")); + } + + @Test + void standardPasswordFieldRemainsTheFallback() throws IOException { + String template = Files.readString(LOGIN_TEMPLATE); + + assertTrue(template.contains("name=\"password\" type=\"password\"")); + assertTrue( + template.contains("autocomplete=\"<#if structuredCredential>username<#else>off\"")); + assertTrue( + template.contains( + "autocomplete=\"<#if structuredCredential>current-password<#else>off\"")); + assertTrue( + template.contains( + "<#if structuredCredentialHasError || credentialFieldError>aria-invalid=\"true\"")); + assertFalse(template.contains("aria-invalid=\"<#if")); + assertTrue(template.contains("src=\"${url.resourcesPath}/js/passwordVisibility.js\"")); + assertTrue(template.contains("<#if structuredCredential>")); + assertTrue(template.contains("<#else>")); + } + + @Test + void structuredCredentialDoesNotMaskOperationalGlobalErrors() throws IOException { + String template = Files.readString(LOGIN_TEMPLATE); + + assertFalse(template.contains("credentialGlobalError")); + assertTrue( + template.contains( + "<#assign structuredCredentialHasError = structuredCredential && credentialFieldError>")); + } +} diff --git a/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/MessageBundleTest.java b/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/MessageBundleTest.java new file mode 100644 index 00000000000..d8fa6ef59aa --- /dev/null +++ b/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/MessageBundleTest.java @@ -0,0 +1,76 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only + +package sequent.keycloak.theme; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.io.Reader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.text.MessageFormat; +import java.util.List; +import java.util.Locale; +import java.util.Properties; +import org.junit.jupiter.api.Test; + +class MessageBundleTest { + + private static final Path MESSAGES = + Path.of("src/main/resources/theme/sequent.admin-portal/login/messages"); + + @Test + void catalanApostrophesSurviveKeycloakMessageFormatting() throws IOException { + Properties messages = load("ca"); + + assertEquals( + "Introduïu el PIN de la vostra carta d'informació electoral.", + format(messages, "structuredCredentialHint", Locale.forLanguageTag("ca"))); + assertEquals( + "El nom d'usuari o el PIN són incorrectes.", + format(messages, "structuredCredentialError", Locale.forLanguageTag("ca"))); + } + + @Test + void frenchApostrophesSurviveKeycloakMessageFormatting() throws IOException { + Properties messages = load("fr"); + Locale locale = Locale.forLanguageTag("fr"); + + assertEquals( + "Connectez-vous au Portail d'Administration", + format(messages, "loginAccountTitle", locale)); + assertEquals("RETOUR À L'APPLICATION", format(messages, "backToApplication", locale)); + assertEquals( + "Saisissez le code PIN figurant sur votre lettre d'information électorale.", + format(messages, "structuredCredentialHint", locale)); + assertEquals( + "Le nom d'utilisateur ou le code PIN est incorrect.", + format(messages, "structuredCredentialError", locale)); + } + + @Test + void everyLocalePreservesAllGroupStatusPlaceholders() throws IOException { + for (String language : List.of("ca", "en", "es", "eu", "fr", "gl", "nl", "tl")) { + String status = load(language).getProperty("structuredCredentialGroupStatus"); + for (int index = 0; index < 4; index += 1) { + assertTrue(status.contains("{" + index + "}"), language + " is missing {" + index + "}"); + } + } + } + + private static Properties load(String language) throws IOException { + Properties messages = new Properties(); + try (Reader reader = + Files.newBufferedReader(MESSAGES.resolve("messages_" + language + ".properties"))) { + messages.load(reader); + } + return messages; + } + + private static String format(Properties messages, String key, Locale locale) { + return new MessageFormat(messages.getProperty(key), locale).format(new Object[0]); + } +} diff --git a/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/RegisterTemplateTest.java b/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/RegisterTemplateTest.java index 63c75814048..f55f37e8b3f 100644 --- a/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/RegisterTemplateTest.java +++ b/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/RegisterTemplateTest.java @@ -4,6 +4,7 @@ package sequent.keycloak.theme; +import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertTrue; import java.io.IOException; @@ -29,4 +30,48 @@ void deferredLoginModeEnablesSocialProviders() throws IOException { assertTrue(template.contains("href=\"${p.loginUrl}\"")); assertTrue(template.contains("${msg(p.displayName)!}")); } + + @Test + void structuredCredentialIsRestrictedToDeferredLoginMode() throws IOException { + String template = Files.readString(REGISTER_TEMPLATE); + + assertTrue( + template.contains( + "<#assign structuredCredentialLogin = loginMode && passwordRequired && (realm.attributes['credential-input-policy']!'standard') == 'structured'>")); + assertTrue(template.contains("data-structured-credential")); + assertTrue( + template.contains("realm.attributes['credential-input-pattern']!'dddd-dddd-dddd-dddd'")); + assertFalse(template.contains("?html")); + assertTrue(template.contains("msg(\"structuredCredentialError\")")); + assertTrue(template.contains("data-paste-error=\"${msg('structuredCredentialPasteError')}\"")); + assertTrue( + template.contains("data-format-error=\"${msg('structuredCredentialFormatError')}\"")); + assertTrue(template.contains("<#if structuredCredentialLogin>inputmode=\"numeric\"")); + assertTrue(template.contains("src=\"${url.resourcesPath}/js/structured-credential.js\"")); + assertTrue(template.contains("<#if structuredCredentialLogin>")); + assertTrue( + template.contains("<#if structuredCredentialLogin>autocomplete=\"current-password\"")); + assertTrue( + template.contains( + "<#if structuredCredentialHasError || messagesPerField.existsError('password','password-confirm')>aria-invalid=\"true\"")); + assertFalse( + template.contains( + "aria-invalid=\"<#if structuredCredentialHasError || messagesPerField.existsError('password','password-confirm')>true\"")); + assertTrue( + template.contains( + "displayMessage=messagesPerField.exists('global') displayRequiredFields=true")); + assertFalse(template.contains("credentialGlobalError")); + assertFalse(template.contains("segmentedCredential")); + assertFalse(template.contains("credential-segment-layout")); + } + + @Test + void ordinaryRegistrationKeepsPasswordCreationControls() throws IOException { + String template = Files.readString(REGISTER_TEMPLATE); + + assertTrue(template.contains("autocomplete=\"new-password\"")); + assertTrue(template.contains("id=\"password-confirm\"")); + assertTrue(template.contains("id=\"password-progress\"")); + assertTrue(template.contains("src=\"${url.resourcesPath}/js/passwordVisibility.js\"")); + } } diff --git a/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/StructuredCredentialAssetTest.java b/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/StructuredCredentialAssetTest.java new file mode 100644 index 00000000000..3752ee6eb41 --- /dev/null +++ b/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/StructuredCredentialAssetTest.java @@ -0,0 +1,91 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only + +package sequent.keycloak.theme; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import org.junit.jupiter.api.Test; + +class StructuredCredentialAssetTest { + + private static final Path SCRIPT = + Path.of( + "src/main/resources/theme/sequent.admin-portal/login/resources/js/structured-credential.js"); + + private static final Path STYLES = + Path.of("src/main/resources/theme/sequent.admin-portal/login/resources/css/custom.css"); + + @Test + void oneControlledInputSubmitsThroughTheOrdinaryPasswordField() throws IOException { + String script = Files.readString(SCRIPT); + + assertTrue(script.contains("document.createElement(\"input\")")); + assertTrue(script.contains("displayInput.inputMode = \"numeric\"")); + assertTrue(script.contains("displayInput.type = \"text\"")); + assertTrue(script.contains("displayInput.autocomplete = \"current-password\"")); + assertTrue(script.contains("usernameInput.autocomplete = \"username\"")); + assertTrue(script.contains("displayInput.setAttribute(\"aria-required\", \"true\")")); + assertTrue(script.contains("realInput.value = digits.map")); + assertTrue(script.contains("realInput.type = \"hidden\"")); + assertTrue(script.contains("prefilledValue.length <= pattern.totalSize")); + assertFalse(script.contains("realInput.value.replace(/[^0-9]/g")); + assertFalse(script.contains("displayInput.name")); + assertFalse(script.contains("segmentInputs")); + } + + @Test + void patternIsBoundedAndMalformedValuesKeepTheStockField() throws IOException { + String script = Files.readString(SCRIPT); + + assertTrue(script.contains("const DIGIT_TOKEN = \"d\"")); + assertTrue(script.contains("const MAX_GROUPS = 8")); + assertTrue(script.contains("const MAX_GROUP_SIZE = 12")); + assertTrue(script.contains("const MAX_TOTAL_SIZE = 64")); + assertTrue(script.contains("if (!pattern)")); + assertTrue(script.contains("setupNativeVisibilityToggle(realInput, toggle)")); + } + + @Test + void keyboardMaskingAndPasteBehaviorsAreWired() throws IOException { + String script = Files.readString(SCRIPT); + + assertTrue(script.contains("setSelectionRange")); + assertTrue(script.contains("ArrowLeft")); + assertTrue(script.contains("ArrowRight")); + assertTrue(script.contains("beforeinput")); + assertTrue(script.contains("paste")); + assertTrue(script.contains("digits.fill(null, start, clearEnd)")); + assertTrue(script.contains("REVEAL_DURATION_MS")); + assertTrue(script.contains("visibilitychange")); + assertTrue(script.contains("window.addEventListener(\"pagehide\", hideCredential)")); + assertTrue(script.contains("toggle.tabIndex = originalTabIndex")); + assertTrue(script.contains("event.target === usernameInput")); + assertTrue(script.contains("usernameInput.removeAttribute(\"aria-invalid\")")); + } + + @Test + void cssUsesOneBorderedComponentAndContainsNoMultiBoxRules() throws IOException { + String styles = Files.readString(STYLES); + + assertTrue( + styles.contains( + "[data-structured-credential][data-structured-credential-enhanced=\"true\"]")); + assertTrue(styles.contains(".structured-credential__input")); + assertTrue(styles.contains("[data-structured-credential-toggle]")); + assertTrue(styles.contains("border: 1px solid var(--pf-global--palette--black-500)")); + assertTrue(styles.contains("line-height: 24px")); + assertTrue(styles.contains("padding-block: 10px")); + assertTrue(styles.contains("[data-structured-credential-toggle]:focus-visible")); + assertTrue(styles.contains("@media (forced-colors: active)")); + assertTrue(styles.contains("[data-structured-credential-toggle]::after")); + assertFalse(styles.contains("line-height: 44px")); + assertFalse(styles.contains(".segmented-credential__segment")); + assertFalse(styles.contains("--segmented-credential-gap")); + } +} diff --git a/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/TemplateSyntaxTest.java b/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/TemplateSyntaxTest.java new file mode 100644 index 00000000000..aec3af7de43 --- /dev/null +++ b/packages/keycloak-extensions/sequent-theme/src/test/java/sequent/keycloak/theme/TemplateSyntaxTest.java @@ -0,0 +1,198 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only + +package sequent.keycloak.theme; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import freemarker.cache.FileTemplateLoader; +import freemarker.cache.MultiTemplateLoader; +import freemarker.cache.StringTemplateLoader; +import freemarker.cache.TemplateLoader; +import freemarker.core.HTMLOutputFormat; +import freemarker.template.Configuration; +import freemarker.template.Template; +import freemarker.template.TemplateException; +import freemarker.template.TemplateMethodModelEx; +import java.io.IOException; +import java.io.Reader; +import java.io.StringWriter; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; + +class TemplateSyntaxTest { + + private static final Path THEME_ROOT = Path.of("src/main/resources/theme"); + + @Test + void structuredCredentialTemplatesParseWithHtmlAutoEscaping() throws IOException { + assertParses(THEME_ROOT.resolve("sequent.voting-portal/login/login.ftl")); + assertParses(THEME_ROOT.resolve("sequent.admin-portal/login/register.ftl")); + } + + @Test + void loginTemplateEscapesHostileRealmPatternAndLocalizationOverrides() + throws IOException, TemplateException { + Path child = THEME_ROOT.resolve("sequent.voting-portal/login"); + Path parent = THEME_ROOT.resolve("sequent.admin-portal/login"); + Configuration configuration = configuration(); + configuration.setTemplateLoader( + new MultiTemplateLoader( + new TemplateLoader[] { + new FileTemplateLoader(child.toFile()), new FileTemplateLoader(parent.toFile()) + })); + + Map model = baseModel(); + + StringWriter rendered = new StringWriter(); + configuration.getTemplate("login.ftl").process(model, rendered); + String html = rendered.toString(); + + assertTrue(html.contains("data-credential-pattern=\"dddd" onfocus="alert(1)\"")); + assertTrue(html.contains("<img src=x onerror=alert(1)>")); + assertTrue(html.contains("data-paste-error=\"paste" onfocus="alert(2)\"")); + assertTrue(html.contains("data-format-error=\"format" onfocus="alert(3)\"")); + assertFalse(html.contains("")); + assertFalse(html.contains("data-credential-pattern=\"dddd\" onfocus=")); + } + + @Test + void deferredLoginRegistrationTemplateEscapesTheSameHostileConfiguration() + throws IOException, TemplateException { + Path parent = THEME_ROOT.resolve("sequent.admin-portal/login"); + StringTemplateLoader baseThemeStubs = new StringTemplateLoader(); + baseThemeStubs.putTemplate("intl-tel-input.ftl", ""); + baseThemeStubs.putTemplate("register-commons.ftl", "<#macro termsAcceptance>"); + Configuration configuration = configuration(); + configuration.setTemplateLoader( + new MultiTemplateLoader( + new TemplateLoader[] {new FileTemplateLoader(parent.toFile()), baseThemeStubs})); + Map model = baseModel(); + model.put("formMode", "LOGIN"); + model.put("passwordRequired", true); + model.put("hiddenProfileAttributes", List.of()); + model.put( + "profile", + Map.of( + "attributes", + List.of( + Map.ofEntries( + Map.entry("name", "username"), + Map.entry("values", List.of()), + Map.entry("value", "voter"), + Map.entry("annotations", Map.of("showPasswordAfterThis", "true")), + Map.entry("group", ""), + Map.entry("required", true), + Map.entry("multivalued", false), + Map.entry("readOnly", false), + Map.entry("displayName", "Username"), + Map.entry("html5DataAnnotations", Map.of()))), + "html5DataAnnotations", + Map.of())); + + StringWriter rendered = new StringWriter(); + configuration.getTemplate("register.ftl").process(model, rendered); + String html = rendered.toString(); + + assertTrue(html.contains("data-credential-pattern=\"dddd" onfocus="alert(1)\"")); + assertTrue(html.contains("<img src=x onerror=alert(1)>")); + assertTrue(html.contains("data-paste-error=\"paste" onfocus="alert(2)\"")); + assertTrue(html.contains("data-format-error=\"format" onfocus="alert(3)\"")); + assertTrue(html.contains("inputmode=\"numeric\"")); + assertFalse(html.contains("data-credential-pattern=\"dddd\" onfocus=")); + } + + private static void assertParses(Path path) throws IOException { + Configuration configuration = configuration(); + try (Reader reader = Files.newBufferedReader(path)) { + new Template(path.getFileName().toString(), reader, configuration); + } + } + + private static Configuration configuration() { + Configuration configuration = new Configuration(Configuration.VERSION_2_3_34); + configuration.setOutputFormat(HTMLOutputFormat.INSTANCE); + configuration.setAutoEscapingPolicy(Configuration.ENABLE_IF_SUPPORTED_AUTO_ESCAPING_POLICY); + return configuration; + } + + private static Map baseModel() { + TemplateMethodModelEx falseMethod = arguments -> false; + TemplateMethodModelEx message = + arguments -> { + String key = arguments.get(0).toString(); + if ("structuredCredentialHint".equals(key)) { + return ""; + } + if ("structuredCredentialPasteError".equals(key)) { + return "paste\" onfocus=\"alert(2)"; + } + if ("structuredCredentialFormatError".equals(key)) { + return "format\" onfocus=\"alert(3)"; + } + return key; + }; + TemplateMethodModelEx sanitize = arguments -> arguments.get(0).toString(); + TemplateMethodModelEx noFieldError = arguments -> false; + TemplateMethodModelEx noFieldMessage = arguments -> ""; + TemplateMethodModelEx advancedMessage = arguments -> arguments.get(0).toString(); + return new HashMap<>( + Map.ofEntries( + Map.entry( + "realm", + Map.ofEntries( + Map.entry( + "attributes", + Map.of( + "credential-input-policy", + "structured", + "credential-input-pattern", + "dddd\" onfocus=\"alert(1)")), + Map.entry("password", true), + Map.entry("registrationAllowed", false), + Map.entry("loginWithEmailAllowed", false), + Map.entry("registrationEmailAsUsername", false), + Map.entry("rememberMe", false), + Map.entry("resetPasswordAllowed", false), + Map.entry("internationalizationEnabled", false), + Map.entry("displayName", "Realm"), + Map.entry("defaultLocale", "en"))), + Map.entry("properties", Map.of("systemVersion", "test", "systemHash", "test-hash")), + Map.entry( + "url", + Map.ofEntries( + Map.entry("resourcesPath", "/resources"), + Map.entry("resourcesCommonPath", "/common"), + Map.entry("ssoLoginInOtherTabsUrl", "/sso"), + Map.entry("loginAction", "/login"), + Map.entry("registrationAction", "/register"), + Map.entry("loginUrl", "/login"))), + Map.entry("social", Map.of("providers", List.of())), + Map.entry("locale", Map.of("supported", List.of(), "currentLanguageTag", "en")), + Map.entry( + "auth", + Map.of( + "selectedCredential", "", + "showUsername", falseMethod, + "showResetCredentials", falseMethod, + "showTryAnotherWayLink", falseMethod)), + Map.entry( + "messagesPerField", + Map.of( + "exists", noFieldError, + "existsError", noFieldError, + "get", noFieldMessage, + "getFirstError", noFieldMessage)), + Map.entry("msg", message), + Map.entry("advancedMsg", advancedMessage), + Map.entry("kcSanitize", sanitize), + Map.entry("login", Map.of()), + Map.entry("scripts", List.of()))); + } +} diff --git a/packages/keycloak-extensions/sequent-theme/src/test/js/structured-credential.test.cjs b/packages/keycloak-extensions/sequent-theme/src/test/js/structured-credential.test.cjs new file mode 100644 index 00000000000..165911d52d4 --- /dev/null +++ b/packages/keycloak-extensions/sequent-theme/src/test/js/structured-credential.test.cjs @@ -0,0 +1,430 @@ +// SPDX-FileCopyrightText: 2026 Sequent Tech +// +// SPDX-License-Identifier: AGPL-3.0-only + +const assert = require("node:assert/strict"); +const { readFileSync } = require("node:fs"); +const { join } = require("node:path"); +const { test } = require("node:test"); +const { JSDOM } = require("jsdom"); + +const script = readFileSync( + join( + __dirname, + "../../main/resources/theme/sequent.admin-portal/login/resources/js/structured-credential.js", + ), + "utf8", +); + +const setup = ({ pattern = "dddd-dddd", password = "" } = {}) => { + const dom = new JSDOM( + ` + + + +
+ + +
+
Hint
+ + + `, + { runScripts: "outside-only", url: "https://example.test" }, + ); + dom.window.eval(script); + return { + container: dom.window.document.querySelector("[data-structured-credential]"), + display: dom.window.document.getElementById("structured-password"), + dom, + error: dom.window.document.getElementById("pin-error"), + form: dom.window.document.getElementById("login"), + real: dom.window.document.getElementById("password"), + status: dom.window.document.getElementById("structured-credential-status"), + submit: dom.window.document.getElementById("submit"), + toggle: dom.window.document.querySelector("[data-structured-credential-toggle]"), + }; +}; + +const typeDigit = (dom, input, digit) => { + input.dispatchEvent( + new dom.window.InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + data: digit, + inputType: "insertText", + }), + ); +}; + +const paste = (dom, input, value) => { + const event = new dom.window.Event("paste", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "clipboardData", { + value: { getData: () => value }, + }); + input.dispatchEvent(event); +}; + +const submitEvent = (dom, submitter) => { + const event = new dom.window.Event("submit", { bubbles: true, cancelable: true }); + Object.defineProperty(event, "submitter", { value: submitter }); + return event; +}; + +test("enhances one visible input while retaining the ordinary named password field", () => { + const { container, display, real } = setup(); + + assert.equal(container.dataset.structuredCredentialEnhanced, "true"); + assert.equal(display.inputMode, "numeric"); + assert.equal(display.autocomplete, "current-password"); + assert.equal(display.tabIndex, 3); + assert.equal(real.type, "hidden"); + assert.equal(real.name, "password"); + assert.equal(display.name, ""); +}); + +test("malformed patterns preserve the native password input and visibility toggle", () => { + const { display, real, toggle } = setup({ pattern: "dddd-text" }); + + assert.equal(display, null); + assert.equal(real.type, "password"); + toggle.click(); + assert.equal(real.type, "text"); +}); + +test("typing fills groups, arrow navigation replaces a selected group, and extra digits are ignored", () => { + const { display, dom, real } = setup(); + display.focus(); + for (const digit of "12345678") { + typeDigit(dom, display, digit); + } + assert.equal(real.value, "12345678"); + + typeDigit(dom, display, "9"); + assert.equal(real.value, "12345678"); + + display.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "ArrowLeft", + }), + ); + typeDigit(dom, display, "9"); + assert.equal(display.value, "9ddd-****"); + assert.equal(real.value, "95678"); +}); + +test("password-manager replacement input is accepted and synchronized", () => { + const { display, dom, real } = setup(); + display.value = "1234-5678"; + display.dispatchEvent( + new dom.window.InputEvent("input", { + bubbles: true, + data: null, + inputType: "insertReplacementText", + }), + ); + + assert.equal(real.value, "12345678"); + assert.match(display.value, /^\*{4}-\*{3}8$/); +}); + +test("plain input events and late hidden-field autofill are synchronized", () => { + const { display, dom, real } = setup(); + display.value = "1234-5678"; + display.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + assert.equal(real.value, "12345678"); + + real.value = "87654321"; + real.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + assert.equal(real.value, "87654321"); + assert.match(display.value, /^\*{4}-\*{3}1$/); +}); + +test("invalid hidden-field autofill is rejected, restored, and shown", () => { + const { display, dom, error, form, real, submit } = setup(); + paste(dom, display, "1234-5678"); + real.value = "123"; + real.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + real.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + + assert.equal(real.value, "12345678"); + assert.equal(error.hidden, false); + assert.equal(error.textContent, "Format rejected"); + + const username = form.querySelector('input[name="username"]'); + username.value = "another-voter"; + username.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + assert.equal(error.hidden, false); + assert.equal(username.hasAttribute("aria-invalid"), false); + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); +}); + +test("invalid visible replacement shows format error and editing restores the generic error", () => { + const { display, dom, error, form, submit } = setup(); + const insertion = new dom.window.InputEvent("beforeinput", { + bubbles: true, + cancelable: true, + data: null, + inputType: "insertText", + }); + assert.equal(display.dispatchEvent(insertion), false); + assert.equal(display.value, "dddd-dddd"); + assert.equal(error.hidden, true); + + display.value = "not a PIN"; + display.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + assert.equal(display.value, "dddd-dddd"); + assert.equal(error.hidden, false); + assert.equal(error.textContent, "Format rejected"); + + typeDigit(dom, display, "1"); + assert.equal(error.hidden, true); + assert.equal(error.textContent, "Wrong credential"); + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); + assert.equal(error.hidden, false); + assert.equal(error.textContent, "Wrong credential"); +}); + +test("eventless password-manager values are reconciled on submit", () => { + const { dom, form, real, submit } = setup(); + real.value = "12345678"; + + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), true); + assert.equal(real.value, "12345678"); +}); + +test("eventless malformed hidden autofill is restored and shown on submit", () => { + const { display, dom, error, form, real, submit } = setup(); + paste(dom, display, "1234-5678"); + real.value = "invalid"; + + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); + assert.equal(real.value, "12345678"); + assert.equal(error.hidden, false); + assert.equal(error.textContent, "Format rejected"); + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); +}); + +test("paired hidden clear events remain pending and cannot submit the stale PIN", () => { + const { display, dom, error, form, real, submit } = setup(); + paste(dom, display, "1234-5678"); + real.value = ""; + real.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + real.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + + assert.equal(real.value, ""); + assert.equal(error.hidden, true); + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); + assert.equal(real.value, ""); + assert.equal(error.hidden, false); + assert.equal(error.textContent, "Format rejected"); + real.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); +}); + +test("password-manager clear then same valid fill resolves the pending invalid state", () => { + const { display, dom, error, form, real, submit } = setup(); + paste(dom, display, "1234-5678"); + real.value = ""; + real.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + real.value = "12345678"; + real.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + + assert.equal(real.value, "12345678"); + assert.equal(error.hidden, true); + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), true); +}); + +test("clear then malformed paired events cannot submit the stale PIN", () => { + const { display, dom, error, form, real, submit } = setup(); + paste(dom, display, "1234-5678"); + real.value = ""; + real.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + real.value = "invalid"; + real.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + real.dispatchEvent(new dom.window.Event("change", { bubbles: true })); + + assert.equal(real.value, "invalid"); + assert.equal(error.hidden, false); + assert.equal(error.textContent, "Format rejected"); + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); + assert.equal(real.value, "invalid"); + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); +}); + +test("eventless hidden-field clearing cannot submit a previously complete credential", () => { + const { display, dom, error, form, real, submit } = setup(); + paste(dom, display, "1234-5678"); + real.value = ""; + + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); + assert.equal(real.value, ""); + assert.equal(error.hidden, false); + assert.equal(error.textContent, "Format rejected"); + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); +}); + +test("eventless malformed visible replacement is restored and shown on submit", () => { + const { display, dom, error, form, real, submit } = setup(); + paste(dom, display, "1234-5678"); + display.value = "invalid"; + + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); + assert.notEqual(display.value, "invalid"); + assert.equal(real.value, "12345678"); + assert.equal(error.hidden, false); + assert.equal(error.textContent, "Format rejected"); + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); +}); + +test("paste distributes a complete credential and announces rejected values", () => { + const { display, dom, real, status } = setup(); + paste(dom, display, "1234-5678"); + assert.equal(real.value, "12345678"); + + paste(dom, display, "not a PIN"); + assert.equal(real.value, "12345678"); + assert.equal(status.textContent, "Paste rejected"); + + display.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "ArrowLeft", + }), + ); + assert.equal(status.textContent, "Group 1/2: 4/4"); +}); + +test("partial paste replaces its groups without clearing later groups", () => { + const { display, dom, real } = setup(); + paste(dom, display, "1234-5678"); + display.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "ArrowLeft", + }), + ); + paste(dom, display, "99"); + + assert.equal(real.value, "995678"); + assert.match(display.value, /^\*9dd-\*{4}$/); +}); + +test("Backspace and Delete clear selected groups", () => { + const { display, dom, real } = setup(); + paste(dom, display, "1234-5678"); + display.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Backspace", + }), + ); + assert.equal(real.value, "1234"); + + display.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "ArrowLeft", + }), + ); + display.dispatchEvent( + new dom.window.KeyboardEvent("keydown", { + bubbles: true, + cancelable: true, + key: "Delete", + }), + ); + assert.equal(real.value, ""); +}); + +test("the last digit is remasked and page hiding closes visibility", async () => { + const { display, dom, toggle } = setup(); + typeDigit(dom, display, "1"); + assert.equal(display.value, "1ddd-dddd"); + await new Promise((resolve) => dom.window.setTimeout(resolve, 1050)); + assert.equal(display.value, "*ddd-dddd"); + + toggle.click(); + assert.equal(display.value, "1ddd-dddd"); + dom.window.dispatchEvent(new dom.window.Event("pagehide")); + assert.equal(display.value, "*ddd-dddd"); + assert.equal(toggle.getAttribute("aria-pressed"), "false"); +}); + +test("unrelated field edits retain errors and a one-digit pattern remains valid", () => { + const { display, dom, error, form, real, submit } = setup({ pattern: "d" }); + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); + assert.equal(error.hidden, false); + + const unrelated = dom.window.document.createElement("input"); + unrelated.name = "firstName"; + form.append(unrelated); + unrelated.dispatchEvent(new dom.window.Event("input", { bubbles: true })); + assert.equal(error.hidden, false); + + typeDigit(dom, display, "7"); + assert.equal(real.value, "7"); +}); + +test("incomplete submit is blocked and complete submit resets after bfcache restoration", async () => { + const { display, dom, error, form, real, submit } = setup(); + for (const digit of "1234") { + typeDigit(dom, display, digit); + } + const incomplete = submitEvent(dom, submit); + assert.equal(form.dispatchEvent(incomplete), false); + assert.equal(error.hidden, false); + + paste(dom, display, "1234-5678"); + const complete = submitEvent(dom, submit); + assert.equal(form.dispatchEvent(complete), true); + assert.equal(real.value, "12345678"); + assert.equal(submit.disabled, false); + + const duplicate = submitEvent(dom, submit); + assert.equal(form.dispatchEvent(duplicate), false); + assert.equal(submit.disabled, false); + + await new Promise((resolve) => dom.window.setTimeout(resolve, 0)); + assert.equal(submit.disabled, true); + dom.window.dispatchEvent(new dom.window.Event("pageshow")); + assert.equal(submit.disabled, false); + + const restored = submitEvent(dom, submit); + assert.equal(form.dispatchEvent(restored), true); +}); + +test("later submit cancellation releases the re-entry guard and leaves feedback enabled", async () => { + const { display, dom, form, submit } = setup(); + paste(dom, display, "1234-5678"); + let cancellations = 0; + form.addEventListener("submit", (event) => { + cancellations += 1; + event.preventDefault(); + }); + + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); + await new Promise((resolve) => dom.window.setTimeout(resolve, 0)); + assert.equal(submit.disabled, false); + + assert.equal(form.dispatchEvent(submitEvent(dom, submit)), false); + await new Promise((resolve) => dom.window.setTimeout(resolve, 0)); + assert.equal(submit.disabled, false); + assert.equal(cancellations, 2); +}); diff --git a/packages/keycloak-extensions/voter-enrollment/pom.xml b/packages/keycloak-extensions/voter-enrollment/pom.xml index ae6fe6d1fbf..00c73e4145e 100644 --- a/packages/keycloak-extensions/voter-enrollment/pom.xml +++ b/packages/keycloak-extensions/voter-enrollment/pom.xml @@ -31,6 +31,12 @@ SPDX-License-Identifier: AGPL-3.0-only 6.0.3 test + + org.mockito + mockito-core + 5.19.0 + test + diff --git a/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreation.java b/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreation.java index 8e8c81b265f..e50fcff19ff 100644 --- a/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreation.java +++ b/packages/keycloak-extensions/voter-enrollment/src/main/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreation.java @@ -26,8 +26,10 @@ import org.keycloak.authentication.FormActionFactory; import org.keycloak.authentication.FormContext; import org.keycloak.authentication.ValidationContext; +import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; import org.keycloak.authentication.forms.RegistrationPage; import org.keycloak.common.util.Time; +import org.keycloak.credential.hash.PasswordHashProvider; import org.keycloak.events.Details; import org.keycloak.events.Errors; import org.keycloak.forms.login.LoginFormsProvider; @@ -35,6 +37,7 @@ import org.keycloak.models.AuthenticatorConfigModel; import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.PasswordPolicy; import org.keycloak.models.RealmModel; import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserModel; @@ -62,6 +65,9 @@ public class DeferredRegistrationUserCreation implements FormAction, FormActionF public static final String UNIQUE_ATTRIBUTES = "unique-attributes"; public static final String PASSWORD_REQUIRED = "password-required"; public static final String FORM_MODE = "form-mode"; + public static final String CREDENTIAL_INPUT_POLICY_REALM_ATTRIBUTE = "credential-input-policy"; + public static final String STRUCTURED_POLICY = "structured"; + public static final String STRUCTURED_CREDENTIAL_ERROR = "structuredCredentialError"; public static final String PASSWORD_EXPIRATION_USER_ATTRIBUTE = "password-expiration-user-attribute"; public static final String PASSWORD_EXPIRATION_USER_ATTRIBUTE_DEFAULT = @@ -175,10 +181,19 @@ public void validate(ValidationContext context) { final String unsetAttributes = configMap.get(UNSET_ATTRIBUTES); final String uniqueAttributes = configMap.get(UNIQUE_ATTRIBUTES); final String formMode = configMap.get(FORM_MODE); + final boolean loginMode = FormMode.LOGIN.getValue().equals(formMode); + final boolean passwordRequired = + Boolean.parseBoolean(Optional.ofNullable(configMap.get(PASSWORD_REQUIRED)).orElse("true")); + final boolean structuredCredentialLogin = + isStructuredCredentialLogin(formMode, passwordRequired, context.getRealm().getAttributes()); final String verifiedAttributeId = Optional.ofNullable(configMap.get(UNIQUE_ATTRIBUTES)).orElse(VERIFIED_DEFAULT_ID); - boolean passwordRequired = - Boolean.parseBoolean(Optional.ofNullable(configMap.get(PASSWORD_REQUIRED)).orElse("true")); + + if (loginMode) { + context + .getAuthenticationSession() + .removeAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME); + } // Parse attributes lists List searchAttributesList = parseAttributesList(searchAttributes); @@ -213,7 +228,11 @@ public void validate(ValidationContext context) { context.error(INVALID_EMAIL); List errors = new ArrayList<>(); errors.add(new FormMessage(RegistrationPage.FIELD_EMAIL, Messages.INVALID_EMAIL)); - context.validationError(formData, errors); + if (loginMode && passwordRequired) { + // User lookup and event-detail serialization already ran; keep failure timing aligned. + performDummyHash(context); + } + reportValidationError(context, formData, errors, structuredCredentialLogin); return; } } catch (ValidationException pve) { @@ -257,7 +276,11 @@ public void validate(ValidationContext context) { context.error(INVALID_REGISTRATION); } log.info(errors); - context.validationError(formData, errors); + if (loginMode && passwordRequired) { + // User lookup and event-detail serialization already ran; keep failure timing aligned. + performDummyHash(context); + } + reportValidationError(context, formData, errors, structuredCredentialLogin); return; } } @@ -272,22 +295,33 @@ public void validate(ValidationContext context) { log.errorv( "validate(): Register form data {0}: {1}", attribute, formData.getFirst(attribute)); } + if (loginMode && passwordRequired) { + performDummyHash(context); + } context.error(Utils.ERROR_MESSAGE_USER_NOT_FOUND); List errors = new ArrayList<>(); errors.add(new FormMessage(null, Utils.ERROR_USER_NOT_FOUND, sessionId)); - context.validationError(formData, errors); + reportValidationError(context, formData, errors, structuredCredentialLogin); return; } - if (formMode.equals(FormMode.LOGIN.getValue())) { + if (loginMode) { // Validate password in LOGIN mode if (passwordRequired) { - if (!validatePasswordForLogin(context, user, formData)) { + context + .getAuthenticationSession() + .setAuthNote( + AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, user.getUsername()); + if (!validatePasswordForLogin(context, user, formData, structuredCredentialLogin)) { return; } + context + .getAuthenticationSession() + .removeAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME); // Check password expiration after successful password validation - if (!checkPasswordExpiration(context, user, formData, configMap)) { + if (!checkPasswordExpiration( + context, user, formData, configMap, structuredCredentialLogin)) { return; } } @@ -314,7 +348,7 @@ public void validate(ValidationContext context) { context.error(Utils.ERROR_USER_ATTRIBUTES_NOT_UNSET + ": " + unsetAttributesChecked.get()); List errors = new ArrayList<>(); errors.add(new FormMessage(null, Utils.ERROR_USER_ATTRIBUTES_NOT_UNSET, sessionId)); - context.validationError(formData, errors); + reportValidationError(context, formData, errors, structuredCredentialLogin); return; } @@ -330,8 +364,9 @@ public void validate(ValidationContext context) { context.error( Utils.ERROR_USER_ATTRIBUTES_NOT_UNIQUE + ": " + uniqueAttributesChecked.get()); List errors = new ArrayList<>(); - errors.add(new FormMessage(null, Utils.ERROR_USER_ATTRIBUTES_NOT_UNSET, sessionId)); - context.validationError(formData, errors); + errors.add(new FormMessage(null, Utils.ERROR_USER_ATTRIBUTES_NOT_UNIQUE, sessionId)); + reportValidationError(context, formData, errors, structuredCredentialLogin); + return; } } @@ -340,7 +375,7 @@ public void validate(ValidationContext context) { context.getEvent().detail(Details.REGISTER_METHOD, "form"); // Validate password if it's required for the form. - if (passwordRequired) { + if (passwordRequired && shouldValidatePasswordCreationPolicy(formMode)) { String password = formData.getFirst(RegistrationPage.FIELD_PASSWORD); String passwordConfirm = formData.getFirst(RegistrationPage.FIELD_PASSWORD_CONFIRM); @@ -396,7 +431,7 @@ public void validate(ValidationContext context) { originalKey, originalValue, confirmValue); context.error(INVALID_INPUT); errors.add(new FormMessage(formKey, "invalidConfirmationValue")); - context.validationError(formData, errors); + reportValidationError(context, formData, errors, structuredCredentialLogin); } } } @@ -411,9 +446,7 @@ public void validate(ValidationContext context) { context.error(Errors.INVALID_REGISTRATION); } } - formData.remove(RegistrationPage.FIELD_PASSWORD); - formData.remove(RegistrationPage.FIELD_PASSWORD_CONFIRM); - context.validationError(formData, errors); + reportValidationError(context, formData, errors, structuredCredentialLogin); return; } @@ -468,6 +501,74 @@ private String getAnnotationValueFromProfile( return null; } + static boolean isStructuredCredentialLogin( + String formMode, boolean passwordRequired, Map realmAttributes) { + return FormMode.LOGIN.getValue().equals(formMode) + && passwordRequired + && realmAttributes != null + && STRUCTURED_POLICY.equals(realmAttributes.get(CREDENTIAL_INPUT_POLICY_REALM_ATTRIBUTE)); + } + + static boolean shouldValidatePasswordCreationPolicy(String formMode) { + return !FormMode.LOGIN.getValue().equals(formMode); + } + + private void reportValidationError( + ValidationContext context, + MultivaluedMap formData, + List errors, + boolean structuredCredentialLogin) { + removeCredentialValues(formData); + + if (!structuredCredentialLogin) { + context.validationError(formData, errors); + return; + } + + context.excludeOtherErrors(); + context.validationError( + formData, + List.of(new FormMessage(RegistrationPage.FIELD_PASSWORD, STRUCTURED_CREDENTIAL_ERROR))); + } + + static void removeCredentialValues(MultivaluedMap formData) { + formData.remove(RegistrationPage.FIELD_PASSWORD); + formData.remove(RegistrationPage.FIELD_PASSWORD_CONFIRM); + } + + /** + * Performs the equivalent dummy password hash to Keycloak's username/password authenticator. + * + *

{@code AuthenticatorUtils.dummyHash} only accepts an {@code AuthenticationFlowContext}, so + * deferred registration login needs the equivalent operation for its {@code ValidationContext}. + * If a configured named provider is unavailable, the default provider supplies a best-effort + * dummy hash rather than turning an authentication failure into a server error. + */ + static void performDummyHash(ValidationContext context) { + PasswordPolicy policy = context.getRealm().getPasswordPolicy(); + PasswordHashProvider provider; + int iterations; + if (policy != null && policy.getHashAlgorithm() != null) { + provider = + context.getSession().getProvider(PasswordHashProvider.class, policy.getHashAlgorithm()); + iterations = policy.getHashIterations(); + if (provider == null) { + log.warnv( + "Password hash provider {0} is unavailable; using the default provider for dummy hashing", + policy.getHashAlgorithm()); + provider = context.getSession().getProvider(PasswordHashProvider.class); + iterations = -1; + } + } else { + provider = context.getSession().getProvider(PasswordHashProvider.class); + iterations = policy == null ? -1 : policy.getHashIterations(); + } + if (provider == null) { + throw new IllegalStateException("No password hash provider is available for dummy hashing"); + } + provider.encodedCredential("SlightlyLongerDummyPassword", iterations); + } + private Optional checkUniqueAttributes( ValidationContext context, List attributes, MultivaluedMap formData) { log.info("lookupUserByFormData(): checkUniqueAttributes start" + attributes); @@ -757,22 +858,42 @@ private void buildEventDetails( * @param context the validation context * @param user the user model * @param formData the form data containing the password + * @param structuredCredentialLogin whether structured login errors must use the generic PIN + * message * @return true if password is valid, false otherwise */ private boolean validatePasswordForLogin( - ValidationContext context, UserModel user, MultivaluedMap formData) { + ValidationContext context, + UserModel user, + MultivaluedMap formData, + boolean structuredCredentialLogin) { log.info("validatePasswordForLogin: start"); String password = formData.getFirst(CredentialRepresentation.PASSWORD); + if (!user.isEnabled()) { + log.info("validatePasswordForLogin: user disabled"); + performDummyHash(context); + context.getEvent().user(user); + context.getEvent().error(Errors.USER_DISABLED); + context.error(PASSWORD_NOT_MATCHED); + reportValidationError( + context, + formData, + List.of(new FormMessage(RegistrationPage.FIELD_PASSWORD, Messages.INVALID_PASSWORD)), + structuredCredentialLogin); + return false; + } + // Check for empty password if (password == null || password.isEmpty()) { log.info("validatePasswordForLogin: empty password"); - return handleBadPassword(context, user, formData, true); + performDummyHash(context); + return handleBadPassword(context, user, formData, true, structuredCredentialLogin); } // Check for brute force protection - if (isDisabledByBruteForce(context, user)) { + if (isDisabledByBruteForce(context, user, structuredCredentialLogin)) { log.info("validatePasswordForLogin: user disabled by brute force"); return false; } @@ -784,7 +905,7 @@ private boolean validatePasswordForLogin( return true; } else { log.info("validatePasswordForLogin: password invalid"); - return handleBadPassword(context, user, formData, false); + return handleBadPassword(context, user, formData, false, structuredCredentialLogin); } } @@ -795,13 +916,16 @@ private boolean validatePasswordForLogin( * @param user the user model * @param formData the form data * @param isEmptyPassword whether the password was empty + * @param structuredCredentialLogin whether structured login errors must use the generic PIN + * message * @return always false */ private boolean handleBadPassword( ValidationContext context, UserModel user, MultivaluedMap formData, - boolean isEmptyPassword) { + boolean isEmptyPassword, + boolean structuredCredentialLogin) { log.info("handleBadPassword: isEmptyPassword=" + isEmptyPassword); context.getEvent().user(user); @@ -816,11 +940,7 @@ private boolean handleBadPassword( context.error(PASSWORD_NOT_MATCHED); } - // Remove password from form data for security - formData.remove(RegistrationPage.FIELD_PASSWORD); - formData.remove(RegistrationPage.FIELD_PASSWORD_CONFIRM); - - context.validationError(formData, errors); + reportValidationError(context, formData, errors, structuredCredentialLogin); return false; } @@ -835,9 +955,12 @@ private boolean handleBadPassword( * * @param context the validation context * @param user the user model + * @param structuredCredentialLogin whether structured login errors must use the generic PIN + * message * @return true if user is disabled by brute force, false otherwise */ - private boolean isDisabledByBruteForce(ValidationContext context, UserModel user) { + private boolean isDisabledByBruteForce( + ValidationContext context, UserModel user, boolean structuredCredentialLogin) { RealmModel realm = context.getRealm(); // Check if brute force protection is enabled @@ -856,13 +979,15 @@ private boolean isDisabledByBruteForce(ValidationContext context, UserModel user return false; } - // Check if user is temporarily or permanently disabled + // Permanent disablement is handled before password validation. Check the + // brute-force protector for temporary disablement here. boolean isDisabled = protector.isTemporarilyDisabled(session, realm, user); if (isDisabled) { log.infov( "isDisabledByBruteForce: user {0} is disabled by brute force protection", user.getUsername()); + performDummyHash(context); context.getEvent().user(user); context.getEvent().error(Errors.USER_TEMPORARILY_DISABLED); @@ -871,10 +996,8 @@ private boolean isDisabledByBruteForce(ValidationContext context, UserModel user context.error(Messages.INVALID_USER); MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); - formData.remove(RegistrationPage.FIELD_PASSWORD); - formData.remove(RegistrationPage.FIELD_PASSWORD_CONFIRM); - context.validationError(formData, errors); + reportValidationError(context, formData, errors, structuredCredentialLogin); return true; } @@ -889,13 +1012,16 @@ private boolean isDisabledByBruteForce(ValidationContext context, UserModel user * @param user the user model * @param formData the form data * @param configMap the authenticator configuration map + * @param structuredCredentialLogin whether structured login errors must use the generic PIN + * message * @return true if password is not expired or expiration is not configured, false if expired */ private boolean checkPasswordExpiration( ValidationContext context, UserModel user, MultivaluedMap formData, - Map configMap) { + Map configMap, + boolean structuredCredentialLogin) { log.info("checkPasswordExpiration: start"); // Get the password expiration user attribute name from configuration @@ -936,10 +1062,7 @@ private boolean checkPasswordExpiration( context.error("Password has expired"); // Remove password from form data for security - formData.remove(RegistrationPage.FIELD_PASSWORD); - formData.remove(RegistrationPage.FIELD_PASSWORD_CONFIRM); - - context.validationError(formData, errors); + reportValidationError(context, formData, errors, structuredCredentialLogin); return false; } diff --git a/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreationTest.java b/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreationTest.java index e73e7eb4c34..b74da0bd001 100644 --- a/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreationTest.java +++ b/packages/keycloak-extensions/voter-enrollment/src/test/java/sequent/keycloak/voter_enrollment/DeferredRegistrationUserCreationTest.java @@ -5,23 +5,442 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import jakarta.ws.rs.core.MultivaluedHashMap; import jakarta.ws.rs.core.MultivaluedMap; import java.lang.reflect.Method; +import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Stream; import org.junit.jupiter.api.Test; +import org.keycloak.authentication.ValidationContext; +import org.keycloak.authentication.authenticators.browser.AbstractUsernameFormAuthenticator; import org.keycloak.authentication.forms.RegistrationPage; +import org.keycloak.credential.CredentialInput; +import org.keycloak.credential.hash.PasswordHashProvider; +import org.keycloak.events.EventBuilder; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.RealmModel; +import org.keycloak.models.SubjectCredentialManager; import org.keycloak.models.UserModel; +import org.keycloak.models.UserProvider; +import org.keycloak.services.messages.Messages; +import org.keycloak.sessions.AuthenticationSessionModel; +import org.keycloak.sessions.RootAuthenticationSessionModel; +import org.keycloak.userprofile.Attributes; +import org.keycloak.userprofile.UserProfile; import org.keycloak.userprofile.ValidationException; import org.keycloak.validate.ValidationError; +import org.mockito.InOrder; +import org.mockito.MockedStatic; class DeferredRegistrationUserCreationTest { private static final String CUSTOM_HIDDEN_ATTRIBUTE = "customHidden"; + @Test + void structuredCredentialIsEnabledOnlyForLoginModeAndRealmPolicy() { + assertTrue( + DeferredRegistrationUserCreation.isStructuredCredentialLogin( + DeferredRegistrationUserCreation.FormMode.LOGIN.getValue(), + true, + Map.of( + DeferredRegistrationUserCreation.CREDENTIAL_INPUT_POLICY_REALM_ATTRIBUTE, + DeferredRegistrationUserCreation.STRUCTURED_POLICY))); + assertFalse( + DeferredRegistrationUserCreation.isStructuredCredentialLogin( + DeferredRegistrationUserCreation.FormMode.REGISTRATION.getValue(), + true, + Map.of( + DeferredRegistrationUserCreation.CREDENTIAL_INPUT_POLICY_REALM_ATTRIBUTE, + DeferredRegistrationUserCreation.STRUCTURED_POLICY))); + assertFalse( + DeferredRegistrationUserCreation.isStructuredCredentialLogin( + DeferredRegistrationUserCreation.FormMode.LOGIN.getValue(), + true, + Map.of( + DeferredRegistrationUserCreation.CREDENTIAL_INPUT_POLICY_REALM_ATTRIBUTE, + "standard"))); + assertFalse( + DeferredRegistrationUserCreation.isStructuredCredentialLogin( + DeferredRegistrationUserCreation.FormMode.LOGIN.getValue(), true, Map.of())); + assertFalse( + DeferredRegistrationUserCreation.isStructuredCredentialLogin( + DeferredRegistrationUserCreation.FormMode.LOGIN.getValue(), true, null)); + assertFalse( + DeferredRegistrationUserCreation.isStructuredCredentialLogin( + DeferredRegistrationUserCreation.FormMode.LOGIN.getValue(), + false, + Map.of( + DeferredRegistrationUserCreation.CREDENTIAL_INPUT_POLICY_REALM_ATTRIBUTE, + DeferredRegistrationUserCreation.STRUCTURED_POLICY))); + } + + @Test + void passwordCreationPolicyIsNotAppliedInLoginMode() { + assertFalse( + DeferredRegistrationUserCreation.shouldValidatePasswordCreationPolicy( + DeferredRegistrationUserCreation.FormMode.LOGIN.getValue())); + assertTrue( + DeferredRegistrationUserCreation.shouldValidatePasswordCreationPolicy( + DeferredRegistrationUserCreation.FormMode.REGISTRATION.getValue())); + } + + @Test + void dummyHashUsesTheRealmPasswordPolicy() { + ValidationContext context = mock(ValidationContext.class); + KeycloakSession session = mock(KeycloakSession.class); + RealmModel realm = mock(RealmModel.class); + PasswordPolicy policy = mock(PasswordPolicy.class); + PasswordHashProvider provider = mock(PasswordHashProvider.class); + when(context.getSession()).thenReturn(session); + when(context.getRealm()).thenReturn(realm); + when(realm.getPasswordPolicy()).thenReturn(policy); + when(policy.getHashAlgorithm()).thenReturn("pbkdf2-sha256"); + when(policy.getHashIterations()).thenReturn(27500); + when(session.getProvider(PasswordHashProvider.class, "pbkdf2-sha256")).thenReturn(provider); + + DeferredRegistrationUserCreation.performDummyHash(context); + + verify(provider).encodedCredential("SlightlyLongerDummyPassword", 27500); + } + + @Test + void dummyHashUsesDefaultProviderWithoutAPasswordPolicy() { + ValidationContext context = mock(ValidationContext.class); + KeycloakSession session = mock(KeycloakSession.class); + RealmModel realm = mock(RealmModel.class); + PasswordHashProvider provider = mock(PasswordHashProvider.class); + when(context.getSession()).thenReturn(session); + when(context.getRealm()).thenReturn(realm); + when(realm.getPasswordPolicy()).thenReturn(null); + when(session.getProvider(PasswordHashProvider.class)).thenReturn(provider); + + DeferredRegistrationUserCreation.performDummyHash(context); + + verify(provider).encodedCredential("SlightlyLongerDummyPassword", -1); + } + + @Test + void dummyHashFallsBackWhenTheConfiguredProviderIsUnavailable() { + ValidationContext context = mock(ValidationContext.class); + KeycloakSession session = mock(KeycloakSession.class); + RealmModel realm = mock(RealmModel.class); + PasswordPolicy policy = mock(PasswordPolicy.class); + PasswordHashProvider provider = mock(PasswordHashProvider.class); + when(context.getSession()).thenReturn(session); + when(context.getRealm()).thenReturn(realm); + when(realm.getPasswordPolicy()).thenReturn(policy); + when(policy.getHashAlgorithm()).thenReturn("missing-provider"); + when(policy.getHashIterations()).thenReturn(12345); + when(session.getProvider(PasswordHashProvider.class, "missing-provider")).thenReturn(null); + when(session.getProvider(PasswordHashProvider.class)).thenReturn(provider); + + DeferredRegistrationUserCreation.performDummyHash(context); + + verify(session).getProvider(PasswordHashProvider.class, "missing-provider"); + verify(provider).encodedCredential("SlightlyLongerDummyPassword", -1); + } + + @Test + void dummyHashUsesPolicyIterationsWithTheDefaultProvider() { + ValidationContext context = mock(ValidationContext.class); + KeycloakSession session = mock(KeycloakSession.class); + RealmModel realm = mock(RealmModel.class); + PasswordPolicy policy = mock(PasswordPolicy.class); + PasswordHashProvider provider = mock(PasswordHashProvider.class); + when(context.getSession()).thenReturn(session); + when(context.getRealm()).thenReturn(realm); + when(realm.getPasswordPolicy()).thenReturn(policy); + when(policy.getHashAlgorithm()).thenReturn(null); + when(policy.getHashIterations()).thenReturn(31000); + when(session.getProvider(PasswordHashProvider.class)).thenReturn(provider); + + DeferredRegistrationUserCreation.performDummyHash(context); + + verify(provider).encodedCredential("SlightlyLongerDummyPassword", 31000); + } + + @Test + void dummyHashFailsExplicitlyWhenNoProviderIsAvailable() { + ValidationContext context = mock(ValidationContext.class); + KeycloakSession session = mock(KeycloakSession.class); + RealmModel realm = mock(RealmModel.class); + PasswordPolicy policy = mock(PasswordPolicy.class); + when(context.getSession()).thenReturn(session); + when(context.getRealm()).thenReturn(realm); + when(realm.getPasswordPolicy()).thenReturn(policy); + when(policy.getHashAlgorithm()).thenReturn("missing-provider"); + when(session.getProvider(PasswordHashProvider.class, "missing-provider")).thenReturn(null); + when(session.getProvider(PasswordHashProvider.class)).thenReturn(null); + + IllegalStateException error = + assertThrows( + IllegalStateException.class, + () -> DeferredRegistrationUserCreation.performDummyHash(context)); + + assertEquals("No password hash provider is available for dummy hashing", error.getMessage()); + } + + @Test + void loginAttemptStateIsResetAndEarlyProfileErrorsUseDummyHash() { + ValidationContext context = mock(ValidationContext.class); + AuthenticatorConfigModel config = mock(AuthenticatorConfigModel.class); + KeycloakSession session = mock(KeycloakSession.class); + RealmModel realm = mock(RealmModel.class); + HttpRequest request = mock(HttpRequest.class); + EventBuilder event = mock(EventBuilder.class); + AuthenticationSessionModel authenticationSession = mock(AuthenticationSessionModel.class); + UserProfile profile = mock(UserProfile.class); + Attributes attributes = mock(Attributes.class); + UserModel user = mock(UserModel.class); + PasswordHashProvider provider = mock(PasswordHashProvider.class); + MultivaluedMap formData = new MultivaluedHashMap<>(); + formData.add(UserModel.USERNAME, "voter"); + formData.add(RegistrationPage.FIELD_PASSWORD, "12345678"); + + when(context.getAuthenticatorConfig()).thenReturn(config); + when(config.getConfig()) + .thenReturn( + Map.of( + DeferredRegistrationUserCreation.SEARCH_ATTRIBUTES, + UserModel.USERNAME, + DeferredRegistrationUserCreation.FORM_MODE, + DeferredRegistrationUserCreation.FormMode.LOGIN.getValue(), + DeferredRegistrationUserCreation.PASSWORD_REQUIRED, + "true")); + when(context.getSession()).thenReturn(session); + when(context.getRealm()).thenReturn(realm); + when(realm.getAttributes()).thenReturn(Map.of()); + when(realm.isRegistrationEmailAsUsername()).thenReturn(false); + when(realm.getPasswordPolicy()).thenReturn(null); + when(session.getProvider(PasswordHashProvider.class)).thenReturn(provider); + when(context.getHttpRequest()).thenReturn(request); + when(request.getDecodedFormParameters()).thenReturn(formData); + when(context.getEvent()).thenReturn(event); + when(context.getAuthenticationSession()).thenReturn(authenticationSession); + when(session.getAttribute("UP_REGISTER")).thenReturn(profile); + when(profile.getAttributes()).thenReturn(attributes); + when(attributes.getFirst(UserModel.EMAIL)).thenReturn("invalid@example.com"); + + try (MockedStatic utils = mockStatic(Utils.class)) { + utils + .when(() -> Utils.lookupUserByFormData(context, List.of(UserModel.USERNAME), formData)) + .thenReturn(user); + + DeferredRegistrationUserCreation action = new DeferredRegistrationUserCreation(); + action.validate(context); + action.validate(context); + } + + verify(authenticationSession, times(2)) + .removeAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME); + verify(authenticationSession, never()) + .setAuthNote(eq(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME), any()); + verify(provider, times(2)).encodedCredential("SlightlyLongerDummyPassword", -1); + } + + @Test + void failedLoginCredentialRetainsAttemptedUsernameForBruteForceAccounting() { + ValidationContext context = mock(ValidationContext.class); + AuthenticatorConfigModel config = mock(AuthenticatorConfigModel.class); + KeycloakSession session = mock(KeycloakSession.class); + RealmModel realm = mock(RealmModel.class); + HttpRequest request = mock(HttpRequest.class); + EventBuilder event = mock(EventBuilder.class); + AuthenticationSessionModel authenticationSession = mock(AuthenticationSessionModel.class); + UserProfile profile = mock(UserProfile.class); + Attributes attributes = mock(Attributes.class); + UserModel user = mock(UserModel.class); + SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class); + MultivaluedMap formData = new MultivaluedHashMap<>(); + formData.add(UserModel.USERNAME, "voter"); + formData.add(RegistrationPage.FIELD_PASSWORD, "wrong-pin"); + + when(context.getAuthenticatorConfig()).thenReturn(config); + when(config.getConfig()) + .thenReturn( + Map.of( + DeferredRegistrationUserCreation.SEARCH_ATTRIBUTES, + UserModel.USERNAME, + DeferredRegistrationUserCreation.FORM_MODE, + DeferredRegistrationUserCreation.FormMode.LOGIN.getValue(), + DeferredRegistrationUserCreation.PASSWORD_REQUIRED, + "true")); + when(context.getSession()).thenReturn(session); + when(context.getRealm()).thenReturn(realm); + when(realm.getAttributes()).thenReturn(Map.of()); + when(realm.isRegistrationEmailAsUsername()).thenReturn(false); + when(realm.isBruteForceProtected()).thenReturn(false); + when(context.getHttpRequest()).thenReturn(request); + when(request.getDecodedFormParameters()).thenReturn(formData); + when(context.getEvent()).thenReturn(event); + when(context.getAuthenticationSession()).thenReturn(authenticationSession); + when(session.getAttribute("UP_REGISTER")).thenReturn(profile); + when(profile.getAttributes()).thenReturn(attributes); + when(attributes.getFirst(UserModel.EMAIL)).thenReturn(null); + when(user.isEnabled()).thenReturn(true); + when(user.getUsername()).thenReturn("voter"); + when(user.credentialManager()).thenReturn(credentialManager); + when(credentialManager.isValid(any(CredentialInput.class))).thenReturn(false); + + try (MockedStatic utils = mockStatic(Utils.class)) { + utils + .when(() -> Utils.lookupUserByFormData(context, List.of(UserModel.USERNAME), formData)) + .thenReturn(user); + + new DeferredRegistrationUserCreation().validate(context); + } + + verify(authenticationSession, times(1)) + .removeAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME); + verify(authenticationSession) + .setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, "voter"); + verify(context, never()).success(); + } + + @Test + void correctLoginCredentialClearsAttemptStateBeforeLaterValidationError() { + ValidationContext context = mock(ValidationContext.class); + AuthenticatorConfigModel config = mock(AuthenticatorConfigModel.class); + KeycloakSession session = mock(KeycloakSession.class); + RealmModel realm = mock(RealmModel.class); + HttpRequest request = mock(HttpRequest.class); + EventBuilder event = mock(EventBuilder.class); + AuthenticationSessionModel authenticationSession = mock(AuthenticationSessionModel.class); + RootAuthenticationSessionModel rootSession = mock(RootAuthenticationSessionModel.class); + UserProfile profile = mock(UserProfile.class); + Attributes attributes = mock(Attributes.class); + UserProvider users = mock(UserProvider.class); + UserModel user = mock(UserModel.class); + UserModel duplicate = mock(UserModel.class); + SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class); + MultivaluedMap formData = new MultivaluedHashMap<>(); + formData.add(UserModel.USERNAME, "voter"); + formData.add("phone", "123"); + formData.add(RegistrationPage.FIELD_PASSWORD, "12345678"); + + when(context.getAuthenticatorConfig()).thenReturn(config); + when(config.getConfig()) + .thenReturn( + Map.of( + DeferredRegistrationUserCreation.SEARCH_ATTRIBUTES, + UserModel.USERNAME, + DeferredRegistrationUserCreation.UNIQUE_ATTRIBUTES, + "phone", + DeferredRegistrationUserCreation.FORM_MODE, + DeferredRegistrationUserCreation.FormMode.LOGIN.getValue(), + DeferredRegistrationUserCreation.PASSWORD_REQUIRED, + "true")); + when(context.getSession()).thenReturn(session); + when(context.getRealm()).thenReturn(realm); + when(realm.getAttributes()).thenReturn(Map.of()); + when(realm.isRegistrationEmailAsUsername()).thenReturn(false); + when(context.getHttpRequest()).thenReturn(request); + when(request.getDecodedFormParameters()).thenReturn(formData); + when(context.getEvent()).thenReturn(event); + when(session.getAttribute("UP_REGISTER")).thenReturn(profile); + when(profile.getAttributes()).thenReturn(attributes); + when(attributes.getFirst(UserModel.EMAIL)).thenReturn(null); + when(user.isEnabled()).thenReturn(true); + when(user.getUsername()).thenReturn("voter"); + when(user.credentialManager()).thenReturn(credentialManager); + when(credentialManager.isValid(any(CredentialInput.class))).thenReturn(true); + when(user.getFirstAttribute("phone")).thenReturn(null); + when(user.getAttributes()).thenReturn(Map.of()); + when(context.getAuthenticationSession()).thenReturn(authenticationSession); + when(authenticationSession.getParentSession()).thenReturn(rootSession); + when(rootSession.getId()).thenReturn("session-id"); + when(session.users()).thenReturn(users); + when(users.searchForUserStream(realm, Map.of("phone", "123"))) + .thenReturn(Stream.of(user, duplicate)); + + try (MockedStatic utils = mockStatic(Utils.class)) { + utils + .when(() -> Utils.lookupUserByFormData(context, List.of(UserModel.USERNAME), formData)) + .thenReturn(user); + + new DeferredRegistrationUserCreation().validate(context); + } + + verify(context) + .error( + Utils.ERROR_USER_ATTRIBUTES_NOT_UNIQUE + + ": Unique attribute phone with value=123 present in more than one user"); + verify(context).validationError(eq(formData), anyList()); + verify(context, never()).success(); + + InOrder notes = inOrder(authenticationSession); + notes + .verify(authenticationSession) + .removeAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME); + notes + .verify(authenticationSession) + .setAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME, "voter"); + notes + .verify(authenticationSession) + .removeAuthNote(AbstractUsernameFormAuthenticator.ATTEMPTED_USERNAME); + } + + @Test + void disabledUserUsesTheSameStandardFieldErrorAsABadPassword() throws Exception { + ValidationContext context = mock(ValidationContext.class); + KeycloakSession session = mock(KeycloakSession.class); + RealmModel realm = mock(RealmModel.class); + EventBuilder event = mock(EventBuilder.class); + UserModel user = mock(UserModel.class); + PasswordHashProvider provider = mock(PasswordHashProvider.class); + MultivaluedMap formData = new MultivaluedHashMap<>(); + formData.add(RegistrationPage.FIELD_PASSWORD, "12345678"); + + when(context.getSession()).thenReturn(session); + when(context.getRealm()).thenReturn(realm); + when(context.getEvent()).thenReturn(event); + when(realm.getPasswordPolicy()).thenReturn(null); + when(session.getProvider(PasswordHashProvider.class)).thenReturn(provider); + when(user.isEnabled()).thenReturn(false); + + Method method = + DeferredRegistrationUserCreation.class.getDeclaredMethod( + "validatePasswordForLogin", + ValidationContext.class, + UserModel.class, + MultivaluedMap.class, + boolean.class); + method.setAccessible(true); + + assertFalse( + (boolean) + method.invoke(new DeferredRegistrationUserCreation(), context, user, formData, false)); + + verify(event).error(org.keycloak.events.Errors.USER_DISABLED); + verify(context) + .validationError( + eq(formData), + argThat( + errors -> + errors.size() == 1 + && RegistrationPage.FIELD_PASSWORD.equals(errors.get(0).getField()) + && Messages.INVALID_PASSWORD.equals(errors.get(0).getMessage()))); + } + @Test void normalizeFormParametersRemovesHiddenAndSensitiveFields() throws Exception { MultivaluedMap formParams = new MultivaluedHashMap<>(); @@ -51,6 +470,20 @@ void normalizeFormParametersRemovesHiddenAndSensitiveFields() throws Exception { assertTrue(normalized.containsKey(UserModel.EMAIL)); } + @Test + void removeCredentialValuesSanitizesValidationFormData() { + MultivaluedMap formData = new MultivaluedHashMap<>(); + formData.add(RegistrationPage.FIELD_USERNAME, "voter"); + formData.add(RegistrationPage.FIELD_PASSWORD, "12345678"); + formData.add(RegistrationPage.FIELD_PASSWORD_CONFIRM, "12345678"); + + DeferredRegistrationUserCreation.removeCredentialValues(formData); + + assertTrue(formData.containsKey(RegistrationPage.FIELD_USERNAME)); + assertFalse(formData.containsKey(RegistrationPage.FIELD_PASSWORD)); + assertFalse(formData.containsKey(RegistrationPage.FIELD_PASSWORD_CONFIRM)); + } + @Test void hiddenProfileAttributesDefaultToLocale() { assertEquals( diff --git a/packages/package.json b/packages/package.json index 097bd56fa7d..2ee4ac28f4c 100644 --- a/packages/package.json +++ b/packages/package.json @@ -9,6 +9,7 @@ "ui-essentials", "voting-portal", "admin-portal", + "keycloak-extensions/sequent-theme", "ballot-verifier", "results-portal" ], diff --git a/packages/sequent-core/src/services/keycloak/realm_attributes.rs b/packages/sequent-core/src/services/keycloak/realm_attributes.rs index 60457939478..925d299085d 100644 --- a/packages/sequent-core/src/services/keycloak/realm_attributes.rs +++ b/packages/sequent-core/src/services/keycloak/realm_attributes.rs @@ -4,6 +4,9 @@ use crate::ballot::VoterCertificatePolicy; use crate::services::keycloak::{get_event_realm, KeycloakAdminClient}; use crate::types::keycloak::{ + CredentialInputPolicy, MAX_CREDENTIAL_PATTERN_GROUPS, + MAX_CREDENTIAL_PATTERN_GROUP_SIZE, MAX_CREDENTIAL_PATTERN_TOTAL_SIZE, + REALM_ATTR_CREDENTIAL_INPUT_PATTERN, REALM_ATTR_CREDENTIAL_INPUT_POLICY, REALM_ATTR_SMARTLINK_CLOCK_SKEW_SECS, REALM_ATTR_SMARTLINK_ENABLED, REALM_ATTR_SMARTLINK_REQUIRED_ATTRIBUTES, REALM_ATTR_SMARTLINK_SHARED_SECRET, REALM_ATTR_SMARTLINK_TIMEOUT_SECS, @@ -140,6 +143,18 @@ pub fn validate_realm_attributes( fn validate_realm_attribute_value(key: &str, value: &str) -> Result<()> { match key { + REALM_ATTR_CREDENTIAL_INPUT_POLICY => { + if CredentialInputPolicy::from_str(value).is_err() { + bail!("Invalid value {value:?} for realm attribute {key}"); + } + } + REALM_ATTR_CREDENTIAL_INPUT_PATTERN => { + if !is_valid_credential_input_pattern(value) { + bail!( + "Realm attribute {key} must contain 1 to {MAX_CREDENTIAL_PATTERN_GROUPS} dash-separated groups of 1 to {MAX_CREDENTIAL_PATTERN_GROUP_SIZE} 'd' digit tokens, with no more than {MAX_CREDENTIAL_PATTERN_TOTAL_SIZE} digits in total" + ); + } + } REALM_ATTR_SMARTLINK_ENABLED => { if bool::from_str(value).is_err() { bail!("Realm attribute {key} must be 'true' or 'false'"); @@ -175,6 +190,32 @@ fn validate_realm_attribute_value(key: &str, value: &str) -> Result<()> { Ok(()) } +fn is_valid_credential_input_pattern(value: &str) -> bool { + let groups = value.split('-').collect::>(); + if groups.len() > MAX_CREDENTIAL_PATTERN_GROUPS { + return false; + } + + let mut total = 0; + for group in groups { + if group.is_empty() || !group.bytes().all(|byte| byte == b'd') { + return false; + } + + let size = group.len(); + if size > MAX_CREDENTIAL_PATTERN_GROUP_SIZE { + return false; + } + + total += size; + if total > MAX_CREDENTIAL_PATTERN_TOTAL_SIZE { + return false; + } + } + + true +} + pub fn redacted_attributes( attributes: &HashMap, ) -> HashMap { @@ -217,6 +258,8 @@ mod tests { fn validate_realm_attributes_accepts_generic_names() { let attributes = attributes(&[ ("acr.loa.map", "{}"), + ("credential-input-policy", "structured"), + ("credential-input-pattern", "dddd-dddd-dddd-dddd"), ("smart-link-enabled", "true"), ("smart-link-timeout-secs", "90"), ("smart-link-clock-skew-secs", "5"), @@ -226,6 +269,66 @@ mod tests { assert!(validate_realm_attributes(&attributes).is_ok()); } + #[test] + fn validate_realm_attributes_accepts_credential_input_configuration() { + for (policy, pattern) in [ + ("standard", "dddd-dddd-dddd-dddd"), + ("structured", "ddd-ddd"), + ("structured", "dd-dddd-dd"), + ("structured", "dddddddd-dddddddd-dddddddd-dddddddd-dddddddd-dddddddd-dddddddd-dddddddd"), + ] { + assert!( + validate_realm_attributes(&attributes(&[ + ("credential-input-policy", policy), + ("credential-input-pattern", pattern), + ])) + .is_ok(), + "expected policy={policy:?}, pattern={pattern:?} to be accepted" + ); + } + } + + #[test] + fn validate_realm_attributes_rejects_invalid_credential_input_policy() { + for value in ["true", "segmented-numeric", "numeric", "STANDARD"] { + assert!( + validate_realm_attributes(&attributes(&[( + "credential-input-policy", + value, + )])) + .is_err(), + "expected credential-input-policy={value:?} to be rejected" + ); + } + } + + #[test] + fn validate_realm_attributes_rejects_invalid_credential_input_pattern() { + for value in [ + "4-4", + "dddd--dddd", + "dddd-", + "-dddd", + "dddd_dddd", + "dddd?", + "dddd*", + "DDDD", + "ddddddddddddd", + "dddddddd-dddddddd-dddddddd-dddddddd-dddddddd-dddddddd-dddddddd-dddddddd-dd", + "dddddddd-dddddddd-dddddddd-dddddddd-dddddddd-dddddddd-dddddddd-dddddddd-dddddddd", + "dddd-dddd", + ] { + assert!( + validate_realm_attributes(&attributes(&[( + "credential-input-pattern", + value, + )])) + .is_err(), + "expected credential-input-pattern={value:?} to be rejected" + ); + } + } + #[test] fn validate_realm_attributes_rejects_blank_names() { let attributes = attributes(&[(" ", "value")]); diff --git a/packages/sequent-core/src/types/keycloak.rs b/packages/sequent-core/src/types/keycloak.rs index cbc593f1f18..4b70fe6cbef 100644 --- a/packages/sequent-core/src/types/keycloak.rs +++ b/packages/sequent-core/src/types/keycloak.rs @@ -5,6 +5,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::collections::HashMap; +use strum_macros::{Display, EnumString}; /// A voter can be disabled: /// @@ -40,8 +41,37 @@ pub const LAST_NAME: &str = "lastName"; pub const PERMISSION_LABELS: &str = "permission_labels"; pub const REALM_ATTR_VOTER_CERTIFICATE_POLICY: &str = "voter-certificate-policy"; +pub const REALM_ATTR_CREDENTIAL_INPUT_POLICY: &str = "credential-input-policy"; +pub const REALM_ATTR_CREDENTIAL_INPUT_PATTERN: &str = + "credential-input-pattern"; +pub const MAX_CREDENTIAL_PATTERN_GROUPS: usize = 8; +pub const MAX_CREDENTIAL_PATTERN_GROUP_SIZE: usize = 12; +pub const MAX_CREDENTIAL_PATTERN_TOTAL_SIZE: usize = 64; pub const CERTIFICATES_IDP_ALIAS: &str = "digital-certificates"; +#[allow(non_camel_case_types)] +#[derive( + Default, + Display, + Serialize, + Deserialize, + Debug, + PartialEq, + Eq, + Clone, + EnumString, + JsonSchema, +)] +pub enum CredentialInputPolicy { + #[default] + #[strum(serialize = "standard")] + #[serde(rename = "standard")] + STANDARD, + #[strum(serialize = "structured")] + #[serde(rename = "structured")] + STRUCTURED, +} + /// Default client ID used by the IVR for system-level interactions. pub const DEFAULT_IVR_SERVICE_CLIENT_ID: &str = "ivr-service"; /// Client ID used by the IVR for voting. From 557edbeb2a1fd137593cb36f0178d969601018df Mon Sep 17 00:00:00 2001 From: Findeton Date: Mon, 27 Jul 2026 10:41:42 -0500 Subject: [PATCH 2/2] fix sonar --- .github/workflows/sonarqube.yml | 4 ++-- beyond | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 7b0c1be0565..ff09cfa4e99 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -202,7 +202,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: mvn -B verify sonar:sonar -f packages/keycloak-extensions/pom.xml + run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:5.7.0.6970:sonar -f packages/keycloak-extensions/pom.xml -Dsonar.host.url=https://sonarcloud.io -Dsonar.token=$SONAR_TOKEN -Dsonar.organization=sequentech @@ -216,7 +216,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: mvn -B verify sonar:sonar -f packages/keycloak-extensions/pom.xml + run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:5.7.0.6970:sonar -f packages/keycloak-extensions/pom.xml -Dsonar.host.url=https://sonarcloud.io -Dsonar.token=$SONAR_TOKEN -Dsonar.organization=sequentech diff --git a/beyond b/beyond index 58a5f19fc83..dffa529c7ef 160000 --- a/beyond +++ b/beyond @@ -1 +1 @@ -Subproject commit 58a5f19fc83da0d86279c4dda889c9b08d4e595f +Subproject commit dffa529c7ef4be2f3d5cffda9985b853b65a0293