From e274dbdb6afda9309f8332fdab72fb647e420684 Mon Sep 17 00:00:00 2001 From: xalsina-sequent Date: Tue, 21 Jul 2026 10:43:48 +0000 Subject: [PATCH 01/13] Add username-less DOB + PIN authentication --- ...utorials_multi-attribute-password-login.md | 101 +++++ .../message-otp-authenticator/pom.xml | 35 ++ .../MultiAttributePasswordAuthenticator.java | 305 +++++++++++++++ .../authenticator/forgot_password/Utils.java | 31 ++ .../messages/messages_ca.properties | 6 +- .../messages/messages_en.properties | 4 + .../messages/messages_es.properties | 4 + .../messages/messages_eu.properties | 5 +- .../messages/messages_gl.properties | 4 + .../messages/messages_tl.properties | 4 + ...ltiAttributePasswordAuthenticatorTest.java | 359 ++++++++++++++++++ .../sequent.admin-portal/login/login.ftl | 31 +- .../sequent.voting-portal/login/login.ftl | 31 +- 13 files changed, 906 insertions(+), 14 deletions(-) create mode 100644 docs/docusaurus/docs/02-election_managers/01-tutorials/101-admin_portal_tutorials_multi-attribute-password-login.md create mode 100644 packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java create mode 100644 packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticatorTest.java diff --git a/docs/docusaurus/docs/02-election_managers/01-tutorials/101-admin_portal_tutorials_multi-attribute-password-login.md b/docs/docusaurus/docs/02-election_managers/01-tutorials/101-admin_portal_tutorials_multi-attribute-password-login.md new file mode 100644 index 00000000000..de3948dec45 --- /dev/null +++ b/docs/docusaurus/docs/02-election_managers/01-tutorials/101-admin_portal_tutorials_multi-attribute-password-login.md @@ -0,0 +1,101 @@ +--- +id: admin_portal_tutorials_multi_attribute_password_login +title: Logging In Without a Username (Attribute + Password) +--- + + + +## Overview + +By default, voters log in with a username and password. Some elections instead identify voters by +attributes they already know - a date of birth, a national ID - without asking them to remember a +separate username. This tutorial explains how to configure the **Multi-Attribute + Password Form** +authenticator so voters log in with one or more configured user attributes plus a password, +instead of a username. + +The authenticator finds every user whose configured attribute(s) match the submitted value(s) - +**all** configured attributes must match the same user - then checks the submitted password +against that candidate. Login succeeds only when **exactly one** candidate's password matches. + +A single attribute like date of birth is not unique on its own (many voters share a birth date). +This still works: the authenticator collects every user with that birth date as candidates and +relies on the password to uniquely identify one of them. Configuring a second attribute (e.g. also +a national ID) narrows the candidate set before the password check, and is recommended whenever a +second identifying attribute is available. + +--- + +## Prerequisites + +- Access to the Keycloak Admin Console. +- The `sequent.message-otp-authenticator.jar` extension deployed in Keycloak's `providers/` + directory (included in the Sequent Keycloak Docker image by default). +- The user attribute(s) you want to log in with must already exist in the realm's **User + Profile** - see + [Adding User Attributes to Keycloak](./99-admin_portal_tutorials_add-user-attributes-to-keycloak.md). + If an attribute is configured there with **Input type** `html5-date` (e.g. a date of birth + field), the login form automatically renders it as a native date picker too, matching what + voters already see at registration. + +--- + +## Step 1 – Create a Browser Flow with the New Authenticator + +1. Navigate to **Authentication** → **Flows**. +2. Duplicate an existing browser flow (e.g. the realm's default `browser` flow) or create a new + one, and give it a descriptive name such as `attribute password login`. +3. Inside the appropriate sub-flow, click **Add step**. +4. Search for **Multi-Attribute + Password Form** and add it. +5. Set the requirement to **ALTERNATIVE** (to offer it alongside other login methods already in + the flow) or **REQUIRED** (to make it the only way to log in on that flow). + +--- + +## Step 2 – Configure the Attributes to Match + +1. Click **⚙ Config** (gear icon) next to the new step. +2. Give the config a descriptive alias, e.g. `attribute-login-dateOfBirth`. +3. Under **User attributes to match**, click **+ Add** and enter each user attribute name to + require, e.g. `dateOfBirth`, then **+ Add** again for `nationalId` if you want a second + attribute (all listed attributes must match the same user). These must be existing User + Profile attribute names (see Prerequisites). +4. Click **Save**. + +--- + +## Step 3 – Bind the Flow to a Client + +1. Navigate to **Clients** and select the client this login page applies to (e.g. + `onsite-voting-portal`). +2. Open the **Advanced** tab → **Authentication flow overrides**. +3. Set **Browser Flow** to the flow you created in Step 1. +4. Click **Save**. + +This keeps the change scoped to the client(s) you explicitly bind it to - every other client +keeps using the realm's default browser flow unchanged. + +--- + +## Behavior Summary + +| Scenario | Authenticator action | +|---|---| +| A configured attribute field is left blank | Generic "invalid credentials" error (no lookup performed). | +| No user matches all configured attributes | Generic "invalid credentials" error. | +| Exactly one candidate, correct password | Login succeeds. | +| Exactly one candidate, wrong password | Generic "invalid credentials" error. | +| Multiple candidates share the configured attribute(s), and the password matches exactly one | Login succeeds as that user. | +| Multiple candidates match the password (or none do) | Generic "invalid credentials" error. | + +The error is always the same generic message regardless of cause, so a failed attempt never +reveals which attribute, or the password, was wrong. + +> **Note on brute-force protection:** Keycloak's built-in per-account lockout is tied to a +> resolved user. When more than one candidate matches the configured attributes, there is no +> single user to attribute a failed attempt to until the password check resolves, so the +> brute-force counter cannot engage the same way it does for a standard username/password login. +> Configuring more attributes narrows the candidate set before the password check and is the main +> way to mitigate this; keep **Brute Force Detection** enabled at the realm level regardless. diff --git a/packages/keycloak-extensions/message-otp-authenticator/pom.xml b/packages/keycloak-extensions/message-otp-authenticator/pom.xml index 6f6d2682974..88c7c2f87b1 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/pom.xml +++ b/packages/keycloak-extensions/message-otp-authenticator/pom.xml @@ -22,6 +22,8 @@ SPDX-License-Identifier: AGPL-3.0-only 17 3.14.0 3.6.1 + 5.11.3 + 5.14.2 @@ -89,6 +91,34 @@ SPDX-License-Identifier: AGPL-3.0-only 10.9.2 compile + + + + org.junit.jupiter + junit-jupiter-api + ${junit.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.version} + test + + + + + org.mockito + mockito-core + ${mockito.version} + test + + + org.mockito + mockito-junit-jupiter + ${mockito.version} + test + @@ -121,6 +151,11 @@ SPDX-License-Identifier: AGPL-3.0-only + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.2 + com.diffplug.spotless spotless-maven-plugin diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java new file mode 100644 index 00000000000..5cb84377f8c --- /dev/null +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java @@ -0,0 +1,305 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +package sequent.keycloak.authenticator.forgot_password; + +import com.google.auto.service.AutoService; +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.extern.jbosslog.JBossLog; +import org.keycloak.Config; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.Authenticator; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.events.Errors; +import org.keycloak.forms.login.LoginFormsProvider; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.KeycloakSessionFactory; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.services.messages.Messages; + +/** + * Authenticates a user by matching one or more configured user attributes against submitted form + * values, all against the same user, plus a password. Does not require a username. + * + *

Resolution: for each configured {@code matchAttributes} entry, find every user whose attribute + * equals the submitted value, then intersect those candidate sets across all attributes. If exactly + * one candidate's password matches the submitted password, that user authenticates. Any other + * outcome (no candidates, no password match, more than one password match) fails with a generic + * error to avoid revealing which part of the submission was wrong. + */ +@JBossLog +@AutoService(AuthenticatorFactory.class) +public class MultiAttributePasswordAuthenticator implements Authenticator, AuthenticatorFactory { + public static final String PROVIDER_ID = "multi-attribute-password-form"; + + /** + * Renders the active theme's own {@code login.ftl} (voting-portal / admin-portal), instead of a + * bespoke template, so this authenticator gets the same registration link, social-provider + * buttons, remember-me and password-visibility toggle as the standard login form. {@code + * login.ftl} renders its single "username" field as one field per {@code matchAttributes} entry + * when that template attribute is set - see {@link #challenge}. + */ + public static final String FORM_FTL = "login.ftl"; + + public static final String FIELD_PASSWORD = "password"; + public static final MultiAttributePasswordAuthenticator SINGLETON = + new MultiAttributePasswordAuthenticator(); + public static final Requirement[] REQUIREMENT_CHOICES = { + Requirement.REQUIRED, Requirement.ALTERNATIVE, Requirement.DISABLED + }; + + @Override + public void authenticate(AuthenticationFlowContext context) { + Response challengeResponse = challenge(context, new MultivaluedHashMap<>(), null); + context.challenge(challengeResponse); + } + + @Override + public void action(AuthenticationFlowContext context) { + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + if (formData.containsKey("cancel")) { + context.cancelLogin(); + return; + } + + List matchAttributes = + Utils.getMultivalueString( + context.getAuthenticatorConfig(), + Utils.MATCH_ATTRIBUTES, + Utils.MATCH_ATTRIBUTES_DEFAULT); + Map submittedValues = new HashMap<>(); + for (String attribute : matchAttributes) { + submittedValues.put(attribute, formData.getFirst(attribute)); + } + String password = formData.getFirst(FIELD_PASSWORD); + + Optional user = + resolveAuthenticatedUser( + context.getSession(), context.getRealm(), matchAttributes, submittedValues, password); + + if (user.isPresent()) { + context.setUser(user.get()); + context.success(); + } else { + fail(context, formData); + } + } + + private void fail(AuthenticationFlowContext context, MultivaluedMap formData) { + context.getEvent().error(Errors.INVALID_USER_CREDENTIALS); + Response challengeResponse = challenge(context, formData, Messages.INVALID_USER); + context.failureChallenge(AuthenticationFlowError.INVALID_CREDENTIALS, challengeResponse); + } + + /** + * Resolves the single user matching every configured attribute AND the submitted password. + * + *

Returns {@link Optional#empty()} for any ambiguous or unmatched outcome (missing + * configuration, blank input, zero candidates, or zero/multiple password matches) so callers + * never need to distinguish "no such attributes" from "wrong password" - that distinction must + * not leak to the end user. + */ + protected Optional resolveAuthenticatedUser( + KeycloakSession session, + RealmModel realm, + List matchAttributes, + Map submittedValues, + String password) { + if (matchAttributes == null || matchAttributes.isEmpty()) { + log.warn("resolveAuthenticatedUser(): no matchAttributes configured"); + return Optional.empty(); + } + if (password == null || password.isBlank()) { + return Optional.empty(); + } + + Map candidatesById = null; + for (String attribute : matchAttributes) { + String value = submittedValues.get(attribute); + if (value == null || value.isBlank()) { + return Optional.empty(); + } + value = value.trim(); + + Map matchesForAttribute = + findUsersByAttribute(session, realm, attribute, value) + .collect(Collectors.toMap(UserModel::getId, Function.identity(), (a, b) -> a)); + + if (candidatesById == null) { + candidatesById = matchesForAttribute; + } else { + candidatesById.keySet().retainAll(matchesForAttribute.keySet()); + } + + if (candidatesById.isEmpty()) { + return Optional.empty(); + } + } + + List passwordMatches = + candidatesById.values().stream() + .filter(UserModel::isEnabled) + .filter(candidate -> isPasswordValid(candidate, password)) + .collect(Collectors.toList()); + + if (passwordMatches.size() != 1) { + if (passwordMatches.size() > 1) { + log.warnv( + "resolveAuthenticatedUser(): ambiguous match, {0} candidates matched the submitted" + + " password", + passwordMatches.size()); + } + return Optional.empty(); + } + + return Optional.of(passwordMatches.get(0)); + } + + protected Stream findUsersByAttribute( + KeycloakSession session, RealmModel realm, String attribute, String value) { + if ("email".equalsIgnoreCase(attribute)) { + UserModel user = session.users().getUserByEmail(realm, value); + return user == null ? Stream.empty() : Stream.of(user); + } + if ("username".equalsIgnoreCase(attribute)) { + UserModel user = session.users().getUserByUsername(realm, value); + return user == null ? Stream.empty() : Stream.of(user); + } + return session.users().searchForUserByUserAttributeStream(realm, attribute, value); + } + + protected boolean isPasswordValid(UserModel user, String password) { + return user.credentialManager().isValid(UserCredentialModel.password(password)); + } + + protected Response challenge( + AuthenticationFlowContext context, MultivaluedMap formData, String error) { + LoginFormsProvider form = context.form(); + + if (formData.size() > 0) { + form.setFormData(formData); + } + if (error != null) { + form.setError(error); + } + + List matchAttributes = + Utils.getMultivalueString( + context.getAuthenticatorConfig(), + Utils.MATCH_ATTRIBUTES, + Utils.MATCH_ATTRIBUTES_DEFAULT); + form.setAttribute( + "matchAttributes", buildAttributeFields(context.getSession(), matchAttributes)); + + return form.createForm(FORM_FTL); + } + + /** + * Builds the {@code {name, type}} pairs the template renders one input per configured attribute + * from. {@code type} mirrors whatever HTML5 input type (e.g. {@code date}) the realm's User + * Profile configuration declares for that attribute, so a field like {@code dateOfBirth} renders + * the same native date picker here as it does at registration - see {@link + * Utils#resolveHtml5InputType}. + */ + protected List> buildAttributeFields( + KeycloakSession session, List matchAttributes) { + List> fields = new ArrayList<>(); + for (String attribute : matchAttributes) { + fields.add( + Map.of("name", attribute, "type", Utils.resolveHtml5InputType(session, attribute))); + } + return fields; + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {} + + @Override + public Authenticator create(KeycloakSession session) { + return SINGLETON; + } + + @Override + public void init(Config.Scope config) {} + + @Override + public void postInit(KeycloakSessionFactory factory) {} + + @Override + public void close() {} + + @Override + public String getId() { + return PROVIDER_ID; + } + + @Override + public String getReferenceCategory() { + return PasswordCredentialModel.TYPE; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public String getDisplayType() { + return "Multi-Attribute + Password Form"; + } + + @Override + public String getHelpText() { + return "Authenticates a user by matching one or more configured user attributes (all must" + + " match the same user) plus a password. Does not require a username."; + } + + @Override + public List getConfigProperties() { + return List.of( + new ProviderConfigProperty( + Utils.MATCH_ATTRIBUTES, + "User attributes to match", + "All of these user attributes must match the submitted values, for the same user." + + " For example: dateOfBirth or dateOfBirth,nationalId", + ProviderConfigProperty.MULTIVALUED_STRING_TYPE, + null)); + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } +} diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/Utils.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/Utils.java index aa9fc590458..2b3ba901042 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/Utils.java +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/Utils.java @@ -38,6 +38,8 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.RealmModel; import org.keycloak.models.UserModel; +import org.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.userprofile.UserProfileProvider; import org.keycloak.util.JsonSerialization; @UtilityClass @@ -46,6 +48,8 @@ public class Utils { public static final String USERNAME_ATTRIBUTES = "usernameAttributes"; public static final List USERNAME_ATTRIBUTES_DEFAULT = Collections.unmodifiableList(Arrays.asList("username")); + public static final String MATCH_ATTRIBUTES = "matchAttributes"; + public static final List MATCH_ATTRIBUTES_DEFAULT = Collections.emptyList(); public final String ATTEMPTED_EMAIL = "ATTEMPTED_EMAIL"; public final String DISABLE_PASSWORD_ATTRIBUTE = "disablePassword"; public final String HIDE_USER_NOT_FOUND = "hideUserNotFound"; @@ -172,6 +176,33 @@ String getPasswordExpirationUserAttribute(AuthenticatorConfigModel config) { Utils.PASSWORD_EXPIRATION_USER_ATTRIBUTE_DEFAULT); } + /** + * Looks up the HTML5 input type ({@code date}, {@code email}, ...) that the realm's User Profile + * configuration declares for {@code attributeName} via its {@code inputType} annotation (e.g. + * {@code html5-date}), the same source registration/profile forms (user-profile-commons.ftl) + * already render from. Falls back to {@code text} when the attribute has no User Profile entry, + * or its declared input type isn't an {@code html5-*} one (e.g. {@code select}, {@code textarea} + * - not meaningful for a single login lookup field). + */ + public String resolveHtml5InputType(KeycloakSession session, String attributeName) { + UserProfileProvider userProfileProvider = session.getProvider(UserProfileProvider.class); + List attributes = userProfileProvider.getConfiguration().getAttributes(); + if (attributes == null) { + return "text"; + } + Object inputType = + attributes.stream() + .filter(attribute -> attributeName.equals(attribute.getName())) + .findFirst() + .map(UPAttribute::getAnnotations) + .map(annotations -> annotations.get("inputType")) + .orElse(null); + if (!(inputType instanceof String) || !((String) inputType).startsWith("html5-")) { + return "text"; + } + return ((String) inputType).substring("html5-".length()); + } + Optional getConfig(RealmModel realm) { // Using streams to find the first matching configuration // TODO: We're assuming there's only one instance in this realm of this diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_ca.properties b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_ca.properties index 9db9502b8b0..9d2ceb11c6c 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_ca.properties +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_ca.properties @@ -129,4 +129,8 @@ resetMobileOtp.auth.error.codeExpired=El codi ha caducat. resetMobileOtp.auth.error.codeInvalid=Codi no vàlid, torneu-ho a provar. resetMobileOtp.auth.error.maxReceiverReuse=S''ha assolit el nombre màxim d''usuaris amb el mateix número de telèfon. Contacteu amb l''administrador. resetEmailOtp.auth.error.maxReceiverReuse=S''ha assolit el nombre màxim d''usuaris amb la mateixa adreça de correu. Contacteu amb l''administrador. -resetMobileOtp.auth.error.invalidCountry=Codi de país no vàlid per al número de telèfon. Contacteu amb l''administrador. \ No newline at end of file +resetMobileOtp.auth.error.invalidCountry=Codi de país no vàlid per al número de telèfon. Contacteu amb l''administrador. + +# --- Multi-Attribute Password Form field labels --- +dateOfBirth=Data de naixement +nationalId=Document d''identitat diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_en.properties b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_en.properties index faf189db52d..a75cb320aa5 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_en.properties +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_en.properties @@ -130,3 +130,7 @@ resetMobileOtp.auth.error.codeInvalid=Invalid code entered, please try again. resetMobileOtp.auth.error.maxReceiverReuse=Maximum number of users with the same phone number reached. Contact administrator. resetEmailOtp.auth.error.maxReceiverReuse=Maximum number of users with the same email address reached. Contact administrator. resetMobileOtp.auth.error.invalidCountry=Invalid country code for phone number. Contact administrator. + +# --- Multi-Attribute Password Form field labels --- +dateOfBirth=Date of birth +nationalId=National ID diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_es.properties b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_es.properties index 570a70a1a34..e6027539075 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_es.properties +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_es.properties @@ -107,3 +107,7 @@ resetMobileOtp.auth.error.codeExpired=El código ha expirado. resetMobileOtp.auth.error.codeInvalid=Código inválido ingresado, por favor intente de nuevo.resetMobileOtp.auth.error.maxReceiverReuse=Se ha alcanzado el número máximo de usuarios con el mismo número de teléfono. Contacte al administrador. resetEmailOtp.auth.error.maxReceiverReuse=Se ha alcanzado el número máximo de usuarios con la misma dirección de correo. Contacte al administrador. resetMobileOtp.auth.error.invalidCountry=Código de país no válido para el número de teléfono. Contacte al administrador. + +# --- Multi-Attribute Password Form field labels --- +dateOfBirth=Fecha de nacimiento +nationalId=Documento de identidad diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_eu.properties b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_eu.properties index 3d83ae1fcd4..52013ed4961 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_eu.properties +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_eu.properties @@ -105,4 +105,7 @@ resetMobileOtp.auth.error.resendTimer=Itxaron mesedez kodea berriro bidali aurre resetMobileOtp.auth.error.codeExpired=Kodea iraungi da. resetMobileOtp.auth.error.codeInvalid=Sartutako kodea baliogabea da, mesedez sartu berriro.resetMobileOtp.auth.error.maxReceiverReuse=Telefono zenbaki berdinarekin erabiltzaile kopuru maximoa lortu da. Administratzailearekin harremanetan jarri. resetEmailOtp.auth.error.maxReceiverReuse=Helbide elektroniko berdinarekin erabiltzaile kopuru maximoa lortu da. Administratzailearekin harremanetan jarri. -resetMobileOtp.auth.error.invalidCountry=Telefono-zenbakiaren herrialde-kodea ez da zuzena. Jarri harremanetan administratzailearekin. \ No newline at end of file +resetMobileOtp.auth.error.invalidCountry=Telefono-zenbakiaren herrialde-kodea ez da zuzena. Jarri harremanetan administratzailearekin. +# --- Multi-Attribute Password Form field labels --- +dateOfBirth=Jaiotze-data +nationalId=NAN zenbakia diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_gl.properties b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_gl.properties index 91673d262ee..0963b7d41f6 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_gl.properties +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_gl.properties @@ -130,3 +130,7 @@ resetMobileOtp.auth.error.codeInvalid=Código introducido inválido, por favor, resetMobileOtp.auth.error.maxReceiverReuse=Alcanzouse o número máximo de usuarios co mesmo número de teléfono. Contacta co administrador. resetEmailOtp.auth.error.maxReceiverReuse=Alcanzouse o número máximo de usuarios coa mesma dirección de correo electrónico. Contacta co administrador. resetMobileOtp.auth.error.invalidCountry=Código de país inválido para o número de teléfono. Contacta co administrador. + +# --- Multi-Attribute Password Form field labels --- +dateOfBirth=Data de nacemento +nationalId=Documento de identidade diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_tl.properties b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_tl.properties index eab25df26a9..544d687e69e 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_tl.properties +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme-resources/messages/messages_tl.properties @@ -107,3 +107,7 @@ resetMobileOtp.auth.error.codeInvalid=Hindi wasto ang code na ipinasok, pakipaso resetMobileOtp.auth.error.maxReceiverReuse=Naabot na ang maximum na bilang ng mga user na may parehong numero ng telepono. Makipag-ugnayan sa administrator. resetEmailOtp.auth.error.maxReceiverReuse=Naabot na ang maximum na bilang ng mga user na may parehong email address. Makipag-ugnayan sa administrator. resetMobileOtp.auth.error.invalidCountry=Di-wasto ang country code para sa numero ng telepono. Makipag-ugnayan sa administrator. + +# --- Multi-Attribute Password Form field labels --- +dateOfBirth=Petsa ng kapanganakan +nationalId=National ID diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticatorTest.java b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticatorTest.java new file mode 100644 index 00000000000..9f29e6e12ac --- /dev/null +++ b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticatorTest.java @@ -0,0 +1,359 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +package sequent.keycloak.authenticator.forgot_password; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.keycloak.credential.CredentialInput; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.SubjectCredentialManager; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserProvider; +import org.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.representations.userprofile.config.UPConfig; +import org.keycloak.userprofile.UserProfileProvider; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MultiAttributePasswordAuthenticatorTest { + + private MultiAttributePasswordAuthenticator authenticator; + + @Mock private KeycloakSession session; + @Mock private RealmModel realm; + @Mock private UserProvider userProvider; + + @BeforeEach + void setUp() { + authenticator = new MultiAttributePasswordAuthenticator(); + lenient().when(session.users()).thenReturn(userProvider); + } + + private UserModel mockUser(String id, String password, boolean enabled) { + UserModel user = mock(UserModel.class); + lenient().when(user.getId()).thenReturn(id); + lenient().when(user.isEnabled()).thenReturn(enabled); + SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class); + lenient().when(user.credentialManager()).thenReturn(credentialManager); + lenient() + .when(credentialManager.isValid(any(CredentialInput.class))) + .thenAnswer( + invocation -> { + CredentialInput input = invocation.getArgument(0); + return input instanceof UserCredentialModel + && password.equals(((UserCredentialModel) input).getValue()); + }); + return user; + } + + private Map valuesOf(String... keyValuePairs) { + Map values = new HashMap<>(); + for (int i = 0; i < keyValuePairs.length; i += 2) { + values.put(keyValuePairs[i], keyValuePairs[i + 1]); + } + return values; + } + + // ── Single attribute, unique candidate ────────────────────────────────── + + @Test + void singleAttribute_uniqueMatch_correctPassword_succeeds() { + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserByUserAttributeStream(realm, "nationalId", "X123")) + .thenReturn(Stream.of(user)); + + Optional result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), "correct-horse"); + + assertTrue(result.isPresent()); + assertEquals(user, result.get()); + } + + @Test + void singleAttribute_uniqueMatch_wrongPassword_fails() { + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserByUserAttributeStream(realm, "nationalId", "X123")) + .thenReturn(Stream.of(user)); + + Optional result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), "wrong"); + + assertTrue(result.isEmpty()); + } + + @Test + void singleAttribute_disabledUser_fails() { + UserModel user = mockUser("user-1", "correct-horse", false); + when(userProvider.searchForUserByUserAttributeStream(realm, "nationalId", "X123")) + .thenReturn(Stream.of(user)); + + Optional result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), "correct-horse"); + + assertTrue(result.isEmpty()); + } + + // ── Multiple attributes intersect to one candidate ────────────────────── + + @Test + void twoAttributes_intersectionNarrowsToOne_succeeds() { + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + + when(userProvider.searchForUserByUserAttributeStream(realm, "dateOfBirth", "19900101")) + .thenReturn(Stream.of(alice, bob)); + when(userProvider.searchForUserByUserAttributeStream(realm, "nationalId", "ALICE-ID")) + .thenReturn(Stream.of(alice)); + + Optional result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth", "nationalId"), + valuesOf("dateOfBirth", "19900101", "nationalId", "ALICE-ID"), + "alice-pw"); + + assertTrue(result.isPresent()); + assertEquals(alice, result.get()); + } + + // ── DOB-not-unique-alone case: multiple candidates, password disambiguates ── + + @Test + void singleAttribute_multipleCandidates_passwordUniquelyMatchesOne_succeeds() { + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + when(userProvider.searchForUserByUserAttributeStream(realm, "dateOfBirth", "19900101")) + .thenReturn(Stream.of(alice, bob)); + + Optional result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth"), + valuesOf("dateOfBirth", "19900101"), + "alice-pw"); + + assertTrue(result.isPresent()); + assertEquals(alice, result.get()); + } + + @Test + void singleAttribute_multipleCandidates_passwordMatchesNone_fails() { + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + when(userProvider.searchForUserByUserAttributeStream(realm, "dateOfBirth", "19900101")) + .thenReturn(Stream.of(alice, bob)); + + Optional result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("dateOfBirth"), valuesOf("dateOfBirth", "19900101"), "wrong"); + + assertTrue(result.isEmpty()); + } + + @Test + void singleAttribute_multipleCandidates_passwordMatchesMoreThanOne_fails() { + UserModel alice = mockUser("alice", "shared-pw", true); + UserModel bob = mockUser("bob", "shared-pw", true); + when(userProvider.searchForUserByUserAttributeStream(realm, "dateOfBirth", "19900101")) + .thenReturn(Stream.of(alice, bob)); + + Optional result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth"), + valuesOf("dateOfBirth", "19900101"), + "shared-pw"); + + assertTrue(result.isEmpty()); + } + + // ── Zero candidates ────────────────────────────────────────────────────── + + @Test + void noCandidates_fails() { + when(userProvider.searchForUserByUserAttributeStream(realm, "nationalId", "unknown")) + .thenReturn(Stream.empty()); + + Optional result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "unknown"), "pw"); + + assertTrue(result.isEmpty()); + } + + @Test + void twoAttributes_disjointCandidateSets_fails() { + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + when(userProvider.searchForUserByUserAttributeStream(realm, "dateOfBirth", "19900101")) + .thenReturn(Stream.of(alice)); + when(userProvider.searchForUserByUserAttributeStream(realm, "nationalId", "BOB-ID")) + .thenReturn(Stream.of(bob)); + + Optional result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth", "nationalId"), + valuesOf("dateOfBirth", "19900101", "nationalId", "BOB-ID"), + "alice-pw"); + + assertTrue(result.isEmpty()); + } + + // ── Missing / blank submitted values ──────────────────────────────────── + + @Test + void blankSubmittedValue_failsWithoutLookup() { + Optional result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", " "), "pw"); + + assertTrue(result.isEmpty()); + } + + @Test + void missingSubmittedValue_failsWithoutLookup() { + Optional result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), Map.of(), "pw"); + + assertTrue(result.isEmpty()); + } + + @Test + void blankPassword_failsWithoutLookup() { + Optional result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), " "); + + assertTrue(result.isEmpty()); + } + + @Test + void noMatchAttributesConfigured_fails() { + Optional result = + authenticator.resolveAuthenticatedUser(session, realm, List.of(), Map.of(), "pw"); + + assertTrue(result.isEmpty()); + } + + // ── username / email special-casing ───────────────────────────────────── + + @Test + void usernameAttribute_usesGetUserByUsername() { + UserModel user = mockUser("user-1", "pw", true); + when(userProvider.getUserByUsername(realm, "voter1")).thenReturn(user); + + Optional result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("username"), valuesOf("username", "voter1"), "pw"); + + assertTrue(result.isPresent()); + assertEquals(user, result.get()); + } + + @Test + void emailAttribute_usesGetUserByEmail() { + UserModel user = mockUser("user-1", "pw", true); + when(userProvider.getUserByEmail(realm, "voter1@example.com")).thenReturn(user); + + Optional result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("email"), valuesOf("email", "voter1@example.com"), "pw"); + + assertTrue(result.isPresent()); + assertEquals(user, result.get()); + } + + // ── Rendering: HTML5 input type resolved from the realm's User Profile ── + + private void mockUserProfileAttributes(UPAttribute... attributes) { + UserProfileProvider userProfileProvider = mock(UserProfileProvider.class); + UPConfig upConfig = new UPConfig(); + for (UPAttribute attribute : attributes) { + upConfig.addOrReplaceAttribute(attribute); + } + lenient().when(session.getProvider(UserProfileProvider.class)).thenReturn(userProfileProvider); + lenient().when(userProfileProvider.getConfiguration()).thenReturn(upConfig); + } + + @Test + void buildAttributeFields_html5DateAnnotation_resolvesToDateInputType() { + mockUserProfileAttributes(new UPAttribute("dateOfBirth", Map.of("inputType", "html5-date"))); + + List> fields = + authenticator.buildAttributeFields(session, List.of("dateOfBirth")); + + assertEquals(List.of(Map.of("name", "dateOfBirth", "type", "date")), fields); + } + + @Test + void buildAttributeFields_nonHtml5InputType_fallsBackToText() { + mockUserProfileAttributes(new UPAttribute("country", Map.of("inputType", "select"))); + + List> fields = + authenticator.buildAttributeFields(session, List.of("country")); + + assertEquals(List.of(Map.of("name", "country", "type", "text")), fields); + } + + @Test + void buildAttributeFields_noUserProfileEntry_fallsBackToText() { + mockUserProfileAttributes(); + + List> fields = + authenticator.buildAttributeFields(session, List.of("nationalId")); + + assertEquals(List.of(Map.of("name", "nationalId", "type", "text")), fields); + } + + // ── Factory metadata ──────────────────────────────────────────────────── + + @Test + void factory_providerId() { + MultiAttributePasswordAuthenticator factory = new MultiAttributePasswordAuthenticator(); + assertEquals("multi-attribute-password-form", factory.getId()); + } + + @Test + void factory_configPropertiesIncludeMatchAttributes() { + MultiAttributePasswordAuthenticator factory = new MultiAttributePasswordAuthenticator(); + boolean hasMatchAttributes = + factory.getConfigProperties().stream() + .anyMatch(prop -> Utils.MATCH_ATTRIBUTES.equals(prop.getName())); + assertTrue(hasMatchAttributes); + } + + @Test + void factory_isConfigurable() { + MultiAttributePasswordAuthenticator factory = new MultiAttributePasswordAuthenticator(); + assertTrue(factory.isConfigurable()); + assertFalse(factory.isUserSetupAllowed()); + } +} diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl index 02ab3cc3f1b..14922c15596 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl @@ -14,20 +14,39 @@ SPDX-License-Identifier: AGPL-3.0-only <#if realm.password>

<#if !usernameHidden??> -
- + <#if matchAttributes?? && matchAttributes?has_content> + <#list matchAttributes as field> +
+ - + autofocus autocomplete="off" + aria-invalid="<#if messagesPerField.existsError('username','password')>true" + /> +
+ <#if messagesPerField.existsError('username','password')> ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} + <#else> +
+ -
+ + + <#if messagesPerField.existsError('username','password')> + + ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} + + + +
+
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..b3ee2f8ad1c 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 @@ -14,20 +14,39 @@ SPDX-License-Identifier: AGPL-3.0-only <#if realm.password> <#if !usernameHidden??> -
- + <#if matchAttributes?? && matchAttributes?has_content> + <#list matchAttributes as field> +
+ - + autofocus autocomplete="off" + aria-invalid="<#if messagesPerField.existsError('username','password')>true" + /> +
+ <#if messagesPerField.existsError('username','password')> ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} + <#else> +
+ -
+ + + <#if messagesPerField.existsError('username','password')> + + ${kcSanitize(messagesPerField.getFirstError('username','password'))?no_esc} + + + +
+
From 44bf877c8d8fedbac087ff82836f8296c06e3d48 Mon Sep 17 00:00:00 2001 From: xalsina-sequent Date: Tue, 21 Jul 2026 11:16:54 +0000 Subject: [PATCH 02/13] Some fixes --- .../MultiAttributePasswordAuthenticator.java | 23 +++++-- .../authenticator/forgot_password/Utils.java | 30 +++++--- ...ltiAttributePasswordAuthenticatorTest.java | 68 ++++++++++++++++++- .../sequent.admin-portal/login/login.ftl | 2 +- .../sequent.voting-portal/login/login.ftl | 2 +- 5 files changed, 107 insertions(+), 18 deletions(-) diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java index 5cb84377f8c..a5f6c4e470e 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java @@ -32,6 +32,7 @@ import org.keycloak.models.UserModel; import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.provider.ProviderConfigProperty; +import org.keycloak.representations.userprofile.config.UPAttribute; import org.keycloak.services.messages.Messages; /** @@ -172,11 +173,19 @@ protected Optional resolveAuthenticatedUser( return Optional.of(passwordMatches.get(0)); } + /** + * Resolves candidates for one configured attribute. {@code username} is always unique in + * Keycloak, so a single lookup is safe there - but {@code email} is only unique when the realm + * has {@code duplicateEmailsAllowed} disabled, so it uses the exact-match search API (rather than + * {@code getUserByEmail}, which returns only one arbitrary match) to keep every candidate in play + * for the password-disambiguation step in {@link #resolveAuthenticatedUser}. + */ protected Stream findUsersByAttribute( KeycloakSession session, RealmModel realm, String attribute, String value) { if ("email".equalsIgnoreCase(attribute)) { - UserModel user = session.users().getUserByEmail(realm, value); - return user == null ? Stream.empty() : Stream.of(user); + return session + .users() + .searchForUserStream(realm, Map.of(UserModel.EMAIL, value, UserModel.EXACT, "true")); } if ("username".equalsIgnoreCase(attribute)) { UserModel user = session.users().getUserByUsername(realm, value); @@ -216,14 +225,20 @@ protected Response challenge( * from. {@code type} mirrors whatever HTML5 input type (e.g. {@code date}) the realm's User * Profile configuration declares for that attribute, so a field like {@code dateOfBirth} renders * the same native date picker here as it does at registration - see {@link - * Utils#resolveHtml5InputType}. + * Utils#resolveHtml5InputType}. Fetches the User Profile attribute list once up front rather than + * once per configured attribute. */ protected List> buildAttributeFields( KeycloakSession session, List matchAttributes) { + List profileAttributes = Utils.getRealmUserProfileAttributes(session); List> fields = new ArrayList<>(); for (String attribute : matchAttributes) { fields.add( - Map.of("name", attribute, "type", Utils.resolveHtml5InputType(session, attribute))); + Map.of( + "name", + attribute, + "type", + Utils.resolveHtml5InputType(profileAttributes, attribute))); } return fields; } diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/Utils.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/Utils.java index 2b3ba901042..e11a82dcae3 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/Utils.java +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/Utils.java @@ -177,19 +177,29 @@ String getPasswordExpirationUserAttribute(AuthenticatorConfigModel config) { } /** - * Looks up the HTML5 input type ({@code date}, {@code email}, ...) that the realm's User Profile - * configuration declares for {@code attributeName} via its {@code inputType} annotation (e.g. - * {@code html5-date}), the same source registration/profile forms (user-profile-commons.ftl) - * already render from. Falls back to {@code text} when the attribute has no User Profile entry, - * or its declared input type isn't an {@code html5-*} one (e.g. {@code select}, {@code textarea} - * - not meaningful for a single login lookup field). + * Fetches the realm's User Profile attribute declarations, tolerating a missing {@link + * UserProfileProvider}, a missing configuration, or a missing attribute list - any of which + * yields an empty list rather than throwing, so callers never need their own null checks. */ - public String resolveHtml5InputType(KeycloakSession session, String attributeName) { + public List getRealmUserProfileAttributes(KeycloakSession session) { UserProfileProvider userProfileProvider = session.getProvider(UserProfileProvider.class); - List attributes = userProfileProvider.getConfiguration().getAttributes(); - if (attributes == null) { - return "text"; + if (userProfileProvider == null || userProfileProvider.getConfiguration() == null) { + return Collections.emptyList(); } + List attributes = userProfileProvider.getConfiguration().getAttributes(); + return attributes == null ? Collections.emptyList() : attributes; + } + + /** + * Looks up the HTML5 input type ({@code date}, {@code email}, ...) that {@code attributeName} + * declares via its {@code inputType} annotation (e.g. {@code html5-date}) among the given User + * Profile attributes - the same source registration/profile forms (user-profile-commons.ftl) + * already render from; see {@link #getRealmUserProfileAttributes}. Falls back to {@code text} + * when the attribute has no User Profile entry, or its declared input type isn't an {@code + * html5-*} one (e.g. {@code select}, {@code textarea} - not meaningful for a single login lookup + * field). + */ + public String resolveHtml5InputType(List attributes, String attributeName) { Object inputType = attributes.stream() .filter(attribute -> attributeName.equals(attribute.getName())) diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticatorTest.java b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticatorTest.java index 9f29e6e12ac..a33458330e1 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticatorTest.java +++ b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticatorTest.java @@ -279,9 +279,11 @@ void usernameAttribute_usesGetUserByUsername() { } @Test - void emailAttribute_usesGetUserByEmail() { + void emailAttribute_usesExactSearchForUserStream() { UserModel user = mockUser("user-1", "pw", true); - when(userProvider.getUserByEmail(realm, "voter1@example.com")).thenReturn(user); + when(userProvider.searchForUserStream( + realm, Map.of(UserModel.EMAIL, "voter1@example.com", UserModel.EXACT, "true"))) + .thenReturn(Stream.of(user)); Optional result = authenticator.resolveAuthenticatedUser( @@ -291,6 +293,25 @@ void emailAttribute_usesGetUserByEmail() { assertEquals(user, result.get()); } + @Test + void emailAttribute_duplicateEmailsAllowed_multipleCandidates_passwordDisambiguates() { + // When a realm allows duplicate emails, more than one user can share the same email. + // getUserByEmail() would silently pick just one of them; searchForUserStream() must return + // every candidate so the password check can still disambiguate. + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + when(userProvider.searchForUserStream( + realm, Map.of(UserModel.EMAIL, "shared@example.com", UserModel.EXACT, "true"))) + .thenReturn(Stream.of(alice, bob)); + + Optional result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("email"), valuesOf("email", "shared@example.com"), "bob-pw"); + + assertTrue(result.isPresent()); + assertEquals(bob, result.get()); + } + // ── Rendering: HTML5 input type resolved from the realm's User Profile ── private void mockUserProfileAttributes(UPAttribute... attributes) { @@ -333,6 +354,49 @@ void buildAttributeFields_noUserProfileEntry_fallsBackToText() { assertEquals(List.of(Map.of("name", "nationalId", "type", "text")), fields); } + @Test + void getRealmUserProfileAttributes_noUserProfileProvider_returnsEmptyList() { + when(session.getProvider(UserProfileProvider.class)).thenReturn(null); + + List attributes = Utils.getRealmUserProfileAttributes(session); + + assertTrue(attributes.isEmpty()); + } + + @Test + void getRealmUserProfileAttributes_noConfiguration_returnsEmptyList() { + UserProfileProvider userProfileProvider = mock(UserProfileProvider.class); + when(session.getProvider(UserProfileProvider.class)).thenReturn(userProfileProvider); + when(userProfileProvider.getConfiguration()).thenReturn(null); + + List attributes = Utils.getRealmUserProfileAttributes(session); + + assertTrue(attributes.isEmpty()); + } + + @Test + void getRealmUserProfileAttributes_noAttributesInConfiguration_returnsEmptyList() { + mockUserProfileAttributes(); + + List attributes = Utils.getRealmUserProfileAttributes(session); + + assertTrue(attributes.isEmpty()); + } + + @Test + void resolveHtml5InputType_html5Prefix_stripsPrefix() { + assertEquals( + "date", + Utils.resolveHtml5InputType( + List.of(new UPAttribute("dateOfBirth", Map.of("inputType", "html5-date"))), + "dateOfBirth")); + } + + @Test + void resolveHtml5InputType_unknownAttribute_fallsBackToText() { + assertEquals("text", Utils.resolveHtml5InputType(List.of(), "nationalId")); + } + // ── Factory metadata ──────────────────────────────────────────────────── @Test diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl index 14922c15596..762f33cdb7e 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- autofocus autocomplete="off" aria-invalid="<#if messagesPerField.existsError('username','password')>true" /> 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 b3ee2f8ad1c..9fe00780db1 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 @@ -19,7 +19,7 @@ SPDX-License-Identifier: AGPL-3.0-only
- autofocus autocomplete="off" aria-invalid="<#if messagesPerField.existsError('username','password')>true" /> From 1c36ef1b187d19f0f77e187b9913c650748fa21c Mon Sep 17 00:00:00 2001 From: xalsina-sequent Date: Tue, 21 Jul 2026 13:06:12 +0000 Subject: [PATCH 03/13] Support for phone number --- .../resources/theme/base/login/intl-tel-input.ftl | 4 ++-- .../theme/sequent.admin-portal/login/login.ftl | 15 +++++++++++---- .../theme/sequent.voting-portal/login/login.ftl | 15 +++++++++++---- 3 files changed, 24 insertions(+), 10 deletions(-) diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme/base/login/intl-tel-input.ftl b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme/base/login/intl-tel-input.ftl index 1281887154f..afc0e5ac6da 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme/base/login/intl-tel-input.ftl +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/resources/theme/base/login/intl-tel-input.ftl @@ -7,7 +7,7 @@ SPDX-License-Identifier: AGPL-3.0-only Reusable partial for rendering a phone input with intl-tel-input in Keycloak forms. Usage: <#include "intl-tel-input.ftl"> and call renderIntlTelInput(id, name, value) --> -<#macro renderIntlTelInput id name value=""> +<#macro renderIntlTelInput id name value="" autofocus=true> and call renderIntlTelInput(id, name, val class="${properties.kcInputClass!} intl-tel-input-field" value="${value}" required - autofocus + <#if autofocus>autofocus /> diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl index 762f33cdb7e..7bdc434330e 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl @@ -15,14 +15,21 @@ SPDX-License-Identifier: AGPL-3.0-only <#if !usernameHidden??> <#if matchAttributes?? && matchAttributes?has_content> + <#if matchAttributes?filter(f -> f.type == "tel")?has_content> + <#include "intl-tel-input.ftl"> + <#list matchAttributes as field>
- autofocus autocomplete="off" - aria-invalid="<#if messagesPerField.existsError('username','password')>true" - /> + <#if field.type == "tel"> + <@renderIntlTelInput id=field.name name=field.name autofocus=(field?index == 0)/> + <#else> + autofocus autocomplete="off" + aria-invalid="<#if messagesPerField.existsError('username','password')>true" + /> +
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 9fe00780db1..cd3324ac701 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 @@ -15,14 +15,21 @@ SPDX-License-Identifier: AGPL-3.0-only <#if !usernameHidden??> <#if matchAttributes?? && matchAttributes?has_content> + <#if matchAttributes?filter(f -> f.type == "tel")?has_content> + <#include "intl-tel-input.ftl"> + <#list matchAttributes as field>
- autofocus autocomplete="off" - aria-invalid="<#if messagesPerField.existsError('username','password')>true" - /> + <#if field.type == "tel"> + <@renderIntlTelInput id=field.name name=field.name autofocus=(field?index == 0)/> + <#else> + autofocus autocomplete="off" + aria-invalid="<#if messagesPerField.existsError('username','password')>true" + /> +
From 43ebab72cdbdbc60968d523d4a99791d32570349 Mon Sep 17 00:00:00 2001 From: xalsina-sequent Date: Wed, 22 Jul 2026 08:46:54 +0000 Subject: [PATCH 04/13] Added IVR Direct Grant Authenticator --- .../dob-pin-direct-grant-authenticator.md | 160 +++++++++++++ .../ivr_config/IvrConfigResourceProvider.java | 68 ++++-- .../IvrConfigResourceProviderTest.java | 87 +++++++ .../message-otp-authenticator/pom.xml | 10 + .../MultiAttributeCredentialResolver.java | 118 ++++++++++ .../MultiAttributePasswordAuthenticator.java | 88 +------ ...ibutePasswordDirectGrantAuthenticator.java | 218 ++++++++++++++++++ ...ePasswordDirectGrantAuthenticatorTest.java | 206 +++++++++++++++++ .../sequent.admin-portal/login/login.ftl | 6 +- .../sequent.voting-portal/login/login.ftl | 6 +- 10 files changed, 865 insertions(+), 102 deletions(-) create mode 100644 docs/docusaurus/docs/07-developers/12-ivr/dob-pin-direct-grant-authenticator.md create mode 100644 packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributeCredentialResolver.java create mode 100644 packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordDirectGrantAuthenticator.java create mode 100644 packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordDirectGrantAuthenticatorTest.java diff --git a/docs/docusaurus/docs/07-developers/12-ivr/dob-pin-direct-grant-authenticator.md b/docs/docusaurus/docs/07-developers/12-ivr/dob-pin-direct-grant-authenticator.md new file mode 100644 index 00000000000..f9c9750071d --- /dev/null +++ b/docs/docusaurus/docs/07-developers/12-ivr/dob-pin-direct-grant-authenticator.md @@ -0,0 +1,160 @@ +--- +id: dob-pin-direct-grant-authenticator +title: DOB + PIN Authentication (Direct Grant) +--- + + + +# DOB + PIN Authentication (Direct Grant) + +## Overview + +By default, IVR voters authenticate with a voter ID (`direct-grant-validate-username`) followed by +a PIN (`direct-grant-validate-password`). Some elections instead want voters to authenticate with +one or more attributes they already know - a date of birth, a national ID - plus a PIN, without a +separate voter ID step. `MultiAttributePasswordDirectGrantAuthenticator` +(provider ID `multi-attribute-password-direct-grant`) is the IVR/Direct Grant counterpart of the +web login's [Multi-Attribute + Password Form](../../02-election_managers/01-tutorials/101-admin_portal_tutorials_multi-attribute-password-login.md) - +both share the same resolution logic (`MultiAttributeCredentialResolver`): every configured +identifying attribute must match the same user, and the PIN then disambiguates among candidates. + +This is a single Direct Grant flow step - it replaces both `direct-grant-validate-username` and +`direct-grant-validate-password` at once. + +--- + +## Current IVR Lambda compatibility + +The Keycloak side (this authenticator and `ivr-config-provider`) supports any number of +`identifier` fields plus one `secret` field, each with an independently configurable `maps_to`. +**As validated against `beyond` at `feat/meta-10554/main` +(`packages/ivr-core/src/execution/phases/auth.rs`), the IVR Lambda does not yet exercise that full +range:** + +- **Only one identifier field is collected.** `AuthState::IdentifierPrompt` picks the *first* + `identifier`-kind step (`.find(...)`) and moves straight to the secret step after collecting it. + A second `identifier` entry in the config is accepted by `/ivr-config` but never prompted for or + collected by the Lambda. Until that loop is added, configure **exactly one** `identifier` field. +- **`maps_to` is not yet honored when submitting to Keycloak.** The token request is hardcoded to + `("username", )` and `("password", )` + (`// TODO: Use maps_to` in `auth.rs`), regardless of what `maps_to` says. Until that's fixed, + `maps_to` must literally be `username` for the identifier field and `password` for the secret + field for a voter to actually authenticate through IVR - anything else (e.g. `maps_to: dob`) + will pass `/ivr-config` validation but silently fail token requests, because the value never + arrives under the parameter name this authenticator expects. +- **There's no automatic prompt for non-standard fields.** `map_auth_prompt()` only has a built-in + spoken prompt for the literal field names `voter_id` and `pin`. Any other `field` value (e.g. + `dob`) falls back to an "external" prompt, whose text an admin must configure separately via the + IVR prompt-override admin interface - there is no automatic `auth_enter_dob`-style default. + +None of this limits what this authenticator can be configured to *express* (it's designed for the +target end state), but until `beyond` closes the gaps above, the only IVR-usable configuration +today is one `identifier` field mapped to `username` plus the `secret` field mapped to `password` - +functionally equivalent to the default voter-ID + PIN flow, just collected through a single +combined step instead of two, and letting the "identifier" be any user attribute (not necessarily +the username) as long as it's submitted as `username`. + +--- + +## How field configuration works + +Unlike the web form, this authenticator has no separate "which attributes to match" setting. +Instead, it derives its resolution inputs directly from the same `field` / `max_digits` / `kind` / +`maps_to` / `prompt_key` properties the [`ivr-config-provider`](./keycloak-config.md) module reads +to describe DTMF collection steps to the IVR Lambda - one shared source of truth, so the two can +never drift out of sync: + +- Every entry whose `kind` is `identifier` is a Keycloak user attribute to match. Its `maps_to` + value is **both** the attribute name **and** the `grant_type=password` POST parameter name the + Lambda submits it under. +- Exactly one entry's `kind` must be `secret` - the PIN. Its `maps_to` value should normally be + `password` (the standard OAuth2 ROPC parameter name). +- All four required properties (`field`, `max_digits`, `kind`, `maps_to`) must have the same + `##`-separated value count; `prompt_key` is optional but must also match that count if present. + +**Example (target design)** - DOB + national ID + PIN, no voter ID. Requires the `beyond` fixes +described above; not usable end-to-end today: + +| Property | Value | +|---|---| +| `field` | `dob##nationalId##pin` | +| `max_digits` | `8##10##8` | +| `kind` | `identifier##identifier##secret` | +| `maps_to` | `dob##nationalId##password` | +| `prompt_key` | `auth_enter_dob##auth_enter_national_id##auth_enter_pin` (optional; each prompt's text must still be configured via the IVR prompt-override admin interface) | + +**Example (works today)** - a single identifying attribute, submitted the way the current Lambda +expects: + +| Property | Value | +|---|---| +| `field` | `dob` | +| `max_digits` | `8` | +| `kind` | `identifier` | +| `maps_to` | `username` | + +(then a second entry for the PIN: `field=pin`, `max_digits=8`, `kind=secret`, +`maps_to=password` - i.e. `field=dob##pin`, `max_digits=8##8`, `kind=identifier##secret`, +`maps_to=username##password`) + +Either way, the authenticator resolves the voter by intersecting candidates matching every +`identifier` field's submitted value, then checking the submitted secret (PIN) against that +candidate set. + +--- + +## Step 1 - Create the Direct Grant Flow + +1. In the Keycloak Admin Console, navigate to **Authentication** → **Flows**. +2. Duplicate an existing Direct Grant flow (or create a new one), e.g. + `ivr direct grant - dob pin`. +3. Remove (or set to **DISABLED**) the default `Username Validation` and `Password Validation` + steps. +4. Click **Add step**, search for **Multi-Attribute + Password (Direct Grant)**, and add it with + requirement **REQUIRED**. + +## Step 2 - Configure the Fields + +1. Click **⚙ Config** next to the new step. +2. Fill in `field`, `max_digits`, `kind`, `maps_to` (and optionally `prompt_key`). Use the + "works today" example above (`maps_to=username##password`) unless the deployment's `beyond` + version has already picked up the fixes described in + [Current IVR Lambda compatibility](#current-ivr-lambda-compatibility). +3. Click **Save**. + +## Step 3 - Bind the Flow to the `ivr-voting` Client + +1. Navigate to **Clients** → `ivr-voting` → **Advanced** → **Authentication flow overrides**. +2. Set **Direct Grant Flow** to the flow created in Step 1. +3. Click **Save**. + +`IvrConfigResourceProvider` (see [Keycloak Configuration](./keycloak-config.md)) automatically +picks up this override when building `/ivr-config` for the `ivr-voting` client - no separate +configuration needed there. + +--- + +## Behavior Summary + +| Scenario | Authenticator action | +|---|---| +| `maps_to`/`kind` missing, or their `##`-separated counts don't match | Direct Grant `invalid_grant` error (misconfiguration, fails closed). | +| No `kind=secret` entry, or no `kind=identifier` entries | Direct Grant `invalid_grant` error (misconfiguration, fails closed). | +| No user matches all `identifier` attributes | Direct Grant `invalid_grant` error. | +| Exactly one candidate, correct PIN | Authenticates as that user. | +| Exactly one candidate, wrong PIN | Direct Grant `invalid_grant` error. | +| Multiple candidates share the identifying attribute(s), PIN matches exactly one | Authenticates as that user. | +| Multiple candidates match the PIN (or none do) | Direct Grant `invalid_grant` error. | + +The error is the same generic `invalid_grant` regardless of cause, matching the web form's +generic-error behavior - a failed attempt never reveals which attribute, or the PIN, was wrong. + +> **Note on brute-force protection**, same caveat as the web form: Keycloak's per-account +> brute-force lockout needs a resolved user before it can engage. When more than one candidate +> matches the identifying attributes, there is no single user to attribute a failed attempt to +> until the PIN check resolves. Configuring more identifying attributes narrows the candidate set +> before that point and is the main mitigation; keep **Brute Force Detection** enabled at the +> realm level regardless. diff --git a/packages/keycloak-extensions/ivr-config-provider/src/main/java/sequent/keycloak/ivr_config/IvrConfigResourceProvider.java b/packages/keycloak-extensions/ivr-config-provider/src/main/java/sequent/keycloak/ivr_config/IvrConfigResourceProvider.java index ef5c38440cc..6737644e101 100644 --- a/packages/keycloak-extensions/ivr-config-provider/src/main/java/sequent/keycloak/ivr_config/IvrConfigResourceProvider.java +++ b/packages/keycloak-extensions/ivr-config-provider/src/main/java/sequent/keycloak/ivr_config/IvrConfigResourceProvider.java @@ -11,6 +11,7 @@ import jakarta.ws.rs.core.MediaType; import jakarta.ws.rs.core.Response; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; import java.util.Map; import java.util.Set; @@ -93,7 +94,7 @@ public Response getIvrConfig() { .filter(e -> !e.isAuthenticatorFlow()) // skip sub-flow references .filter(IvrConfigResourceProvider::isRequiredOrConditional) .filter(e -> !SKIPPED_AUTHENTICATORS.contains(e.getAuthenticator())) - .forEachOrdered(e -> steps.add(buildStep(realm, e))); + .forEachOrdered(e -> steps.addAll(buildSteps(realm, e))); // If there are no auth steps "left" configured, it is very likely that was a configuration // issue. @@ -153,11 +154,20 @@ private static boolean isRequiredOrConditional(AuthenticationExecutionModel exec || r == AuthenticationExecutionModel.Requirement.CONDITIONAL; } - private static AuthStep buildStep(RealmModel realm, AuthenticationExecutionModel exec) { + /** + * Values for {@code field}/{@code max_digits}/{@code kind}/{@code maps_to}/{@code prompt_key} may + * be {@value #MULTIVALUE_SEPARATOR}-separated to fan one execution out into several ordered + * {@link AuthStep}s (e.g. a single multi-attribute authenticator collecting both a date of birth + * and a PIN). All multivalued properties present must split into the same count; {@code + * prompt_key} is the only one allowed to be entirely absent. + */ + private static final String MULTIVALUE_SEPARATOR = "##"; + + private static List buildSteps(RealmModel realm, AuthenticationExecutionModel exec) { String authenticatorId = exec.getAuthenticator(); AuthStep stock = STOCK_AUTHENTICATORS.get(authenticatorId); if (stock != null) { - return stock; + return List.of(stock); } String configId = exec.getAuthenticatorConfig(); @@ -176,10 +186,10 @@ private static AuthStep buildStep(RealmModel realm, AuthenticationExecutionModel throw new WebApplicationException( msg.formatted(authenticatorId), Response.Status.INTERNAL_SERVER_ERROR); } - String fieldName = c.get(Constants.AUTH_STEP_PROP_FIELD); - String mapsTo = c.get(Constants.AUTH_STEP_PROP_MAPS_TO); - String kind = c.get(Constants.AUTH_STEP_PROP_KIND); - if (fieldName == null || mapsTo == null || kind == null) { + String fieldRaw = c.get(Constants.AUTH_STEP_PROP_FIELD); + String mapsToRaw = c.get(Constants.AUTH_STEP_PROP_MAPS_TO); + String kindRaw = c.get(Constants.AUTH_STEP_PROP_KIND); + if (fieldRaw == null || mapsToRaw == null || kindRaw == null) { String msg = "AuthenticatorConfig for '%s' is missing required IVR keys (%s, %s, %s)"; throw new WebApplicationException( msg.formatted( @@ -190,17 +200,45 @@ private static AuthStep buildStep(RealmModel realm, AuthenticationExecutionModel Response.Status.INTERNAL_SERVER_ERROR); } - int maxDigits; - try { - maxDigits = Integer.parseInt(c.getOrDefault(Constants.AUTH_STEP_PROP_MAX_DIGITS, "10")); - } catch (NumberFormatException e) { - String msg = "AuthenticatorConfig for '%s' has non-numeric max_digits"; + List fields = splitMultivalue(fieldRaw); + List mapsTos = splitMultivalue(mapsToRaw); + List kinds = splitMultivalue(kindRaw); + List maxDigitsRaw = + splitMultivalue(c.getOrDefault(Constants.AUTH_STEP_PROP_MAX_DIGITS, "10")); + String promptKeyRaw = c.get(Constants.AUTH_STEP_PROP_PROMPT_KEY); // optional, may be null + List promptKeys = promptKeyRaw == null ? null : splitMultivalue(promptKeyRaw); + + int count = fields.size(); + if (mapsTos.size() != count + || kinds.size() != count + || maxDigitsRaw.size() != count + || (promptKeys != null && promptKeys.size() != count)) { + String msg = + "AuthenticatorConfig for '%s' has mismatched %s-separated value counts across field/" + + "max_digits/kind/maps_to/prompt_key"; throw new WebApplicationException( - msg.formatted(authenticatorId), Response.Status.INTERNAL_SERVER_ERROR); + msg.formatted(authenticatorId, MULTIVALUE_SEPARATOR), + Response.Status.INTERNAL_SERVER_ERROR); } - String promptKey = c.get(Constants.AUTH_STEP_PROP_PROMPT_KEY); // optional, may be null - return new AuthStep(fieldName, maxDigits, kind, mapsTo, promptKey); + List steps = new ArrayList<>(); + for (int i = 0; i < count; i++) { + int maxDigits; + try { + maxDigits = Integer.parseInt(maxDigitsRaw.get(i)); + } catch (NumberFormatException e) { + String msg = "AuthenticatorConfig for '%s' has non-numeric max_digits"; + throw new WebApplicationException( + msg.formatted(authenticatorId), Response.Status.INTERNAL_SERVER_ERROR); + } + String promptKey = promptKeys == null ? null : promptKeys.get(i); + steps.add(new AuthStep(fields.get(i), maxDigits, kinds.get(i), mapsTos.get(i), promptKey)); + } + return steps; + } + + private static List splitMultivalue(String raw) { + return Arrays.asList(raw.split(MULTIVALUE_SEPARATOR)); } @Override diff --git a/packages/keycloak-extensions/ivr-config-provider/src/test/java/sequent/keycloak/ivr_config/IvrConfigResourceProviderTest.java b/packages/keycloak-extensions/ivr-config-provider/src/test/java/sequent/keycloak/ivr_config/IvrConfigResourceProviderTest.java index 1796391d764..59733333baa 100644 --- a/packages/keycloak-extensions/ivr-config-provider/src/test/java/sequent/keycloak/ivr_config/IvrConfigResourceProviderTest.java +++ b/packages/keycloak-extensions/ivr-config-provider/src/test/java/sequent/keycloak/ivr_config/IvrConfigResourceProviderTest.java @@ -137,6 +137,93 @@ void customAuthenticatorReadsConfigKeys() { new AuthStep("dob", 8, Constants.AUTH_STEP_KIND_SECRET, "dob", "auth_enter_dob")); } + @Test + void customAuthenticatorMultivaluedConfig_fansOutMultipleSteps() { + AuthenticationExecutionModel custom = + exec( + "multi-attribute-password-direct-grant", + AuthenticationExecutionModel.Requirement.REQUIRED, + "cfg-multi"); + stubExecutions(custom); + AuthenticatorConfigModel cfg = mock(AuthenticatorConfigModel.class); + when(cfg.getConfig()) + .thenReturn( + Map.of( + Constants.AUTH_STEP_PROP_FIELD, "dob##pin", + Constants.AUTH_STEP_PROP_MAX_DIGITS, "8##8", + Constants.AUTH_STEP_PROP_KIND, + Constants.AUTH_STEP_KIND_IDENTIFIER + "##" + Constants.AUTH_STEP_KIND_SECRET, + Constants.AUTH_STEP_PROP_MAPS_TO, "dob##password", + Constants.AUTH_STEP_PROP_PROMPT_KEY, "auth_enter_dob##auth_enter_pin")); + when(realm.getAuthenticatorConfigById("cfg-multi")).thenReturn(cfg); + + IvrConfigResourceProvider provider = providerWithValidToken(); + @SuppressWarnings("unchecked") + List steps = + (List) + ((Map) provider.getIvrConfig().getEntity()).get(Constants.IVR_CONFIG_FIELD_STEPS); + + assertThat(steps) + .containsExactly( + new AuthStep("dob", 8, Constants.AUTH_STEP_KIND_IDENTIFIER, "dob", "auth_enter_dob"), + new AuthStep("pin", 8, Constants.AUTH_STEP_KIND_SECRET, "password", "auth_enter_pin")); + } + + @Test + void customAuthenticatorMultivaluedConfig_noPromptKey_stepsHaveNullPromptKey() { + AuthenticationExecutionModel custom = + exec( + "multi-attribute-password-direct-grant", + AuthenticationExecutionModel.Requirement.REQUIRED, + "cfg-multi"); + stubExecutions(custom); + AuthenticatorConfigModel cfg = mock(AuthenticatorConfigModel.class); + when(cfg.getConfig()) + .thenReturn( + Map.of( + Constants.AUTH_STEP_PROP_FIELD, "dob##pin", + Constants.AUTH_STEP_PROP_MAX_DIGITS, "8##8", + Constants.AUTH_STEP_PROP_KIND, + Constants.AUTH_STEP_KIND_IDENTIFIER + "##" + Constants.AUTH_STEP_KIND_SECRET, + Constants.AUTH_STEP_PROP_MAPS_TO, "dob##password")); + when(realm.getAuthenticatorConfigById("cfg-multi")).thenReturn(cfg); + + IvrConfigResourceProvider provider = providerWithValidToken(); + @SuppressWarnings("unchecked") + List steps = + (List) + ((Map) provider.getIvrConfig().getEntity()).get(Constants.IVR_CONFIG_FIELD_STEPS); + + assertThat(steps) + .containsExactly( + new AuthStep("dob", 8, Constants.AUTH_STEP_KIND_IDENTIFIER, "dob", null), + new AuthStep("pin", 8, Constants.AUTH_STEP_KIND_SECRET, "password", null)); + } + + @Test + void customAuthenticatorMultivaluedConfig_mismatchedCountsYields500() { + AuthenticationExecutionModel custom = + exec( + "multi-attribute-password-direct-grant", + AuthenticationExecutionModel.Requirement.REQUIRED, + "cfg-mismatch"); + stubExecutions(custom); + AuthenticatorConfigModel cfg = mock(AuthenticatorConfigModel.class); + when(cfg.getConfig()) + .thenReturn( + Map.of( + Constants.AUTH_STEP_PROP_FIELD, "dob##pin", + Constants.AUTH_STEP_PROP_MAX_DIGITS, "8", // only one value for two fields + Constants.AUTH_STEP_PROP_KIND, + Constants.AUTH_STEP_KIND_IDENTIFIER + "##" + Constants.AUTH_STEP_KIND_SECRET, + Constants.AUTH_STEP_PROP_MAPS_TO, "dob##password")); + when(realm.getAuthenticatorConfigById("cfg-mismatch")).thenReturn(cfg); + + IvrConfigResourceProvider provider = providerWithValidToken(); + WebApplicationException e = assertThrows(WebApplicationException.class, provider::getIvrConfig); + assertEquals(500, e.getResponse().getStatus()); + } + @Test void alternativeDisabledAndSubflowExecutionsAreSkipped() { AuthenticationExecutionModel subflow = mock(AuthenticationExecutionModel.class); diff --git a/packages/keycloak-extensions/message-otp-authenticator/pom.xml b/packages/keycloak-extensions/message-otp-authenticator/pom.xml index 88c7c2f87b1..9a4d973af22 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/pom.xml +++ b/packages/keycloak-extensions/message-otp-authenticator/pom.xml @@ -119,6 +119,16 @@ SPDX-License-Identifier: AGPL-3.0-only ${mockito.version} test + + + + org.glassfish.jersey.core + jersey-common + 3.1.6 + test + diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributeCredentialResolver.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributeCredentialResolver.java new file mode 100644 index 00000000000..737cb0476e8 --- /dev/null +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributeCredentialResolver.java @@ -0,0 +1,118 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +package sequent.keycloak.authenticator.forgot_password; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import lombok.extern.jbosslog.JBossLog; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; + +/** + * Resolves a user from one or more configured attributes plus a password, without a username. + * Shared by every transport that needs this: the browser form ({@link + * MultiAttributePasswordAuthenticator}) and the IVR Direct Grant flow + * (MultiAttributePasswordDirectGrantAuthenticator) - same rules, same failure semantics, regardless + * of how the values arrived. + * + *

Resolution: for each configured attribute, find every user whose attribute equals the + * submitted value, then intersect those candidate sets across all attributes. If exactly one + * candidate's password matches the submitted password, that user authenticates. Any other outcome + * (no candidates, no password match, more than one password match) fails generically, so callers + * never need to distinguish "no such attributes" from "wrong password" - that distinction must not + * leak to the end user. + */ +@JBossLog +public final class MultiAttributeCredentialResolver { + + private MultiAttributeCredentialResolver() {} + + public static Optional resolveAuthenticatedUser( + KeycloakSession session, + RealmModel realm, + List matchAttributes, + Map submittedValues, + String password) { + if (matchAttributes == null || matchAttributes.isEmpty()) { + log.warn("resolveAuthenticatedUser(): no matchAttributes configured"); + return Optional.empty(); + } + if (password == null || password.isBlank()) { + return Optional.empty(); + } + + Map candidatesById = null; + for (String attribute : matchAttributes) { + String value = submittedValues.get(attribute); + if (value == null || value.isBlank()) { + return Optional.empty(); + } + value = value.trim(); + + Map matchesForAttribute = + findUsersByAttribute(session, realm, attribute, value) + .collect(Collectors.toMap(UserModel::getId, Function.identity(), (a, b) -> a)); + + if (candidatesById == null) { + candidatesById = matchesForAttribute; + } else { + candidatesById.keySet().retainAll(matchesForAttribute.keySet()); + } + + if (candidatesById.isEmpty()) { + return Optional.empty(); + } + } + + List passwordMatches = + candidatesById.values().stream() + .filter(UserModel::isEnabled) + .filter(candidate -> isPasswordValid(candidate, password)) + .collect(Collectors.toList()); + + if (passwordMatches.size() != 1) { + if (passwordMatches.size() > 1) { + log.warnv( + "resolveAuthenticatedUser(): ambiguous match, {0} candidates matched the submitted" + + " password", + passwordMatches.size()); + } + return Optional.empty(); + } + + return Optional.of(passwordMatches.get(0)); + } + + /** + * Resolves candidates for one configured attribute. {@code username} is always unique in + * Keycloak, so a single lookup is safe there - but {@code email} is only unique when the realm + * has {@code duplicateEmailsAllowed} disabled, so it uses the exact-match search API (rather than + * {@code getUserByEmail}, which returns only one arbitrary match) to keep every candidate in play + * for the password-disambiguation step in {@link #resolveAuthenticatedUser}. + */ + public static Stream findUsersByAttribute( + KeycloakSession session, RealmModel realm, String attribute, String value) { + if ("email".equalsIgnoreCase(attribute)) { + return session + .users() + .searchForUserStream(realm, Map.of(UserModel.EMAIL, value, UserModel.EXACT, "true")); + } + if ("username".equalsIgnoreCase(attribute)) { + UserModel user = session.users().getUserByUsername(realm, value); + return user == null ? Stream.empty() : Stream.of(user); + } + return session.users().searchForUserByUserAttributeStream(realm, attribute, value); + } + + public static boolean isPasswordValid(UserModel user, String password) { + return user.credentialManager().isValid(UserCredentialModel.password(password)); + } +} diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java index a5f6c4e470e..f2aa8974a51 100644 --- a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java @@ -13,9 +13,6 @@ import java.util.List; import java.util.Map; import java.util.Optional; -import java.util.function.Function; -import java.util.stream.Collectors; -import java.util.stream.Stream; import lombok.extern.jbosslog.JBossLog; import org.keycloak.Config; import org.keycloak.authentication.AuthenticationFlowContext; @@ -28,7 +25,6 @@ import org.keycloak.models.KeycloakSession; import org.keycloak.models.KeycloakSessionFactory; import org.keycloak.models.RealmModel; -import org.keycloak.models.UserCredentialModel; import org.keycloak.models.UserModel; import org.keycloak.models.credential.PasswordCredentialModel; import org.keycloak.provider.ProviderConfigProperty; @@ -110,12 +106,9 @@ private void fail(AuthenticationFlowContext context, MultivaluedMapReturns {@link Optional#empty()} for any ambiguous or unmatched outcome (missing - * configuration, blank input, zero candidates, or zero/multiple password matches) so callers - * never need to distinguish "no such attributes" from "wrong password" - that distinction must - * not leak to the end user. + * Resolves the single user matching every configured attribute AND the submitted password. See + * {@link MultiAttributeCredentialResolver} for the resolution rules (shared with the IVR Direct + * Grant authenticator). */ protected Optional resolveAuthenticatedUser( KeycloakSession session, @@ -123,79 +116,8 @@ protected Optional resolveAuthenticatedUser( List matchAttributes, Map submittedValues, String password) { - if (matchAttributes == null || matchAttributes.isEmpty()) { - log.warn("resolveAuthenticatedUser(): no matchAttributes configured"); - return Optional.empty(); - } - if (password == null || password.isBlank()) { - return Optional.empty(); - } - - Map candidatesById = null; - for (String attribute : matchAttributes) { - String value = submittedValues.get(attribute); - if (value == null || value.isBlank()) { - return Optional.empty(); - } - value = value.trim(); - - Map matchesForAttribute = - findUsersByAttribute(session, realm, attribute, value) - .collect(Collectors.toMap(UserModel::getId, Function.identity(), (a, b) -> a)); - - if (candidatesById == null) { - candidatesById = matchesForAttribute; - } else { - candidatesById.keySet().retainAll(matchesForAttribute.keySet()); - } - - if (candidatesById.isEmpty()) { - return Optional.empty(); - } - } - - List passwordMatches = - candidatesById.values().stream() - .filter(UserModel::isEnabled) - .filter(candidate -> isPasswordValid(candidate, password)) - .collect(Collectors.toList()); - - if (passwordMatches.size() != 1) { - if (passwordMatches.size() > 1) { - log.warnv( - "resolveAuthenticatedUser(): ambiguous match, {0} candidates matched the submitted" - + " password", - passwordMatches.size()); - } - return Optional.empty(); - } - - return Optional.of(passwordMatches.get(0)); - } - - /** - * Resolves candidates for one configured attribute. {@code username} is always unique in - * Keycloak, so a single lookup is safe there - but {@code email} is only unique when the realm - * has {@code duplicateEmailsAllowed} disabled, so it uses the exact-match search API (rather than - * {@code getUserByEmail}, which returns only one arbitrary match) to keep every candidate in play - * for the password-disambiguation step in {@link #resolveAuthenticatedUser}. - */ - protected Stream findUsersByAttribute( - KeycloakSession session, RealmModel realm, String attribute, String value) { - if ("email".equalsIgnoreCase(attribute)) { - return session - .users() - .searchForUserStream(realm, Map.of(UserModel.EMAIL, value, UserModel.EXACT, "true")); - } - if ("username".equalsIgnoreCase(attribute)) { - UserModel user = session.users().getUserByUsername(realm, value); - return user == null ? Stream.empty() : Stream.of(user); - } - return session.users().searchForUserByUserAttributeStream(realm, attribute, value); - } - - protected boolean isPasswordValid(UserModel user, String password) { - return user.credentialManager().isValid(UserCredentialModel.password(password)); + return MultiAttributeCredentialResolver.resolveAuthenticatedUser( + session, realm, matchAttributes, submittedValues, password); } protected Response challenge( diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordDirectGrantAuthenticator.java b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordDirectGrantAuthenticator.java new file mode 100644 index 00000000000..37690dcbb86 --- /dev/null +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordDirectGrantAuthenticator.java @@ -0,0 +1,218 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +package sequent.keycloak.authenticator.forgot_password; + +import com.google.auto.service.AutoService; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import lombok.extern.jbosslog.JBossLog; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.authentication.AuthenticationFlowError; +import org.keycloak.authentication.AuthenticatorFactory; +import org.keycloak.authentication.authenticators.directgrant.AbstractDirectGrantAuthenticator; +import org.keycloak.events.Errors; +import org.keycloak.models.AuthenticationExecutionModel.Requirement; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.credential.PasswordCredentialModel; +import org.keycloak.provider.ProviderConfigProperty; + +/** + * IVR-facing Direct Grant analog of {@link MultiAttributePasswordAuthenticator}: resolves a voter + * from one or more configured attributes (e.g. date of birth) plus a PIN submitted together in a + * single {@code grant_type=password} request, without a username. Shares its resolution rules with + * the browser authenticator via {@link MultiAttributeCredentialResolver}. + * + *

Unlike the browser authenticator, there is no {@code matchAttributes} config property here. + * The {@code field}/{@code max_digits}/{@code kind}/{@code maps_to}/{@code prompt_key} properties + * this authenticator declares are read generically by the {@code ivr-config-provider} module's + * {@code IvrConfigResourceProvider} to describe each DTMF collection step to the IVR Lambda - see + * that module for the full contract. Rather than duplicate that same field list in a second, + * parallel config property (risking the two drifting out of sync), this authenticator derives its + * own resolution inputs directly from {@code maps_to}/{@code kind}: every entry whose {@code kind} + * is {@code identifier} is a Keycloak user attribute to match (also the Direct Grant POST parameter + * name its value arrives under); exactly one entry must be {@code secret} - the password/PIN + * parameter name. + */ +@JBossLog +@AutoService(AuthenticatorFactory.class) +public class MultiAttributePasswordDirectGrantAuthenticator + extends AbstractDirectGrantAuthenticator { + public static final String PROVIDER_ID = "multi-attribute-password-direct-grant"; + + /** + * Config keys read by {@code ivr-config-provider}'s {@code Constants} - kept in sync manually + * since the two modules intentionally don't share a compile-time dependency. + */ + public static final String CONFIG_FIELD = "field"; + + public static final String CONFIG_MAX_DIGITS = "max_digits"; + public static final String CONFIG_KIND = "kind"; + public static final String CONFIG_MAPS_TO = "maps_to"; + public static final String CONFIG_PROMPT_KEY = "prompt_key"; + + public static final String KIND_IDENTIFIER = "identifier"; + public static final String KIND_SECRET = "secret"; + + public static final Requirement[] REQUIREMENT_CHOICES = {Requirement.REQUIRED}; + + @Override + public void authenticate(AuthenticationFlowContext context) { + AuthenticatorConfigModel authConfig = context.getAuthenticatorConfig(); + List mapsTos = Utils.getMultivalueString(authConfig, CONFIG_MAPS_TO, List.of()); + List kinds = Utils.getMultivalueString(authConfig, CONFIG_KIND, List.of()); + + if (mapsTos.isEmpty() || mapsTos.size() != kinds.size()) { + log.warn( + "authenticate(): misconfigured or mismatched maps_to/kind, cannot resolve identifying" + + " attributes"); + fail(context); + return; + } + + List matchAttributes = new ArrayList<>(); + String passwordField = null; + for (int i = 0; i < mapsTos.size(); i++) { + if (KIND_SECRET.equalsIgnoreCase(kinds.get(i))) { + passwordField = mapsTos.get(i); + } else { + matchAttributes.add(mapsTos.get(i)); + } + } + if (passwordField == null || matchAttributes.isEmpty()) { + log.warn( + "authenticate(): config must declare exactly one 'secret' kind entry and at least one" + + " 'identifier' entry"); + fail(context); + return; + } + + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + Map submittedValues = new HashMap<>(); + for (String attribute : matchAttributes) { + submittedValues.put(attribute, formData.getFirst(attribute)); + } + String password = formData.getFirst(passwordField); + + Optional user = + MultiAttributeCredentialResolver.resolveAuthenticatedUser( + context.getSession(), context.getRealm(), matchAttributes, submittedValues, password); + + if (user.isPresent()) { + context.setUser(user.get()); + context.success(); + } else { + fail(context); + } + } + + private void fail(AuthenticationFlowContext context) { + context.getEvent().error(Errors.INVALID_USER_CREDENTIALS); + Response challengeResponse = + errorResponse( + Response.Status.BAD_REQUEST.getStatusCode(), + "invalid_grant", + "Invalid user credentials"); + context.failure(AuthenticationFlowError.INVALID_USER, challengeResponse); + } + + @Override + public boolean requiresUser() { + return false; + } + + @Override + public boolean configuredFor(KeycloakSession session, RealmModel realm, UserModel user) { + return true; + } + + @Override + public void setRequiredActions(KeycloakSession session, RealmModel realm, UserModel user) {} + + @Override + public boolean isUserSetupAllowed() { + return false; + } + + @Override + public String getDisplayType() { + return "Multi-Attribute + Password (Direct Grant)"; + } + + @Override + public String getReferenceCategory() { + return PasswordCredentialModel.TYPE; + } + + @Override + public boolean isConfigurable() { + return true; + } + + @Override + public Requirement[] getRequirementChoices() { + return REQUIREMENT_CHOICES; + } + + @Override + public String getHelpText() { + return "Authenticates a caller (e.g. via IVR) by matching one or more configured user" + + " attributes (all must match the same user) plus a password/PIN submitted together in" + + " a single Direct Grant request. Does not require a username."; + } + + @Override + public List getConfigProperties() { + return List.of( + new ProviderConfigProperty( + CONFIG_FIELD, + "IVR field labels", + "One label per collected field, in order (e.g. dob, pin). Shown to the IVR Lambda" + + " only, not used for resolution.", + ProviderConfigProperty.MULTIVALUED_STRING_TYPE, + null), + new ProviderConfigProperty( + CONFIG_MAX_DIGITS, + "Max digits per field", + "Maximum DTMF digits per field, same order as the other field lists (e.g. 8).", + ProviderConfigProperty.MULTIVALUED_STRING_TYPE, + null), + new ProviderConfigProperty( + CONFIG_KIND, + "Kind per field", + "\"identifier\" or \"secret\" per field, same order. Exactly one field must be" + + " \"secret\" (the password/PIN); the rest identify the voter and must jointly" + + " match a single account.", + ProviderConfigProperty.MULTIVALUED_STRING_TYPE, + null), + new ProviderConfigProperty( + CONFIG_MAPS_TO, + "Direct Grant parameter per field", + "The grant_type=password POST parameter name each field's value arrives under, same" + + " order. For \"identifier\" fields this is also the Keycloak user attribute" + + " matched against; the \"secret\" field's value should normally be \"password\".", + ProviderConfigProperty.MULTIVALUED_STRING_TYPE, + null), + new ProviderConfigProperty( + CONFIG_PROMPT_KEY, + "IVR prompt key per field (optional)", + "Overrides the IVR Lambda's default prompt key per field, same order. Leave empty to" + + " use the Lambda's well-known defaults.", + ProviderConfigProperty.MULTIVALUED_STRING_TYPE, + null)); + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} diff --git a/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordDirectGrantAuthenticatorTest.java b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordDirectGrantAuthenticatorTest.java new file mode 100644 index 00000000000..2167f53b1b1 --- /dev/null +++ b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordDirectGrantAuthenticatorTest.java @@ -0,0 +1,206 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +package sequent.keycloak.authenticator.forgot_password; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +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.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.keycloak.authentication.AuthenticationFlowContext; +import org.keycloak.credential.CredentialInput; +import org.keycloak.events.EventBuilder; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.RealmModel; +import org.keycloak.models.SubjectCredentialManager; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.models.UserProvider; +import org.keycloak.provider.ProviderConfigProperty; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MultiAttributePasswordDirectGrantAuthenticatorTest { + + private MultiAttributePasswordDirectGrantAuthenticator authenticator; + + @Mock private AuthenticationFlowContext context; + @Mock private KeycloakSession session; + @Mock private RealmModel realm; + @Mock private UserProvider userProvider; + @Mock private AuthenticatorConfigModel authConfig; + @Mock private HttpRequest httpRequest; + @Mock private EventBuilder event; + + @BeforeEach + void setUp() { + authenticator = new MultiAttributePasswordDirectGrantAuthenticator(); + lenient().when(session.users()).thenReturn(userProvider); + lenient().when(context.getSession()).thenReturn(session); + lenient().when(context.getRealm()).thenReturn(realm); + lenient().when(context.getHttpRequest()).thenReturn(httpRequest); + lenient().when(context.getAuthenticatorConfig()).thenReturn(authConfig); + lenient().when(context.getEvent()).thenReturn(event); + } + + private UserModel mockUser(String id, String password, boolean enabled) { + UserModel user = mock(UserModel.class); + lenient().when(user.getId()).thenReturn(id); + lenient().when(user.isEnabled()).thenReturn(enabled); + SubjectCredentialManager credentialManager = mock(SubjectCredentialManager.class); + lenient().when(user.credentialManager()).thenReturn(credentialManager); + lenient() + .when(credentialManager.isValid(any(CredentialInput.class))) + .thenAnswer( + invocation -> { + CredentialInput input = invocation.getArgument(0); + return input instanceof UserCredentialModel + && password.equals(((UserCredentialModel) input).getValue()); + }); + return user; + } + + private void formParams(String... keyValuePairs) { + MultivaluedMap formData = new MultivaluedHashMap<>(); + for (int i = 0; i < keyValuePairs.length; i += 2) { + formData.add(keyValuePairs[i], keyValuePairs[i + 1]); + } + lenient().when(httpRequest.getDecodedFormParameters()).thenReturn(formData); + } + + private void ivrConfig(String field, String maxDigits, String kind, String mapsTo) { + Map config = new HashMap<>(); + config.put("field", field); + config.put("max_digits", maxDigits); + config.put("kind", kind); + config.put("maps_to", mapsTo); + lenient().when(authConfig.getConfig()).thenReturn(config); + } + + // ── Happy path ─────────────────────────────────────────────────────────── + + @Test + void dobAndPin_uniqueMatch_correctPin_succeeds() { + ivrConfig("dob##pin", "8##8", "identifier##secret", "dob##password"); + formParams("dob", "19900101", "password", "1234"); + UserModel user = mockUser("user-1", "1234", true); + when(userProvider.searchForUserByUserAttributeStream(realm, "dob", "19900101")) + .thenReturn(Stream.of(user)); + + authenticator.authenticate(context); + + verify(context).setUser(user); + verify(context).success(); + verify(context, never()).failure(any()); + } + + @Test + void dobAndPin_multipleCandidates_pinDisambiguates_succeeds() { + ivrConfig("dob##pin", "8##8", "identifier##secret", "dob##password"); + formParams("dob", "19900101", "password", "bob-pin"); + UserModel alice = mockUser("alice", "alice-pin", true); + UserModel bob = mockUser("bob", "bob-pin", true); + when(userProvider.searchForUserByUserAttributeStream(realm, "dob", "19900101")) + .thenReturn(Stream.of(alice, bob)); + + authenticator.authenticate(context); + + verify(context).setUser(bob); + verify(context).success(); + } + + @Test + void dobAndPin_wrongPin_fails() { + ivrConfig("dob##pin", "8##8", "identifier##secret", "dob##password"); + formParams("dob", "19900101", "password", "wrong"); + UserModel user = mockUser("user-1", "1234", true); + when(userProvider.searchForUserByUserAttributeStream(realm, "dob", "19900101")) + .thenReturn(Stream.of(user)); + + authenticator.authenticate(context); + + verify(context, never()).setUser(any()); + verify(context, never()).success(); + verify(context).failure(any(), any()); + } + + @Test + void dobAndPin_noCandidates_fails() { + ivrConfig("dob##pin", "8##8", "identifier##secret", "dob##password"); + formParams("dob", "19900101", "password", "1234"); + when(userProvider.searchForUserByUserAttributeStream(realm, "dob", "19900101")) + .thenReturn(Stream.empty()); + + authenticator.authenticate(context); + + verify(context, never()).setUser(any()); + verify(context).failure(any(), any()); + } + + // ── Misconfiguration ───────────────────────────────────────────────────── + + @Test + void missingSecretKind_fails() { + // Only "identifier" entries, no "secret" - misconfigured, must not silently succeed. + ivrConfig("dob", "8", "identifier", "dob"); + formParams("dob", "19900101"); + + authenticator.authenticate(context); + + verify(context, never()).setUser(any()); + verify(context).failure(any(), any()); + } + + @Test + void mismatchedMapsToAndKindCounts_fails() { + Map config = new HashMap<>(); + config.put("field", "dob##pin"); + config.put("max_digits", "8##8"); + config.put("kind", "identifier"); // only one value for two fields + config.put("maps_to", "dob##password"); + lenient().when(authConfig.getConfig()).thenReturn(config); + formParams("dob", "19900101", "password", "1234"); + + authenticator.authenticate(context); + + verify(context, never()).setUser(any()); + verify(context).failure(any(), any()); + } + + // ── Factory metadata ───────────────────────────────────────────────────── + + @Test + void factory_providerId() { + assertEquals("multi-attribute-password-direct-grant", authenticator.getId()); + } + + @Test + void factory_requiresUserFalse() { + assertTrue(!authenticator.requiresUser()); + } + + @Test + void factory_configPropertiesDeclareIvrKeys() { + List props = authenticator.getConfigProperties(); + List names = props.stream().map(ProviderConfigProperty::getName).toList(); + assertTrue(names.containsAll(List.of("field", "max_digits", "kind", "maps_to", "prompt_key"))); + } +} diff --git a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl index 7bdc434330e..e953b0a5dcc 100644 --- a/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl +++ b/packages/keycloak-extensions/sequent-theme/src/main/resources/theme/sequent.admin-portal/login/login.ftl @@ -13,6 +13,8 @@ SPDX-License-Identifier: AGPL-3.0-only

<#if realm.password> + <#-- Number of fields rendered ahead of the password field --> + <#assign fieldCount = (matchAttributes?? && matchAttributes?has_content)?then(matchAttributes?size, 1)> <#if !usernameHidden??> <#if matchAttributes?? && matchAttributes?has_content> <#if matchAttributes?filter(f -> f.type == "tel")?has_content> @@ -60,12 +62,12 @@ SPDX-License-Identifier: AGPL-3.0-only
-