diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 7b0c1be0565..ff09cfa4e99 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -202,7 +202,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: mvn -B verify sonar:sonar -f packages/keycloak-extensions/pom.xml + run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:5.7.0.6970:sonar -f packages/keycloak-extensions/pom.xml -Dsonar.host.url=https://sonarcloud.io -Dsonar.token=$SONAR_TOKEN -Dsonar.organization=sequentech @@ -216,7 +216,7 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: mvn -B verify sonar:sonar -f packages/keycloak-extensions/pom.xml + run: mvn -B verify org.sonarsource.scanner.maven:sonar-maven-plugin:5.7.0.6970:sonar -f packages/keycloak-extensions/pom.xml -Dsonar.host.url=https://sonarcloud.io -Dsonar.token=$SONAR_TOKEN -Dsonar.organization=sequentech diff --git a/beyond b/beyond index 5874c35895d..903b31d2dfc 160000 --- a/beyond +++ b/beyond @@ -1 +1 @@ -Subproject commit 5874c35895d6df8d9f923762587e397e7560e2a8 +Subproject commit 903b31d2dfc9ed3829f838e1a47bf5348850749e 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..55cdd388fb3 --- /dev/null +++ b/docs/docusaurus/docs/02-election_managers/01-tutorials/101-admin_portal_tutorials_multi-attribute-password-login.md @@ -0,0 +1,192 @@ +--- +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. +- **Date-valued attributes must be stored as `YYYY-MM-DD`** (e.g. `1990-01-05` for January 5, + 1990) - the same format the browser's native date picker always submits, so no reformatting is + needed on the browser path. + +--- + +## 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. Optionally adjust the DoS-mitigation settings below (sensible defaults are pre-filled - see + [Denial-of-Service Considerations](#denial-of-service-considerations)): + - **Max candidates per request** (default `10`) + - **Max failures per attribute-value combination** (default `10`) + - **Failure window (seconds)** (default `60`) + - **Max user-store rows per attribute lookup** (default `5000`) +5. Leave **Multiple-candidate match policy** at its default, `REJECT_AMBIGUOUS`, unless you have + read and understood [Multiple-Candidate Match Policy](#multiple-candidate-match-policy) below - + the alternative, `FIRST_MATCH`, is only safe when password uniqueness across every possible + candidate is guaranteed. +6. 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 - this attempt **is** counted toward that account's Brute Force Detection lockout, same as a standard login. | +| Exactly one candidate, currently locked out by Brute Force Detection | "Account temporarily/permanently disabled" - no password check is even attempted. | +| 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 - see the brute-force note below. | + +The error is always the same generic message regardless of cause, so a failed attempt never +reveals which attribute, or the password, was wrong. Every "no match" outcome above (blank field, +no candidates, wrong password) takes the same time to respond, including a real password-hash +computation on paths that never actually found a candidate to check - so response time doesn't +reveal whether any account has the submitted attribute value. + +> **Note on brute-force protection:** Keycloak's built-in per-account lockout only engages once +> resolution narrows to a single candidate - the same account that ends up locked out is also the +> one whose failed attempts get counted, matching how the standard username/password form behaves. +> When more than one candidate still shares the configured attribute(s), there is no single +> account a failed attempt can honestly be attributed to, so the counter can't engage for that +> specific request (a locked-out account among several ambiguous candidates still can't have its +> password probed, though - it's excluded from consideration before any password is checked). +> Configuring more attributes narrows the candidate set before the password check, making the +> single-candidate (fully protected) case the common one; keep **Brute Force Detection** enabled +> at the realm level regardless. + +--- + +## Denial-of-Service Considerations + +Because this authenticator matches on attributes rather than a unique username, a single request +can legitimately resolve to many candidates (e.g. every voter born on a common date) - and each +candidate normally costs one password-hash comparison to rule out. Three independent settings +bound that cost per request, on top of Keycloak's standard Brute Force Detection: + +- **Max candidates per request** (`maxCandidates`, default `10`): once the configured attributes + match more candidates than this, the request fails generically without checking any of their + passwords. This bounds the worst-case number of password hashes a single request can force, + regardless of how many voters share the submitted attribute value(s). +- **Max failures per attribute-value combination** / **Failure window (seconds)** + (`tupleMaxFailures` / `tupleFailureWindowSeconds`, defaults `10` / `60`): failures are also + counted per distinct combination of submitted attribute values, independent of any single + account. This closes a gap that per-account Brute Force Detection can't cover on its own: when a + request matches more than one candidate, Keycloak has no single account to attribute the failure + to, so its lockout counter never engages for that request - an attacker could otherwise repeat a + common attribute value (e.g. a shared date of birth) indefinitely at full cost. Once a + combination's failures reach the configured maximum within the window, further attempts against + it are rejected without any user lookup at all, until the window elapses or a matching request + succeeds (which clears the count). This throttle is tracked cluster-wide, so it can't be evaded + by spreading requests across Keycloak nodes. +- **Max user-store rows per attribute lookup** (`maxAttributeLookupResults`, default `5000`): a + hard ceiling on how many rows the underlying user-store query may return, applied before any + candidate is even loaded into memory. This is deliberately much larger than **Max candidates per + request** - it exists only to stop a truly pathological match (e.g. an attribute value shared by + most of the realm) from pulling an unbounded number of rows from the database, not to replace the + tighter candidate cap above. Keep it well above the largest realistic combined match count you'd + expect a legitimate voter lookup to produce; setting it too low can make configuring a second + identifying attribute (the recommended lever below) less effective at narrowing large buckets. + +Raising **Max candidates per request** trades this protection for looser matching (useful if a +single attribute alone is expected to match unusually large groups); lowering the failure +threshold or widening the window trades convenience for tighter DoS protection. Configuring a +second identifying attribute (see [Step 2](#step-2--configure-the-attributes-to-match)) is +generally the better lever - it keeps genuine candidate sets small without needing to loosen these +guards. + +If PINs or passwords used with this authenticator are short (e.g. a numeric PIN), do **not** +compensate for guessability by weakening the password hash algorithm - that only makes offline +cracking easier if hashes ever leak. Use the settings above instead; they bound CPU cost without +touching hash strength. + +--- + +## Multiple-Candidate Match Policy + +When more than one candidate shares the configured attribute value(s) (e.g. several voters born on +the same date), **Multiple-candidate match policy** governs how the submitted password picks one: + +- **`REJECT_AMBIGUOUS`** (default): checks every candidate's password. Only succeeds if the + submitted password matches **exactly one** of them; if it matches more than one, the request + fails generically, the same as if none had matched. +- **`FIRST_MATCH`**: succeeds as soon as **any** candidate's password matches, without checking + whether another candidate would also have matched. Cheaper (stops at the first hit instead of + hashing every candidate), but only correct under one condition. + +> ⚠️ **Security warning:** `FIRST_MATCH` is only safe to enable when **password uniqueness across +> every candidate a request could ever match is guaranteed** - for example, passwords assigned +> centrally and never reused, with no possibility of two voters who share an attribute value also +> sharing a password. If two candidates in a matched set share the same password, **which one +> authenticates is unspecified** - meaning one voter could end up authenticated as a different +> voter's account. If you cannot guarantee password uniqueness across candidates, leave this at +> `REJECT_AMBIGUOUS`. + +Enabling `FIRST_MATCH` does not change anything about the DoS mitigations above - the candidate cap +and per-tuple throttle still apply exactly the same way regardless of match policy. 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..635cc77ed6b --- /dev/null +++ b/docs/docusaurus/docs/07-developers/12-ivr/dob-pin-direct-grant-authenticator.md @@ -0,0 +1,254 @@ +--- +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. +- **Date-valued identifier fields** (e.g. a date of birth collected as 8 raw DTMF digits): the + stored attribute must be canonical `YYYY-MM-DD`. Since the IVR prompt collects raw digits with + no separators (e.g. `MMDDYYYY`), set the `date_format` property to that digit order - aligned + by index with the `identifier` fields only (not the secret field) - and the authenticator + normalizes into `YYYY-MM-DD` before matching. Leave it unset (or leave that field's entry + blank) for identifier fields that aren't dates. + +**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. Optionally adjust the DoS-mitigation settings below (sensible defaults are pre-filled - see + [Denial-of-Service Considerations](#denial-of-service-considerations)): + - **Max candidates per request** (default `10`) + - **Max failures per identifier-value combination** (default `10`) + - **Failure window (seconds)** (default `60`) + - **Max user-store rows per identifier lookup** (default `5000`) +4. Leave **Multiple-candidate match policy** at its default, `REJECT_AMBIGUOUS`, unless you have + read and understood [Multiple-Candidate Match Policy](#multiple-candidate-match-policy) below - + the alternative, `FIRST_MATCH`, is only safe when PIN uniqueness across every possible candidate + is guaranteed. +5. 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 - counted toward that account's Brute Force Detection lockout, same as a standard login. | +| Exactly one candidate, currently locked out by Brute Force Detection | Direct Grant `invalid_grant` error - no PIN check is even attempted. | +| 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 - see the brute-force note below. | + +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. +Every "no match" outcome takes the same time to respond, including a real password-hash +computation on paths that never found a candidate to check. + +> **Note on brute-force protection**, same behavior as the web form: Keycloak's per-account +> brute-force lockout engages once resolution narrows to a single candidate - that account's +> failed PIN attempts get counted the same way a standard login's would. When more than one +> candidate still shares the identifying attribute(s), there is no single account a failed attempt +> can honestly be attributed to, so the counter can't engage for that specific request (a +> locked-out account among several ambiguous candidates still can't have its PIN probed, though - +> it's excluded from consideration before any PIN is checked). Configuring more identifying +> attributes narrows the candidate set before the PIN check, making the single-candidate (fully +> protected) case the common one; keep **Brute Force Detection** enabled at the realm level +> regardless. + +--- + +## Denial-of-Service Considerations + +Because this authenticator matches on identifier attributes rather than a unique voter ID, a +single request can legitimately resolve to many candidates (e.g. every voter born on a common +date) - and each candidate normally costs one PIN-hash comparison to rule out. Three independent +settings, shared with the web form's `MultiAttributeCredentialResolver`, bound that cost per +request, on top of Keycloak's standard Brute Force Detection: + +- **Max candidates per request** (`maxCandidates`, default `10`): once the identifier fields match + more candidates than this, the request fails generically without checking any of their PINs. + This bounds the worst-case number of password hashes a single request can force, regardless of + how many voters share the submitted identifier value(s). +- **Max failures per identifier-value combination** / **Failure window (seconds)** + (`tupleMaxFailures` / `tupleFailureWindowSeconds`, defaults `10` / `60`): failures are also + counted per distinct combination of submitted identifier values, independent of any single + account. This closes a gap that per-account Brute Force Detection can't cover on its own: when a + request matches more than one candidate, Keycloak has no single account to attribute the failure + to, so its lockout counter never engages for that request - an IVR caller could otherwise repeat + a common identifier value (e.g. a shared date of birth) indefinitely at full cost. Once a + combination's failures reach the configured maximum within the window, further attempts against + it are rejected without any user lookup at all, until the window elapses or a matching request + succeeds (which clears the count). This throttle is tracked cluster-wide, so it can't be evaded + by spreading requests across Keycloak nodes. +- **Max user-store rows per identifier lookup** (`maxAttributeLookupResults`, default `5000`): a + hard ceiling on how many rows the underlying user-store query may return, applied before any + candidate is even loaded into memory. This is deliberately much larger than **Max candidates per + request** - it exists only to stop a truly pathological match (e.g. an identifier value shared by + most of the realm) from pulling an unbounded number of rows from the database, not to replace the + tighter candidate cap above. Keep it well above the largest realistic combined match count you'd + expect a legitimate voter lookup to produce. + +If PINs are short (e.g. a numeric PIN, as is typical for IVR), do **not** compensate for +guessability by weakening the password hash algorithm - that only makes offline cracking easier if +hashes ever leak. A 16-digit numeric PIN is already infeasible to guess online; these settings +exist to bound CPU cost, not to compensate for a weak PIN. Configuring a second identifier field +(once the IVR Lambda supports collecting more than one - see +[Current IVR Lambda compatibility](#current-ivr-lambda-compatibility)) is generally the better +lever for keeping genuine candidate sets small. + +A throttled voter who then resets their PIN may still be blocked under the per-combination +throttle for up to `tupleFailureWindowSeconds` (60s by default) - the same short delay as a +standard Keycloak temporary lockout. The forgot-password/reset flow itself resolves by +username/email or action token, not by these identifier attributes, so it is unaffected by and +doesn't clear this throttle. + +--- + +## Multiple-Candidate Match Policy + +When more than one candidate shares the identifying attribute value(s) (e.g. several voters born +on the same date), **Multiple-candidate match policy** governs how the submitted PIN picks one: + +- **`REJECT_AMBIGUOUS`** (default): checks every candidate's PIN. Only succeeds if the submitted + PIN matches **exactly one** of them; if it matches more than one, the request fails generically, + the same as if none had matched. +- **`FIRST_MATCH`**: succeeds as soon as **any** candidate's PIN matches, without checking whether + another candidate would also have matched. Cheaper (stops at the first hit instead of hashing + every candidate), but only correct under one condition. + +> ⚠️ **Security warning:** `FIRST_MATCH` is only safe to enable when **PIN uniqueness across every +> candidate a request could ever match is guaranteed** - for example, PINs assigned centrally and +> never reused, with no possibility of two voters who share an identifying attribute value also +> sharing a PIN. If two candidates in a matched set share the same PIN, **which one authenticates +> is unspecified** - meaning one voter could end up authenticated as a different voter's account. +> If you cannot guarantee PIN uniqueness across candidates, leave this at `REJECT_AMBIGUOUS`. + +Enabling `FIRST_MATCH` does not change anything about the DoS mitigations above - the candidate cap +and per-tuple throttle still apply exactly the same way regardless of match policy. 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 700cb94da73..32118dd00b4 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 @@ -94,6 +96,44 @@ 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 + + + + + org.glassfish.jersey.core + jersey-common + 3.1.6 + test + @@ -126,6 +166,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/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..2e62a7dc9d8 --- /dev/null +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributeCredentialResolver.java @@ -0,0 +1,545 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +package sequent.keycloak.authenticator.forgot_password; + +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; +import lombok.extern.jbosslog.JBossLog; +import org.keycloak.credential.hash.PasswordHashProvider; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.PasswordPolicy; +import org.keycloak.models.RealmModel; +import org.keycloak.models.SingleUseObjectProvider; +import org.keycloak.models.UserCredentialModel; +import org.keycloak.models.UserModel; +import org.keycloak.services.managers.BruteForceProtector; + +/** + * 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: every configured attribute except {@code username} is ANDed together into a single + * user-store query (see the {@code maxAttributeLookupResults} mitigation below), so the store + * itself computes the candidates matching every attribute at once; {@code username}, always unique, + * is resolved separately via its own dedicated lookup and intersected with that result. Candidates + * who are disabled or currently locked out by brute-force protection are excluded before any + * password is checked. If exactly one candidate remains, its password is checked directly - this is + * the only case where a failure can be attributed to one account (see {@link + * Resolution#attributableUser()}), so callers should {@code setUser()} it before signaling failure, + * letting Keycloak's normal brute-force accounting engage the same way it does for the standard + * username/password form. If more than one candidate remains, {@link MatchPolicy} governs how the + * password disambiguates among them; either way a failure there can't be attributed to a single + * account. Any early-return path performs an equivalent-cost dummy password hash first, so "no + * viable candidate" doesn't respond measurably faster than "wrong password" - see {@link + * #performDummyHash}. + * + *

Three DoS mitigations bound the cost an attacker can force per request, since - unlike a + * standard username/password form - a common attribute value (e.g. a shared date of birth) can + * legitimately resolve to many candidates, and Keycloak's own brute-force accounting can't engage + * until resolution narrows to one account: + * + *

    + *
  • {@code maxAttributeLookupResults} ({@link ThrottleConfig#maxAttributeLookupResults()}): + * bounds the rows the user-store query itself may return, regardless of how many accounts + * happen to share the submitted attribute value(s) - see that field's javadoc. + *
  • {@code maxCandidates} ({@link ThrottleConfig#maxCandidates()}): once attribute matching + * narrows to more enabled candidates than this, resolution fails generically without checking + * any of their passwords or their brute-force lockout state - bounding both the worst-case + * number of password hashes and the worst-case number of {@code BruteForceProtector} lookups + * per request. + *
  • Per-tuple failure throttle ({@link ThrottleConfig#tupleMaxFailures()} / {@link + * ThrottleConfig#tupleFailureWindowSeconds()}): failures are counted per distinct combination + * of submitted attribute values (a "tuple"), independent of any single account, using {@link + * SingleUseObjectProvider} (replicated across Keycloak nodes) so the same tuple can't be + * hammered past the limit across a cluster. Once a tuple's failures reach the configured + * maximum within the window, further attempts against it short-circuit before any user lookup + * - closing the accounting gap that lets an attacker repeat a common, + * never-uniquely-attributable attribute value indefinitely at full amplification cost. + *
+ */ +@JBossLog +public final class MultiAttributeCredentialResolver { + + private MultiAttributeCredentialResolver() {} + + private static final String TUPLE_THROTTLE_KEY_PREFIX = + "multi-attribute-password:tuple-throttle:"; + private static final String FAILURE_COUNT_NOTE = "failures"; + + /** Brute-force lockout state of a resolved candidate. */ + public enum LockoutState { + NONE, + TEMPORARY, + PERMANENT + } + + /** + * DoS-mitigation configuration, read from each caller's own {@code AuthenticatorConfigModel} via + * {@link Utils#getThrottleConfig}, so the browser form and the IVR Direct Grant flow can be tuned + * independently even though they share this resolution logic. + * + * @param maxCandidates per-request cap on enabled candidates, checked before any brute-force + * lockout lookup or password disambiguation (see the class-level DoS-mitigation note). + * @param tupleMaxFailures failures allowed for a single submitted-attribute-value combination + * within {@code tupleFailureWindowSeconds} before further attempts against it short-circuit. + * @param tupleFailureWindowSeconds rolling window, in seconds, that {@code tupleMaxFailures} + * applies over; each new failure resets the window for that tuple. + * @param maxAttributeLookupResults hard ceiling on rows the user store may return for the + * combined, ANDed query {@link #resolveAuthenticatedUser} issues across every non-username + * match attribute at once (see the class-level DoS-mitigation note on unbounded retrieval) - + * passed straight through as that query's {@code maxResults}, so the store itself applies the + * bound (a real SQL {@code LIMIT} under the default JPA provider). Deliberately much larger + * than {@code maxCandidates}: it exists only to stop a truly pathological match (e.g. a value + * shared by most of the realm) from returning unbounded rows, not to replace {@code + * maxCandidates}'s much tighter bound on password-hash cost. Because every attribute is ANDed + * together in the same query before this limit is applied, the rows returned are always the + * true multi-attribute intersection, so this ceiling can only ever discard results in a + * genuinely pathological case (more true combined matches than the ceiling) - it can never + * exclude a legitimate candidate that a less-common attribute would otherwise have uniquely + * matched. + */ + public record ThrottleConfig( + int maxCandidates, + int tupleMaxFailures, + int tupleFailureWindowSeconds, + int maxAttributeLookupResults) { + + /** + * Convenience overload defaulting {@code maxAttributeLookupResults} to {@link + * Utils#MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT}. + */ + public ThrottleConfig(int maxCandidates, int tupleMaxFailures, int tupleFailureWindowSeconds) { + this( + maxCandidates, + tupleMaxFailures, + tupleFailureWindowSeconds, + Integer.parseInt(Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT)); + } + + public static ThrottleConfig defaults() { + return new ThrottleConfig( + Integer.parseInt(Utils.MAX_CANDIDATES_DEFAULT), + Integer.parseInt(Utils.TUPLE_MAX_FAILURES_DEFAULT), + Integer.parseInt(Utils.TUPLE_FAILURE_WINDOW_SECONDS_DEFAULT), + Integer.parseInt(Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT)); + } + } + + /** + * Governs what happens when more than one viable candidate remains after attribute matching - see + * the multi-candidate branch of {@link #resolveAuthenticatedUser}. + */ + public enum MatchPolicy { + /** + * Default, safe for any deployment: password-check every viable candidate, and only succeed + * when the submitted password matches exactly one of them. If it matches more than + * one, that's an ambiguous outcome and the request fails generically, the same as if none had + * matched. + */ + REJECT_AMBIGUOUS, + /** + * Succeed as soon as any viable candidate's password matches, without checking whether another + * candidate would also have matched. Cheaper (stops at the first hit instead of hashing every + * candidate) but only safe when passwords are guaranteed unique across every candidate + * this could ever match against - if two candidates in a matched set share the same + * password, which one gets authenticated is unspecified, letting one voter authenticate as + * another's account. See {@link Utils#MATCH_POLICY} for where this is surfaced as authenticator + * config, including the required warning. + */ + FIRST_MATCH; + + /** + * Mirrors {@code Utils.MessageCourier#fromString} - the convention used elsewhere in this + * module. + */ + public static MatchPolicy fromString(String value) { + if (value == null || value.isBlank()) { + return REJECT_AMBIGUOUS; + } + for (MatchPolicy policy : values()) { + if (policy.name().equalsIgnoreCase(value)) { + return policy; + } + } + throw new IllegalArgumentException("No constant with text " + value + " found"); + } + } + + /** + * Outcome of a resolution attempt. + * + * @param authenticatedUser populated only on full success (matching attributes AND password). + * @param attributableUser populated whenever resolution narrowed to exactly one viable candidate, + * regardless of whether their password matched - callers should {@code context.setUser()} + * this before signaling failure so Keycloak's brute-force accounting can attribute the + * attempt. + * @param lockoutState non-{@code NONE} when the sole viable-by-attributes candidate is currently + * locked out by brute-force protection, so no password check was even attempted. + */ + public record Resolution( + Optional authenticatedUser, + Optional attributableUser, + LockoutState lockoutState) { + + static Resolution success(UserModel user) { + return new Resolution(Optional.of(user), Optional.of(user), LockoutState.NONE); + } + + static Resolution failure() { + return new Resolution(Optional.empty(), Optional.empty(), LockoutState.NONE); + } + + static Resolution failureAttributedTo(UserModel user) { + return new Resolution(Optional.empty(), Optional.of(user), LockoutState.NONE); + } + + static Resolution lockedOut(UserModel user, LockoutState state) { + return new Resolution(Optional.empty(), Optional.of(user), state); + } + } + + /** Convenience overload for callers that don't need a non-default {@link ThrottleConfig}. */ + public static Resolution resolveAuthenticatedUser( + KeycloakSession session, + RealmModel realm, + List matchAttributes, + Map submittedValues, + String password) { + return resolveAuthenticatedUser( + session, realm, matchAttributes, submittedValues, password, ThrottleConfig.defaults()); + } + + /** Overload for callers that don't need a non-default {@link MatchPolicy}. */ + public static Resolution resolveAuthenticatedUser( + KeycloakSession session, + RealmModel realm, + List matchAttributes, + Map submittedValues, + String password, + ThrottleConfig throttleConfig) { + return resolveAuthenticatedUser( + session, + realm, + matchAttributes, + submittedValues, + password, + throttleConfig, + MatchPolicy.REJECT_AMBIGUOUS); + } + + public static Resolution resolveAuthenticatedUser( + KeycloakSession session, + RealmModel realm, + List matchAttributes, + Map submittedValues, + String password, + ThrottleConfig throttleConfig, + MatchPolicy matchPolicy) { + if (matchAttributes == null || matchAttributes.isEmpty()) { + // Logged at ERROR: a static misconfiguration, not a one-off bad request - every login + // attempt through this authenticator config fails until an admin fixes it, so it needs to + // stand out clearly in production monitoring. + log.errorv( + "resolveAuthenticatedUser(): misconfigured - no matchAttributes configured, realm={0}", + realm.getName()); + return dummyFailure(session, realm); + } + if (password == null || password.isBlank()) { + return dummyFailure(session, realm); + } + + Map trimmedValues = new LinkedHashMap<>(); + for (String attribute : matchAttributes) { + String value = submittedValues.get(attribute); + if (value == null || value.isBlank()) { + return dummyFailure(session, realm); + } + trimmedValues.put(attribute, value.trim()); + } + + // Computed once every attribute has a validated value, and checked before any user lookup - + // see the class-level DoS-mitigation note. Values that never reach this point (missing/blank) + // don't correspond to a specific tuple worth throttling: the loop above already bails out at + // the same cost as any other invalid-input request, without searching for or hashing anything. + String tupleKey = tupleThrottleKey(realm, matchAttributes, trimmedValues); + if (isTupleThrottled(session, tupleKey, throttleConfig.tupleMaxFailures())) { + log.warnv( + "resolveAuthenticatedUser(): tuple throttled, realm={0}, key={1}", + realm.getName(), tupleKey); + return dummyFailure(session, realm); + } + + // username is always resolved by its own dedicated, always-at-most-one-result lookup - it + // never needs the DoS-bounding below, since a username match can never be more than one row. + // Every other attribute (including email) is ANDed together into a single store query, so the + // store computes the true multi-attribute intersection itself - see + // ThrottleConfig#maxAttributeLookupResults. Because the rows returned are already the true + // intersection, bounding the query's result count can only ever discard results in the + // genuinely pathological case (more true combined matches than the ceiling); it can never + // exclude a legitimate candidate, since attributes are never queried independently. + Map candidatesById = null; + + List nonUsernameAttributes = + matchAttributes.stream() + .filter(attribute -> !"username".equalsIgnoreCase(attribute)) + .toList(); + if (!nonUsernameAttributes.isEmpty()) { + Map queryParams = new LinkedHashMap<>(); + for (String attribute : nonUsernameAttributes) { + String key = "email".equalsIgnoreCase(attribute) ? UserModel.EMAIL : attribute; + queryParams.put(key, trimmedValues.get(attribute)); + } + // Exact match, not the store's default partial/LIKE behavior for some fields - see + // UserQueryMethodsProvider#searchForUserStream's javadoc on UserModel#EXACT. Custom + // attributes still match case-insensitively, since the store lower-cases both sides of the + // comparison before comparing. + queryParams.put(UserModel.EXACT, Boolean.TRUE.toString()); + + candidatesById = + session + .users() + .searchForUserStream( + realm, queryParams, 0, throttleConfig.maxAttributeLookupResults()) + .collect(Collectors.toMap(UserModel::getId, Function.identity(), (a, b) -> a)); + } + + Optional usernameAttribute = + matchAttributes.stream() + .filter(attribute -> "username".equalsIgnoreCase(attribute)) + .findFirst(); + if (usernameAttribute.isPresent()) { + UserModel user = + session.users().getUserByUsername(realm, trimmedValues.get(usernameAttribute.get())); + Map usernameMatch = user == null ? Map.of() : Map.of(user.getId(), user); + if (candidatesById == null) { + candidatesById = usernameMatch; + } else { + candidatesById.keySet().retainAll(usernameMatch.keySet()); + } + } + + if (candidatesById.isEmpty()) { + recordTupleFailure(session, tupleKey, throttleConfig.tupleFailureWindowSeconds()); + return dummyFailure(session, realm); + } + + List enabledCandidates = + candidatesById.values().stream().filter(UserModel::isEnabled).collect(Collectors.toList()); + + if (enabledCandidates.size() > throttleConfig.maxCandidates()) { + // Cap checked here, before lockoutStateOf() below touches BruteForceProtector for each + // candidate - bounds not just the worst-case number of password hashes per request (see the + // class-level DoS-mitigation note) but also the K brute-force-lockout lookups that would + // otherwise run first. Never log the candidate values themselves, only the count. + log.warnv( + "resolveAuthenticatedUser(): candidate cap exceeded, realm={0}, {1} enabled candidates" + + " (max {2})", + realm.getName(), enabledCandidates.size(), throttleConfig.maxCandidates()); + recordTupleFailure(session, tupleKey, throttleConfig.tupleFailureWindowSeconds()); + return dummyFailure(session, realm); + } + + Map lockoutStates = + enabledCandidates.stream() + .collect(Collectors.toMap(Function.identity(), c -> lockoutStateOf(session, realm, c))); + List lockedOutCandidates = + enabledCandidates.stream() + .filter(candidate -> lockoutStates.get(candidate) != LockoutState.NONE) + .collect(Collectors.toList()); + // Bounded by the cap above (<= maxCandidates), since it's a subset of enabledCandidates. + List viableCandidates = + enabledCandidates.stream() + .filter(candidate -> lockoutStates.get(candidate) == LockoutState.NONE) + .collect(Collectors.toList()); + + if (viableCandidates.isEmpty()) { + // Every enabled candidate for these attributes is currently locked out: only report the + // specific lockout when there is exactly one such account to attribute it to - an ambiguous + // set of locked accounts must stay as generic a failure as any other ambiguous outcome. + recordTupleFailure(session, tupleKey, throttleConfig.tupleFailureWindowSeconds()); + if (lockedOutCandidates.size() == 1) { + UserModel locked = lockedOutCandidates.get(0); + return Resolution.lockedOut(locked, lockoutStates.get(locked)); + } + return dummyFailure(session, realm); + } + + if (viableCandidates.size() == 1) { + UserModel candidate = viableCandidates.get(0); + if (isPasswordValid(candidate, password)) { + clearTupleThrottle(session, tupleKey); + return Resolution.success(candidate); + } + recordTupleFailure(session, tupleKey, throttleConfig.tupleFailureWindowSeconds()); + return Resolution.failureAttributedTo(candidate); + } + + if (matchPolicy == MatchPolicy.FIRST_MATCH) { + // Stops at the first match rather than checking every candidate - see MatchPolicy's + // javadoc for why this is only safe when passwords are unique across the candidate set. + for (UserModel candidate : viableCandidates) { + if (isPasswordValid(candidate, password)) { + clearTupleThrottle(session, tupleKey); + return Resolution.success(candidate); + } + } + recordTupleFailure(session, tupleKey, throttleConfig.tupleFailureWindowSeconds()); + return Resolution.failure(); + } + + List passwordMatches = + viableCandidates.stream() + .filter(candidate -> isPasswordValid(candidate, password)) + .collect(Collectors.toList()); + + if (passwordMatches.size() == 1) { + clearTupleThrottle(session, tupleKey); + return Resolution.success(passwordMatches.get(0)); + } + if (passwordMatches.size() > 1) { + log.warnv( + "resolveAuthenticatedUser(): ambiguous match, realm={0}, {1} candidates matched the" + + " submitted password", + realm.getName(), passwordMatches.size()); + } + recordTupleFailure(session, tupleKey, throttleConfig.tupleFailureWindowSeconds()); + return Resolution.failure(); + } + + private static Resolution dummyFailure(KeycloakSession session, RealmModel realm) { + performDummyHash(session, realm); + return Resolution.failure(); + } + + /** + * Builds the per-tuple throttle key: {@code SHA-256(realmId + '|' + attr1=value1 + '|' + + * attr2=value2 ...)}, sorted by attribute name so the key is independent of {@code + * matchAttributes}' configured order. Values are lowercased before hashing to match the + * case-insensitive attribute matching the combined user-store query performs, so submitting the + * same value with different casing always lands in the same throttle bucket rather than each + * casing getting its own fresh allowance of failures. + */ + private static String tupleThrottleKey( + RealmModel realm, List matchAttributes, Map trimmedValues) { + List sortedAttributes = new ArrayList<>(matchAttributes); + Collections.sort(sortedAttributes); + StringBuilder raw = new StringBuilder(String.valueOf(realm.getId())); + for (String attribute : sortedAttributes) { + raw.append('|') + .append(attribute) + .append('=') + .append(trimmedValues.get(attribute).toLowerCase(Locale.ROOT)); + } + return sha256Hex(raw.toString()); + } + + private static String sha256Hex(String input) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8)); + StringBuilder hex = new StringBuilder(hash.length * 2); + for (byte b : hash) { + hex.append(String.format("%02x", b)); + } + return hex.toString(); + } catch (NoSuchAlgorithmException e) { + throw new IllegalStateException("SHA-256 not available", e); + } + } + + private static String tupleThrottleStoreKey(String tupleKey) { + return TUPLE_THROTTLE_KEY_PREFIX + tupleKey; + } + + private static int failureCount(Map notes) { + if (notes == null) { + return 0; + } + String count = notes.get(FAILURE_COUNT_NOTE); + return count == null ? 0 : Integer.parseInt(count); + } + + private static boolean isTupleThrottled( + KeycloakSession session, String tupleKey, int tupleMaxFailures) { + Map notes = session.singleUseObjects().get(tupleThrottleStoreKey(tupleKey)); + return failureCount(notes) >= tupleMaxFailures; + } + + /** + * Records a failure against this tuple, resetting its TTL to a fresh {@code windowSeconds} window + * - a rolling window, not a fixed one, so sustained hammering of the same tuple keeps it + * throttled rather than getting a clean slate mid-attack. + * + *

Read-then-write, not atomic: {@link SingleUseObjectProvider} exposes no compare-and-swap, so + * concurrent requests against the same tuple can race both ways - two failures can collapse into + * one increment (undercount, delaying engagement), or a failure can read stale data and overwrite + * a concurrent {@link #clearTupleThrottle} (spuriously reviving a counter a success just + * cleared). Acceptable here since this throttle only bounds CPU cost rather than enforcing a hard + * security limit: every write resets the TTL to at most {@code windowSeconds}, so either race + * self-clears within that window - never a stuck lockout, for attacker or legitimate voter alike. + */ + private static void recordTupleFailure( + KeycloakSession session, String tupleKey, int windowSeconds) { + String storeKey = tupleThrottleStoreKey(tupleKey); + SingleUseObjectProvider store = session.singleUseObjects(); + int nextCount = failureCount(store.get(storeKey)) + 1; + store.put(storeKey, windowSeconds, Map.of(FAILURE_COUNT_NOTE, String.valueOf(nextCount))); + } + + private static void clearTupleThrottle(KeycloakSession session, String tupleKey) { + session.singleUseObjects().remove(tupleThrottleStoreKey(tupleKey)); + } + + /** + * Performs a password-hash computation of realistic cost against fixed dummy data, matching + * {@code org.keycloak.authentication.authenticators.util.AuthenticatorUtils#dummyHash} (used by + * Keycloak's own {@code ValidateUsername} when no user is found), so that "no viable candidate" + * doesn't resolve measurably faster than "found a candidate, wrong password" - both perform + * exactly one hash comparison. + */ + private static void performDummyHash(KeycloakSession session, RealmModel realm) { + PasswordPolicy passwordPolicy = realm.getPasswordPolicy(); + PasswordHashProvider provider; + if (passwordPolicy != null && passwordPolicy.getHashAlgorithm() != null) { + provider = session.getProvider(PasswordHashProvider.class, passwordPolicy.getHashAlgorithm()); + } else { + provider = session.getProvider(PasswordHashProvider.class); + } + int iterations = passwordPolicy != null ? passwordPolicy.getHashIterations() : -1; + provider.encodedCredential("SlightlyLongerDummyPassword", iterations); + } + + private static LockoutState lockoutStateOf( + KeycloakSession session, RealmModel realm, UserModel user) { + if (!realm.isBruteForceProtected()) { + return LockoutState.NONE; + } + BruteForceProtector protector = session.getProvider(BruteForceProtector.class); + if (protector.isPermanentlyLockedOut(session, realm, user)) { + return LockoutState.PERMANENT; + } + if (protector.isTemporarilyDisabled(session, realm, user)) { + return LockoutState.TEMPORARY; + } + return LockoutState.NONE; + } + + 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 new file mode 100644 index 00000000000..f3cf7c45708 --- /dev/null +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticator.java @@ -0,0 +1,402 @@ +// 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 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.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; + +/** + * 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; + } + + // A browser authentication session survives failureChallenge(), so a retry may still carry + // the user attributed by the previous attempt. Clear it before resolving the new submission; + // otherwise setUser() rejects a different unique candidate as USER_CONFLICT, and failures + // without an attributable candidate can be charged to the stale account. + context.clearUser(); + + List matchAttributes = + Utils.getMultivalueString( + context.getAuthenticatorConfig(), + Utils.MATCH_ATTRIBUTES, + Utils.MATCH_ATTRIBUTES_DEFAULT); + Map submittedValues = + collectSubmittedValues(context.getSession(), matchAttributes, formData); + String password = formData.getFirst(FIELD_PASSWORD); + + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + Utils.getThrottleConfig(context.getAuthenticatorConfig()); + MultiAttributeCredentialResolver.MatchPolicy matchPolicy = + Utils.getMatchPolicy(context.getAuthenticatorConfig()); + MultiAttributeCredentialResolver.Resolution result = + resolveAuthenticatedUser( + context.getSession(), + context.getRealm(), + matchAttributes, + submittedValues, + password, + throttleConfig, + matchPolicy); + + // Set even on failure/lockout, before signaling the outcome - Keycloak's brute-force + // accounting (DefaultAuthenticationFlow -> AuthenticationProcessor.logFailure()) only fires + // for a user set on the authentication session, same as the standard username/password form. + result.attributableUser().ifPresent(context::setUser); + + if (result.authenticatedUser().isPresent()) { + context.success(); + } else if (result.lockoutState() != MultiAttributeCredentialResolver.LockoutState.NONE) { + lockedOut(context, formData, result.lockoutState()); + } 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); + context.clearUser(); + } + + /** + * Mirrors {@code AbstractUsernameFormAuthenticator.isDisabledByBruteForce}: uses {@code + * forceChallenge} rather than {@code failureChallenge} so the flow engine doesn't log yet another + * failure against an account that's already locked out. + */ + private void lockedOut( + AuthenticationFlowContext context, + MultivaluedMap formData, + MultiAttributeCredentialResolver.LockoutState state) { + boolean permanent = state == MultiAttributeCredentialResolver.LockoutState.PERMANENT; + context.getEvent().error(permanent ? Errors.USER_DISABLED : Errors.USER_TEMPORARILY_DISABLED); + Response challengeResponse = + challenge( + context, + formData, + permanent + ? Messages.ACCOUNT_PERMANENTLY_DISABLED + : Messages.ACCOUNT_TEMPORARILY_DISABLED); + context.forceChallenge(challengeResponse); + context.clearUser(); + } + + /** + * 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 MultiAttributeCredentialResolver.Resolution resolveAuthenticatedUser( + KeycloakSession session, + RealmModel realm, + List matchAttributes, + Map submittedValues, + String password) { + return MultiAttributeCredentialResolver.resolveAuthenticatedUser( + session, realm, matchAttributes, submittedValues, password); + } + + /** Overload used by {@link #action} once the DoS-mitigation config has been read. */ + protected MultiAttributeCredentialResolver.Resolution resolveAuthenticatedUser( + KeycloakSession session, + RealmModel realm, + List matchAttributes, + Map submittedValues, + String password, + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig) { + return MultiAttributeCredentialResolver.resolveAuthenticatedUser( + session, realm, matchAttributes, submittedValues, password, throttleConfig); + } + + /** + * Overload used by {@link #action} once the DoS-mitigation and match-policy config has been read. + */ + protected MultiAttributeCredentialResolver.Resolution resolveAuthenticatedUser( + KeycloakSession session, + RealmModel realm, + List matchAttributes, + Map submittedValues, + String password, + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig, + MultiAttributeCredentialResolver.MatchPolicy matchPolicy) { + return MultiAttributeCredentialResolver.resolveAuthenticatedUser( + session, realm, matchAttributes, submittedValues, password, throttleConfig, matchPolicy); + } + + /** + * Reads each configured attribute's submitted value, normalizing date-typed attributes (per the + * realm's User Profile {@code html5-date} annotation, the same source {@link + * #buildAttributeFields} reads) into the canonical {@code YYYY-MM-DD} storage format - see {@link + * Utils#normalizeDate}. An HTML5 date input already submits exactly {@code YYYY-MM-DD}, so this + * is a no-op for the common case; it's still applied defensively so the browser path stays + * consistent with the IVR path, which needs real reordering. + */ + protected Map collectSubmittedValues( + KeycloakSession session, + List matchAttributes, + MultivaluedMap formData) { + List profileAttributes = Utils.getRealmUserProfileAttributes(session); + Map submittedValues = new HashMap<>(); + for (String attribute : matchAttributes) { + String rawValue = formData.getFirst(attribute); + if ("date".equals(Utils.resolveHtml5InputType(profileAttributes, attribute))) { + rawValue = Utils.normalizeDate(rawValue, "YYYY-MM-DD"); + } + submittedValues.put(attribute, rawValue); + } + return submittedValues; + } + + 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}. 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(profileAttributes, 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() { + ProviderConfigProperty matchPolicy = + new ProviderConfigProperty( + Utils.MATCH_POLICY, + "Multiple-candidate match policy", + "How to resolve when more than one candidate shares the configured attribute" + + " value(s). REJECT_AMBIGUOUS (default, safe): only succeed if the submitted" + + " password matches exactly one candidate; if it matches more than one, fail" + + " generically. FIRST_MATCH: succeed as soon as any candidate's password" + + " matches, without checking the rest. WARNING: FIRST_MATCH is only secure if" + + " passwords are guaranteed unique across every candidate this could ever match" + + " - if two candidates share a password, which one authenticates is unspecified," + + " letting one voter log in as another's account. Do not enable it unless" + + " password uniqueness across candidates is guaranteed.", + ProviderConfigProperty.LIST_TYPE, + Utils.MATCH_POLICY_DEFAULT); + matchPolicy.setOptions( + List.of( + MultiAttributeCredentialResolver.MatchPolicy.REJECT_AMBIGUOUS.name(), + MultiAttributeCredentialResolver.MatchPolicy.FIRST_MATCH.name())); + + 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), + new ProviderConfigProperty( + Utils.MAX_CANDIDATES, + "Max candidates per request", + "DoS guard: once the configured attributes match more than this many enabled," + + " non-locked-out candidates, the request fails generically without checking any" + + " of their passwords. Bounds the worst-case number of password hashes per" + + " request. Default: " + + Utils.MAX_CANDIDATES_DEFAULT + + ".", + ProviderConfigProperty.STRING_TYPE, + Utils.MAX_CANDIDATES_DEFAULT), + new ProviderConfigProperty( + Utils.TUPLE_MAX_FAILURES, + "Max failures per attribute-value combination", + "DoS guard: failures allowed for a single submitted combination of attribute values" + + " (e.g. one date of birth) within the failure window below, before further" + + " attempts against it are rejected without any user lookup. Default: " + + Utils.TUPLE_MAX_FAILURES_DEFAULT + + ".", + ProviderConfigProperty.STRING_TYPE, + Utils.TUPLE_MAX_FAILURES_DEFAULT), + new ProviderConfigProperty( + Utils.TUPLE_FAILURE_WINDOW_SECONDS, + "Failure window (seconds)", + "Rolling window the failure count above applies over; each new failure against the" + + " same attribute-value combination resets the window. Default: " + + Utils.TUPLE_FAILURE_WINDOW_SECONDS_DEFAULT + + ".", + ProviderConfigProperty.STRING_TYPE, + Utils.TUPLE_FAILURE_WINDOW_SECONDS_DEFAULT), + new ProviderConfigProperty( + Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS, + "Max user-store rows per attribute lookup", + "DoS guard: hard ceiling on rows the user store may return for the combined query" + + " across every configured attribute - bounds worst-case database/memory cost," + + " separately from the (much tighter) max candidates guard above, which bounds" + + " password-hash cost. Since all configured attributes are ANDed together in one" + + " query, the rows returned are always the true multi-attribute match, so this" + + " ceiling can only ever discard results in a genuinely pathological case (more" + + " true combined matches than the ceiling) - keep it well above the largest" + + " realistic combined match count you'd expect a legitimate voter lookup to" + + " produce. Default: " + + Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT + + ".", + ProviderConfigProperty.STRING_TYPE, + Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT), + matchPolicy); + } + + @Override + public boolean isUserSetupAllowed() { + return false; + } +} 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..bb52f59726e --- /dev/null +++ b/packages/keycloak-extensions/message-otp-authenticator/src/main/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordDirectGrantAuthenticator.java @@ -0,0 +1,380 @@ +// 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 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"; + + /** + * Authenticator-internal only - not part of the {@code ivr-config-provider} contract, so it's not + * subject to that module's strict field-count validation. Optional, {@code ##}-separated, aligned + * by index with the {@code identifier}-kind fields in the order they appear (i.e. with {@code + * matchAttributes}, not the full field list) - e.g. {@code "MMDDYYYY"} for the raw digit order + * IVR DTMF collection uses for a date. Missing/blank entries mean "no normalization" for that + * field. + */ + public static final String CONFIG_DATE_FORMAT = "date_format"; + + 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) { + // Direct Grant currently gets a fresh authentication session per request, but clearing here + // keeps the resolver lifecycle consistent and prevents a future session-reuse path from + // inheriting an attributed user. + context.clearUser(); + + 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()) { + // Logged at ERROR: a static misconfiguration, not a one-off bad request - every Direct + // Grant attempt through this authenticator config fails until an admin fixes it, so it + // needs to stand out clearly in production monitoring. IVR callers have no client-side way + // to report the actual cause, so the config alias here is the only lead an operator has. + log.errorv( + "authenticate(): misconfigured - maps_to/kind missing or count mismatch, realm={0}," + + " authenticatorConfig={1}", + context.getRealm().getName(), configAlias(authConfig)); + fail(context); + return; + } + + List matchAttributes = new ArrayList<>(); + String passwordField = null; + int secretCount = 0; + for (int i = 0; i < mapsTos.size(); i++) { + if (KIND_SECRET.equalsIgnoreCase(kinds.get(i))) { + secretCount++; + passwordField = mapsTos.get(i); + } else { + matchAttributes.add(mapsTos.get(i)); + } + } + if (secretCount != 1 || matchAttributes.isEmpty()) { + log.errorv( + "authenticate(): misconfigured - config must declare exactly one 'secret' kind entry" + + " ({0} found) and at least one 'identifier' entry, realm={1}," + + " authenticatorConfig={2}", + secretCount, context.getRealm().getName(), configAlias(authConfig)); + fail(context); + return; + } + + MultivaluedMap formData = context.getHttpRequest().getDecodedFormParameters(); + Map submittedValues = + collectSubmittedValues(authConfig, matchAttributes, formData); + String password = formData.getFirst(passwordField); + + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + Utils.getThrottleConfig(authConfig); + MultiAttributeCredentialResolver.MatchPolicy matchPolicy = Utils.getMatchPolicy(authConfig); + MultiAttributeCredentialResolver.Resolution result = + MultiAttributeCredentialResolver.resolveAuthenticatedUser( + context.getSession(), + context.getRealm(), + matchAttributes, + submittedValues, + password, + throttleConfig, + matchPolicy); + + // Set even on failure/lockout, before signaling the outcome - Keycloak's brute-force + // accounting (DefaultAuthenticationFlow -> AuthenticationProcessor.logFailure()) only fires + // for a user set on the authentication session, same as the standard ValidateUsername. + result.attributableUser().ifPresent(context::setUser); + + if (result.authenticatedUser().isPresent()) { + context.success(); + } else if (result.lockoutState() != MultiAttributeCredentialResolver.LockoutState.NONE) { + lockedOut(context, result.lockoutState()); + } else { + fail(context); + } + } + + /** + * Normalizes date-typed identifier values from the raw digit order the IVR Lambda collects (per + * the optional {@link #CONFIG_DATE_FORMAT} config, aligned by index with {@code matchAttributes}) + * into the canonical {@code YYYY-MM-DD} storage format - see {@link Utils#normalizeDate}. Without + * this, IVR-collected digits in any order other than YYYY-MM-DD never match a stored value. + */ + protected Map collectSubmittedValues( + AuthenticatorConfigModel authConfig, + List matchAttributes, + MultivaluedMap formData) { + List dateFormats = Utils.getMultivalueString(authConfig, CONFIG_DATE_FORMAT, List.of()); + Map submittedValues = new HashMap<>(); + for (int i = 0; i < matchAttributes.size(); i++) { + String attribute = matchAttributes.get(i); + String rawValue = formData.getFirst(attribute); + String format = i < dateFormats.size() ? dateFormats.get(i) : ""; + if (format != null && !format.isBlank()) { + rawValue = Utils.normalizeDate(rawValue, format); + } + submittedValues.put(attribute, rawValue); + } + return submittedValues; + } + + /** + * The admin-visible config alias (e.g. as shown next to the flow step in the Admin Console), or a + * placeholder when no config is attached - the only way a log line can point an operator at which + * specific authenticator config to fix, since a realm can have more than one. + */ + private static String configAlias(AuthenticatorConfigModel authConfig) { + return authConfig == null || authConfig.getAlias() == null ? "(none)" : authConfig.getAlias(); + } + + 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); + } + + /** + * Mirrors the stock {@code ValidateUsername} Direct Grant authenticator's brute-force handling: + * uses {@code forceChallenge} rather than {@code failure} so the flow engine doesn't log yet + * another failure against an account that's already locked out. + */ + private void lockedOut( + AuthenticationFlowContext context, MultiAttributeCredentialResolver.LockoutState state) { + boolean permanent = state == MultiAttributeCredentialResolver.LockoutState.PERMANENT; + context.getEvent().error(permanent ? Errors.USER_DISABLED : Errors.USER_TEMPORARILY_DISABLED); + Response challengeResponse = + errorResponse( + Response.Status.BAD_REQUEST.getStatusCode(), + "invalid_grant", + permanent ? "Account permanently disabled" : "Account temporarily disabled"); + context.forceChallenge(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() { + ProviderConfigProperty matchPolicy = + new ProviderConfigProperty( + Utils.MATCH_POLICY, + "Multiple-candidate match policy", + "How to resolve when more than one candidate shares the identifying attribute" + + " value(s). REJECT_AMBIGUOUS (default, safe): only succeed if the submitted PIN" + + " matches exactly one candidate; if it matches more than one, fail generically." + + " FIRST_MATCH: succeed as soon as any candidate's PIN matches, without checking" + + " the rest. WARNING: FIRST_MATCH is only secure if PINs are guaranteed unique" + + " across every candidate this could ever match - if two candidates share a PIN," + + " which one authenticates is unspecified, letting one voter authenticate as" + + " another's account. Do not enable it unless PIN uniqueness across candidates is" + + " guaranteed.", + ProviderConfigProperty.LIST_TYPE, + Utils.MATCH_POLICY_DEFAULT); + matchPolicy.setOptions( + List.of( + MultiAttributeCredentialResolver.MatchPolicy.REJECT_AMBIGUOUS.name(), + MultiAttributeCredentialResolver.MatchPolicy.FIRST_MATCH.name())); + + 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), + new ProviderConfigProperty( + CONFIG_DATE_FORMAT, + "Date format per identifier field (optional)", + "For date-valued identifier fields only (e.g. a date of birth collected as raw DTMF" + + " digits): the digit order the IVR Lambda collects it in, e.g. \"MMDDYYYY\"." + + " Aligned by index with the identifier fields only (not the secret field) -" + + " leave an entry empty for identifier fields that aren't dates. Normalizes into" + + " the canonical YYYY-MM-DD storage format before matching.", + ProviderConfigProperty.MULTIVALUED_STRING_TYPE, + null), + new ProviderConfigProperty( + Utils.MAX_CANDIDATES, + "Max candidates per request", + "DoS guard: once the identifier fields match more than this many enabled," + + " non-locked-out candidates, the request fails generically without checking any" + + " of their PINs. Bounds the worst-case number of password hashes per request." + + " Default: " + + Utils.MAX_CANDIDATES_DEFAULT + + ".", + ProviderConfigProperty.STRING_TYPE, + Utils.MAX_CANDIDATES_DEFAULT), + new ProviderConfigProperty( + Utils.TUPLE_MAX_FAILURES, + "Max failures per identifier-value combination", + "DoS guard: failures allowed for a single submitted combination of identifier values" + + " (e.g. one date of birth) within the failure window below, before further" + + " attempts against it are rejected without any user lookup. Default: " + + Utils.TUPLE_MAX_FAILURES_DEFAULT + + ".", + ProviderConfigProperty.STRING_TYPE, + Utils.TUPLE_MAX_FAILURES_DEFAULT), + new ProviderConfigProperty( + Utils.TUPLE_FAILURE_WINDOW_SECONDS, + "Failure window (seconds)", + "Rolling window the failure count above applies over; each new failure against the" + + " same identifier-value combination resets the window. Default: " + + Utils.TUPLE_FAILURE_WINDOW_SECONDS_DEFAULT + + ".", + ProviderConfigProperty.STRING_TYPE, + Utils.TUPLE_FAILURE_WINDOW_SECONDS_DEFAULT), + new ProviderConfigProperty( + Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS, + "Max user-store rows per identifier lookup", + "DoS guard: hard ceiling on rows the user store may return for the combined query" + + " across every identifier field - bounds worst-case database/memory cost," + + " separately from the (much tighter) max candidates guard above, which bounds" + + " PIN-hash cost. Since all identifier fields are ANDed together in one query, the" + + " rows returned are always the true multi-field match, so this ceiling can only" + + " ever discard results in a genuinely pathological case (more true combined" + + " matches than the ceiling) - keep it well above the largest realistic combined" + + " match count you'd expect a legitimate voter lookup to produce. Default: " + + Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT + + ".", + ProviderConfigProperty.STRING_TYPE, + Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT), + matchPolicy); + } + + @Override + public String getId() { + return PROVIDER_ID; + } +} 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..a735496a647 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,35 @@ 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 static final String MAX_CANDIDATES = "maxCandidates"; + public static final String MAX_CANDIDATES_DEFAULT = "10"; + public static final String TUPLE_MAX_FAILURES = "tupleMaxFailures"; + public static final String TUPLE_MAX_FAILURES_DEFAULT = "10"; + public static final String TUPLE_FAILURE_WINDOW_SECONDS = "tupleFailureWindowSeconds"; + public static final String TUPLE_FAILURE_WINDOW_SECONDS_DEFAULT = "60"; + + /** + * Hard ceiling on rows pulled from the user store per configured attribute - see {@link + * MultiAttributeCredentialResolver.ThrottleConfig#maxAttributeLookupResults}. Deliberately much + * larger than {@link #MAX_CANDIDATES_DEFAULT}: a safety ceiling against pathological matches, not + * a replacement for the tighter password-hash cost bound. + */ + public static final String MAX_ATTRIBUTE_LOOKUP_RESULTS = "maxAttributeLookupResults"; + + public static final String MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT = "5000"; + + /** + * Selects {@link MultiAttributeCredentialResolver.MatchPolicy}. Defaults to the safe {@code + * REJECT_AMBIGUOUS} - {@code FIRST_MATCH} must only be enabled when passwords are guaranteed + * unique across every candidate a request could match, since it authenticates as the first + * candidate whose password matches without checking for other matches. + */ + public static final String MATCH_POLICY = "matchPolicy"; + + public static final String MATCH_POLICY_DEFAULT = + MultiAttributeCredentialResolver.MatchPolicy.REJECT_AMBIGUOUS.name(); public final String ATTEMPTED_EMAIL = "ATTEMPTED_EMAIL"; public final String DISABLE_PASSWORD_ATTRIBUTE = "disablePassword"; public final String HIDE_USER_NOT_FOUND = "hideUserNotFound"; @@ -152,6 +183,27 @@ public boolean getBoolean( return Boolean.parseBoolean(mapConfig.get(configKey)); } + /** + * Reads the DoS-mitigation config shared by {@link MultiAttributePasswordAuthenticator} and + * {@link MultiAttributePasswordDirectGrantAuthenticator} - see {@link + * MultiAttributeCredentialResolver.ThrottleConfig}. + */ + public MultiAttributeCredentialResolver.ThrottleConfig getThrottleConfig( + AuthenticatorConfigModel config) { + return new MultiAttributeCredentialResolver.ThrottleConfig( + getInt(config, MAX_CANDIDATES, MAX_CANDIDATES_DEFAULT), + getInt(config, TUPLE_MAX_FAILURES, TUPLE_MAX_FAILURES_DEFAULT), + getInt(config, TUPLE_FAILURE_WINDOW_SECONDS, TUPLE_FAILURE_WINDOW_SECONDS_DEFAULT), + getInt(config, MAX_ATTRIBUTE_LOOKUP_RESULTS, MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT)); + } + + /** Reads {@link #MATCH_POLICY}, defaulting to the safe {@code REJECT_AMBIGUOUS}. */ + public MultiAttributeCredentialResolver.MatchPolicy getMatchPolicy( + AuthenticatorConfigModel config) { + return MultiAttributeCredentialResolver.MatchPolicy.fromString( + getString(config, MATCH_POLICY, MATCH_POLICY_DEFAULT)); + } + int getPasswordLength(AuthenticatorConfigModel config) { return getInt(config, Utils.PASSWORD_LENGTH, Utils.PASSWORD_LENGTH_DEFAULT); } @@ -172,6 +224,78 @@ String getPasswordExpirationUserAttribute(AuthenticatorConfigModel config) { Utils.PASSWORD_EXPIRATION_USER_ATTRIBUTE_DEFAULT); } + /** + * 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 List getRealmUserProfileAttributes(KeycloakSession session) { + UserProfileProvider userProfileProvider = session.getProvider(UserProfileProvider.class); + 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())) + .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()); + } + + /** + * Reformats a date-like value into the canonical {@code YYYY-MM-DD} form user attributes are + * stored/searched in (the same format an HTML5 date input always submits), given the digit order + * it arrived in (e.g. {@code "YYYY-MM-DD"} for an HTML5 date input - a no-op reformat - or {@code + * "MMDDYYYY"} for 8 raw IVR DTMF digits). Non-digit characters in {@code rawValue} (separators) + * are stripped before reordering, so callers don't need to pre-clean input. Returns {@code + * rawValue} unchanged if its digit count doesn't match {@code sourceFormat}'s, rather than + * guessing. + */ + public String normalizeDate(String rawValue, String sourceFormat) { + if (rawValue == null) { + return null; + } + String digits = rawValue.replaceAll("[^0-9]", ""); + String formatLetters = sourceFormat.replaceAll("[^YMDymd]", "").toUpperCase(); + if (digits.length() != formatLetters.length()) { + return rawValue; + } + + StringBuilder year = new StringBuilder(); + StringBuilder month = new StringBuilder(); + StringBuilder day = new StringBuilder(); + for (int i = 0; i < formatLetters.length(); i++) { + switch (formatLetters.charAt(i)) { + case 'Y' -> year.append(digits.charAt(i)); + case 'M' -> month.append(digits.charAt(i)); + case 'D' -> day.append(digits.charAt(i)); + default -> { + // unrecognized letter in sourceFormat; ignore this position + } + } + } + return year + "-" + month + "-" + day; + } + 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/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/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/FakeSingleUseObjectProvider.java b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/FakeSingleUseObjectProvider.java new file mode 100644 index 00000000000..85fa37f3879 --- /dev/null +++ b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/FakeSingleUseObjectProvider.java @@ -0,0 +1,79 @@ +// SPDX-FileCopyrightText: 2025 Sequent Tech Inc +// +// SPDX-License-Identifier: AGPL-3.0-only + +package sequent.keycloak.authenticator.forgot_password; + +import java.util.HashMap; +import java.util.Map; +import org.keycloak.models.SingleUseObjectProvider; + +/** + * In-memory {@link SingleUseObjectProvider} test double. Real deployments back this SPI with + * Infinispan (replicated across Keycloak nodes) so entries expire after their given lifespan; this + * fake tracks a controllable virtual clock instead of wall-clock time, via {@link + * #advanceTimeSeconds}, so tuple-throttle TTL expiry can be tested deterministically without + * sleeping. + */ +final class FakeSingleUseObjectProvider implements SingleUseObjectProvider { + + private record Entry(Map notes, long expiresAtSeconds) {} + + private final Map store = new HashMap<>(); + private long nowSeconds = 0; + + void advanceTimeSeconds(long seconds) { + nowSeconds += seconds; + } + + @Override + public void put(String key, long lifespanSeconds, Map notes) { + store.put(key, new Entry(notes, nowSeconds + lifespanSeconds)); + } + + @Override + public Map get(String key) { + Entry entry = store.get(key); + if (entry == null) { + return null; + } + if (entry.expiresAtSeconds() <= nowSeconds) { + store.remove(key); + return null; + } + return entry.notes(); + } + + @Override + public Map remove(String key) { + Entry entry = store.remove(key); + return entry == null ? null : entry.notes(); + } + + @Override + public boolean replace(String key, Map notes) { + Entry existing = store.get(key); + if (existing == null || existing.expiresAtSeconds() <= nowSeconds) { + return false; + } + store.put(key, new Entry(notes, existing.expiresAtSeconds())); + return true; + } + + @Override + public boolean putIfAbsent(String key, long lifespanInSeconds) { + if (get(key) != null) { + return false; + } + store.put(key, new Entry(Map.of(), nowSeconds + lifespanInSeconds)); + return true; + } + + @Override + public boolean contains(String key) { + return get(key) != null; + } + + @Override + public void close() {} +} 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..3a67b4518ef --- /dev/null +++ b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordAuthenticatorTest.java @@ -0,0 +1,1336 @@ +// 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.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import jakarta.ws.rs.core.Response; +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.credential.hash.PasswordHashProvider; +import org.keycloak.events.EventBuilder; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.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.keycloak.representations.userprofile.config.UPAttribute; +import org.keycloak.representations.userprofile.config.UPConfig; +import org.keycloak.services.managers.BruteForceProtector; +import org.keycloak.userprofile.UserProfileProvider; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import sequent.keycloak.authenticator.forgot_password.MultiAttributeCredentialResolver.LockoutState; +import sequent.keycloak.authenticator.forgot_password.MultiAttributeCredentialResolver.Resolution; + +@ExtendWith(MockitoExtension.class) +class MultiAttributePasswordAuthenticatorTest { + + private static final int DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS = + Integer.parseInt(Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT); + + private MultiAttributePasswordAuthenticator authenticator; + + @Mock private KeycloakSession session; + @Mock private RealmModel realm; + @Mock private UserProvider userProvider; + @Mock private PasswordHashProvider passwordHashProvider; + private final FakeSingleUseObjectProvider singleUseObjects = new FakeSingleUseObjectProvider(); + + @BeforeEach + void setUp() { + authenticator = new MultiAttributePasswordAuthenticator(); + lenient().when(session.users()).thenReturn(userProvider); + lenient().when(realm.getId()).thenReturn("test-realm"); + // Every "no viable candidate" path performs a dummy password hash for timing equalization + // (see MultiAttributeCredentialResolver#performDummyHash) - stub it as a safe no-op default + // so tests that don't care about this don't need to. + lenient() + .when(session.getProvider(PasswordHashProvider.class)) + .thenReturn(passwordHashProvider); + // Per-tuple failure throttle (see MultiAttributeCredentialResolver.ThrottleConfig) needs a + // SingleUseObjectProvider for every request that reaches a valid attribute-value combination. + lenient().when(session.singleUseObjects()).thenReturn(singleUseObjects); + } + + 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.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), "correct-horse"); + + assertTrue(result.authenticatedUser().isPresent()); + assertEquals(user, result.authenticatedUser().get()); + } + + @Test + void singleAttribute_uniqueMatch_wrongPassword_fails() { + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), "wrong"); + + assertTrue(result.authenticatedUser().isEmpty()); + } + + @Test + void singleAttribute_disabledUser_fails() { + UserModel user = mockUser("user-1", "correct-horse", false); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), "correct-horse"); + + assertTrue(result.authenticatedUser().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); + + // Both attributes are ANDed together into a single store query (see + // MultiAttributeCredentialResolver.ThrottleConfig#maxAttributeLookupResults) - the store + // computes the true intersection itself, so only alice (who matches both) comes back. + when(userProvider.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", "nationalId", "ALICE-ID", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice)); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth", "nationalId"), + valuesOf("dateOfBirth", "19900101", "nationalId", "ALICE-ID"), + "alice-pw"); + + assertTrue(result.authenticatedUser().isPresent()); + assertEquals(alice, result.authenticatedUser().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.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob)); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth"), + valuesOf("dateOfBirth", "19900101"), + "alice-pw"); + + assertTrue(result.authenticatedUser().isPresent()); + assertEquals(alice, result.authenticatedUser().get()); + } + + @Test + void singleAttribute_multipleCandidates_passwordMatchesNone_fails() { + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob)); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("dateOfBirth"), valuesOf("dateOfBirth", "19900101"), "wrong"); + + assertTrue(result.authenticatedUser().isEmpty()); + } + + @Test + void singleAttribute_multipleCandidates_passwordMatchesMoreThanOne_fails() { + UserModel alice = mockUser("alice", "shared-pw", true); + UserModel bob = mockUser("bob", "shared-pw", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob)); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth"), + valuesOf("dateOfBirth", "19900101"), + "shared-pw"); + + assertTrue(result.authenticatedUser().isEmpty()); + } + + // ── Zero candidates ────────────────────────────────────────────────────── + + @Test + void noCandidates_fails() { + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "unknown", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.empty()); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "unknown"), "pw"); + + assertTrue(result.authenticatedUser().isEmpty()); + } + + @Test + void twoAttributes_disjointCandidateSets_fails() { + // No single user has both dateOfBirth=19900101 AND nationalId=BOB-ID, so the store's combined + // ANDed query returns nothing. + when(userProvider.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", "nationalId", "BOB-ID", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.empty()); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth", "nationalId"), + valuesOf("dateOfBirth", "19900101", "nationalId", "BOB-ID"), + "alice-pw"); + + assertTrue(result.authenticatedUser().isEmpty()); + } + + // ── Missing / blank submitted values ──────────────────────────────────── + + @Test + void blankSubmittedValue_failsWithoutLookup() { + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", " "), "pw"); + + assertTrue(result.authenticatedUser().isEmpty()); + } + + @Test + void missingSubmittedValue_failsWithoutLookup() { + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), Map.of(), "pw"); + + assertTrue(result.authenticatedUser().isEmpty()); + } + + @Test + void blankPassword_failsWithoutLookup() { + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), " "); + + assertTrue(result.authenticatedUser().isEmpty()); + } + + @Test + void noMatchAttributesConfigured_fails() { + Resolution result = + authenticator.resolveAuthenticatedUser(session, realm, List.of(), Map.of(), "pw"); + + assertTrue(result.authenticatedUser().isEmpty()); + } + + // ── username / email special-casing ───────────────────────────────────── + + @Test + void usernameAttribute_usesGetUserByUsername() { + UserModel user = mockUser("user-1", "pw", true); + when(userProvider.getUserByUsername(realm, "voter1")).thenReturn(user); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("username"), valuesOf("username", "voter1"), "pw"); + + assertTrue(result.authenticatedUser().isPresent()); + assertEquals(user, result.authenticatedUser().get()); + } + + @Test + void emailAttribute_usesExactSearchForUserStream() { + UserModel user = mockUser("user-1", "pw", true); + when(userProvider.searchForUserStream( + realm, + Map.of(UserModel.EMAIL, "voter1@example.com", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("email"), valuesOf("email", "voter1@example.com"), "pw"); + + assertTrue(result.authenticatedUser().isPresent()); + assertEquals(user, result.authenticatedUser().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"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob)); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("email"), valuesOf("email", "shared@example.com"), "bob-pw"); + + assertTrue(result.authenticatedUser().isPresent()); + assertEquals(bob, result.authenticatedUser().get()); + } + + // ── Brute-force attribution ────────────────────────────────────────────── + + @Test + void singleCandidate_wrongPassword_attributesFailureToCandidate() { + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), "wrong"); + + // Even though authentication failed, the single candidate is reported so the caller can + // context.setUser() it before signaling failure - otherwise Keycloak's brute-force counters + // never engage (DefaultAuthenticationFlow.processResult() -> + // AuthenticationProcessor.logFailure() + // can only find a user via authenticationSession.getAuthenticatedUser()). + assertTrue(result.attributableUser().isPresent()); + assertEquals(user, result.attributableUser().get()); + assertEquals(LockoutState.NONE, result.lockoutState()); + } + + @Test + void ambiguousCandidates_wrongPassword_doesNotAttributeFailure() { + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob)); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("dateOfBirth"), valuesOf("dateOfBirth", "19900101"), "wrong"); + + // With more than one viable candidate there is no single account a failure can honestly be + // attributed to. + assertTrue(result.attributableUser().isEmpty()); + } + + @Test + void singleCandidate_correctPassword_attributableUserIsTheAuthenticatedUser() { + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), "correct-horse"); + + assertEquals(user, result.attributableUser().orElse(null)); + } + + // ── Brute-force lockout ────────────────────────────────────────────────── + + @Mock private BruteForceProtector bruteForceProtector; + + @Test + void singleCandidate_temporarilyLockedOut_failsWithoutPasswordCheck() { + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + when(realm.isBruteForceProtected()).thenReturn(true); + when(session.getProvider(BruteForceProtector.class)).thenReturn(bruteForceProtector); + when(bruteForceProtector.isPermanentlyLockedOut(session, realm, user)).thenReturn(false); + when(bruteForceProtector.isTemporarilyDisabled(session, realm, user)).thenReturn(true); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), "correct-horse"); + + assertEquals(LockoutState.TEMPORARY, result.lockoutState()); + assertEquals(user, result.attributableUser().orElse(null)); + assertTrue(result.authenticatedUser().isEmpty()); + // No credential check should even be attempted against a locked-out account. + verify(user.credentialManager(), never()).isValid(any(CredentialInput.class)); + } + + @Test + void singleCandidate_permanentlyLockedOut_reportsPermanent() { + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + when(realm.isBruteForceProtected()).thenReturn(true); + when(session.getProvider(BruteForceProtector.class)).thenReturn(bruteForceProtector); + when(bruteForceProtector.isPermanentlyLockedOut(session, realm, user)).thenReturn(true); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), "correct-horse"); + + assertEquals(LockoutState.PERMANENT, result.lockoutState()); + } + + @Test + void multipleCandidatesAllLockedOut_ambiguous_staysGeneric() { + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob)); + when(realm.isBruteForceProtected()).thenReturn(true); + when(session.getProvider(BruteForceProtector.class)).thenReturn(bruteForceProtector); + when(bruteForceProtector.isTemporarilyDisabled(session, realm, alice)).thenReturn(true); + when(bruteForceProtector.isTemporarilyDisabled(session, realm, bob)).thenReturn(true); + // isPermanentlyLockedOut() is left unstubbed - Mockito defaults unstubbed boolean methods to + // false, which is exactly the "not permanently locked" case this test needs. + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("dateOfBirth"), valuesOf("dateOfBirth", "19900101"), "pw"); + + // Two locked-out candidates: no single account to attribute the lockout to, so this stays a + // generic failure rather than confirming "an account with this DOB is locked." + assertEquals(LockoutState.NONE, result.lockoutState()); + assertTrue(result.attributableUser().isEmpty()); + } + + @Test + void realmNotBruteForceProtected_neverConsultsProtector() { + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + // realm.isBruteForceProtected() defaults to false (unstubbed) - the protector must never be + // consulted in that case. + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), "correct-horse"); + + assertTrue(result.authenticatedUser().isPresent()); + verify(session, never()).getProvider(BruteForceProtector.class); + } + + // ── Timing side-channel: dummy hash on every early-return path ────────── + + @Test + void noCandidates_performsDummyHash() { + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "unknown", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.empty()); + + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "unknown"), "pw"); + + verify(passwordHashProvider).encodedCredential(anyString(), anyInt()); + } + + @Test + void blankSubmittedValue_performsDummyHash() { + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", " "), "pw"); + + verify(passwordHashProvider).encodedCredential(anyString(), anyInt()); + } + + @Test + void noMatchAttributesConfigured_performsDummyHash() { + authenticator.resolveAuthenticatedUser(session, realm, List.of(), Map.of(), "pw"); + + verify(passwordHashProvider).encodedCredential(anyString(), anyInt()); + } + + @Test + void singleCandidate_wrongPassword_doesNotAlsoPerformDummyHash() { + // A real candidate got a real credential check - a redundant dummy hash on top would be + // pointless extra cost, not a correctness issue, but confirms the two paths are disjoint. + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + + authenticator.resolveAuthenticatedUser( + session, realm, List.of("nationalId"), valuesOf("nationalId", "X123"), "wrong"); + + verify(passwordHashProvider, never()).encodedCredential(anyString(), anyInt()); + } + + // ── DoS mitigation: maxCandidates cap ──────────────────────────────────── + + @Test + void candidateCap_exceededCount_genericFailureWithNoPasswordChecks() { + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + UserModel carol = mockUser("carol", "carol-pw", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob, carol)); + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + new MultiAttributeCredentialResolver.ThrottleConfig(2, 10, 60); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth"), + valuesOf("dateOfBirth", "19900101"), + "alice-pw", + throttleConfig); + + assertTrue(result.authenticatedUser().isEmpty()); + assertTrue(result.attributableUser().isEmpty()); + verify(alice.credentialManager(), never()).isValid(any(CredentialInput.class)); + verify(bob.credentialManager(), never()).isValid(any(CredentialInput.class)); + verify(carol.credentialManager(), never()).isValid(any(CredentialInput.class)); + verify(passwordHashProvider).encodedCredential(anyString(), anyInt()); + } + + @Test + void candidateCap_exceededCount_neverConsultsBruteForceProtector() { + // The cap is checked against enabled candidates before any lockoutStateOf() call, so it also + // bounds the K BruteForceProtector lookups that would otherwise run for every candidate - not + // just the password hashes. + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + UserModel carol = mockUser("carol", "carol-pw", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob, carol)); + lenient().when(realm.isBruteForceProtected()).thenReturn(true); + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + new MultiAttributeCredentialResolver.ThrottleConfig(2, 10, 60); + + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth"), + valuesOf("dateOfBirth", "19900101"), + "alice-pw", + throttleConfig); + + verify(session, never()).getProvider(BruteForceProtector.class); + } + + @Test + void candidateCap_atBoundary_normalDisambiguationProceeds() { + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob)); + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + new MultiAttributeCredentialResolver.ThrottleConfig(2, 10, 60); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth"), + valuesOf("dateOfBirth", "19900101"), + "alice-pw", + throttleConfig); + + assertTrue(result.authenticatedUser().isPresent()); + assertEquals(alice, result.authenticatedUser().get()); + } + + // ── DoS mitigation: maxAttributeLookupResults ──────────────────────────── + + @Test + void attributeLookup_passesConfiguredMaxAttributeLookupResultsAsQueryLimit() { + // Bounding retrieval is the user store's job, via maxResults - a real SQL LIMIT under the + // default JPA provider - see ThrottleConfig#maxAttributeLookupResults. This verifies the + // configured ceiling is actually passed through as the combined query's maxResults: this stub + // only matches maxResults=5, so if the resolver passed a different value, the mock would + // return null and the resolver would NPE instead of authenticating. + UserModel user = mockUser("user-1", "pw", true); + when(userProvider.searchForUserStream( + realm, Map.of("dateOfBirth", "19900101", UserModel.EXACT, "true"), 0, 5)) + .thenReturn(Stream.of(user)); + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + new MultiAttributeCredentialResolver.ThrottleConfig(10, 10, 60, 5); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth"), + valuesOf("dateOfBirth", "19900101"), + "pw", + throttleConfig); + + assertTrue(result.authenticatedUser().isPresent()); + assertEquals(user, result.authenticatedUser().get()); + } + + @Test + void throttleConfig_defaults_includesMaxAttributeLookupResultsDefault() { + assertEquals( + Integer.parseInt(Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT), + MultiAttributeCredentialResolver.ThrottleConfig.defaults().maxAttributeLookupResults()); + } + + @Test + void throttleConfig_threeArgConstructor_defaultsMaxAttributeLookupResults() { + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + new MultiAttributeCredentialResolver.ThrottleConfig(10, 10, 60); + + assertEquals( + Integer.parseInt(Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT), + throttleConfig.maxAttributeLookupResults()); + } + + // ── DoS mitigation: per-tuple failure throttle ─────────────────────────── + + @Test + void tupleThrottle_maxFailuresReached_shortCircuitsWithoutUserSearch() { + // A Stream can only be consumed once - thenAnswer (rather than thenReturn) gives each of the + // multiple resolveAuthenticatedUser() calls below its own fresh stream. + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenAnswer(invocation -> Stream.of(user)); + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + new MultiAttributeCredentialResolver.ThrottleConfig(10, 2, 60); + + // Two failed attempts against the same attribute-value tuple. + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("nationalId"), + valuesOf("nationalId", "X123"), + "wrong", + throttleConfig); + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("nationalId"), + valuesOf("nationalId", "X123"), + "wrong", + throttleConfig); + + // Third attempt: the tuple is now throttled, so it must short-circuit before any user search, + // even with the correct password. + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("nationalId"), + valuesOf("nationalId", "X123"), + "correct-horse", + throttleConfig); + + assertTrue(result.authenticatedUser().isEmpty()); + assertTrue(result.attributableUser().isEmpty()); + verify(userProvider, times(2)) + .searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS); + } + + @Test + void tupleThrottle_caseVariedSubmittedValue_stillCountsTowardSameTuple() { + // The user store matches custom attributes case-insensitively (see + // MultiAttributeCredentialResolver#resolveAuthenticatedUser), so "X123" and "x123" resolve to + // the same candidate. The failure throttle must treat them as the same tuple too - otherwise + // an attacker could reset their allowance of attempts simply by varying the submitted value's + // casing, multiplying the effective attempt budget by every case permutation of the value. + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenAnswer(invocation -> Stream.of(user)); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "x123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenAnswer(invocation -> Stream.of(user)); + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + new MultiAttributeCredentialResolver.ThrottleConfig(10, 2, 60); + + // Two failed attempts against the same logical value, submitted with different casing. + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("nationalId"), + valuesOf("nationalId", "X123"), + "wrong", + throttleConfig); + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("nationalId"), + valuesOf("nationalId", "x123"), + "wrong", + throttleConfig); + + // Third attempt: the tuple must already be throttled, so it short-circuits before any user + // search, even though this exact casing was never submitted before. + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("nationalId"), + valuesOf("nationalId", "X123"), + "correct-horse", + throttleConfig); + + assertTrue(result.authenticatedUser().isEmpty()); + verify(userProvider, times(1)) + .searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS); + verify(userProvider, times(1)) + .searchForUserStream( + realm, + Map.of("nationalId", "x123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS); + } + + @Test + void tupleThrottle_windowExpires_countResets() { + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenAnswer(invocation -> Stream.of(user)); + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + new MultiAttributeCredentialResolver.ThrottleConfig(10, 1, 60); + + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("nationalId"), + valuesOf("nationalId", "X123"), + "wrong", + throttleConfig); + singleUseObjects.advanceTimeSeconds(61); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("nationalId"), + valuesOf("nationalId", "X123"), + "correct-horse", + throttleConfig); + + assertTrue(result.authenticatedUser().isPresent()); + } + + @Test + void tupleThrottle_successClearsCounter() { + UserModel user = mockUser("user-1", "correct-horse", true); + when(userProvider.searchForUserStream( + realm, + Map.of("nationalId", "X123", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenAnswer(invocation -> Stream.of(user)); + // tupleMaxFailures=2 so the single prior failure below (count=1) doesn't itself throttle the + // success attempt that follows - otherwise that attempt could never reach the credential + // check needed to prove success clears the counter. + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + new MultiAttributeCredentialResolver.ThrottleConfig(10, 2, 60); + + // One failure, then a success - the success must clear the counter so it doesn't carry over + // into the next failure and prematurely throttle it. + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("nationalId"), + valuesOf("nationalId", "X123"), + "wrong", + throttleConfig); + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("nationalId"), + valuesOf("nationalId", "X123"), + "correct-horse", + throttleConfig); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("nationalId"), + valuesOf("nationalId", "X123"), + "wrong", + throttleConfig); + + // Not throttled (the prior failure was cleared by the success in between), so this attempt + // reaches the real single-candidate path and gets attributed normally. + assertTrue(result.attributableUser().isPresent()); + assertEquals(user, result.attributableUser().get()); + } + + // ── MatchPolicy: FIRST_MATCH ───────────────────────────────────────────── + + @Test + void firstMatch_multipleCandidates_uniquePasswords_succeedsWithTheMatchingOne() { + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob)); + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + new MultiAttributeCredentialResolver.ThrottleConfig(10, 10, 60); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth"), + valuesOf("dateOfBirth", "19900101"), + "alice-pw", + throttleConfig, + MultiAttributeCredentialResolver.MatchPolicy.FIRST_MATCH); + + assertTrue(result.authenticatedUser().isPresent()); + assertEquals(alice, result.authenticatedUser().get()); + } + + @Test + void firstMatch_multipleCandidates_sharedPassword_succeedsDespiteAmbiguity() { + // The security-relevant property FIRST_MATCH trades away: alice and bob share a password, so + // this is exactly the ambiguous case REJECT_AMBIGUOUS (the default) would fail generically - + // see ambiguousCandidates_wrongPassword_doesNotAttributeFailure and + // singleAttribute_multipleCandidates_passwordMatchesMoreThanOne_fails above. FIRST_MATCH + // authenticates as whichever candidate it happens to check first instead, which is why its + // config description warns that password uniqueness across candidates is mandatory. + UserModel alice = mockUser("alice", "shared-pw", true); + UserModel bob = mockUser("bob", "shared-pw", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob)); + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + new MultiAttributeCredentialResolver.ThrottleConfig(10, 10, 60); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth"), + valuesOf("dateOfBirth", "19900101"), + "shared-pw", + throttleConfig, + MultiAttributeCredentialResolver.MatchPolicy.FIRST_MATCH); + + assertTrue(result.authenticatedUser().isPresent()); + assertTrue(List.of(alice, bob).contains(result.authenticatedUser().get())); + } + + @Test + void firstMatch_multipleCandidates_noneMatch_failsGenerically() { + UserModel alice = mockUser("alice", "alice-pw", true); + UserModel bob = mockUser("bob", "bob-pw", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dateOfBirth", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob)); + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig = + new MultiAttributeCredentialResolver.ThrottleConfig(10, 10, 60); + + Resolution result = + authenticator.resolveAuthenticatedUser( + session, + realm, + List.of("dateOfBirth"), + valuesOf("dateOfBirth", "19900101"), + "wrong", + throttleConfig, + MultiAttributeCredentialResolver.MatchPolicy.FIRST_MATCH); + + assertTrue(result.authenticatedUser().isEmpty()); + assertTrue(result.attributableUser().isEmpty()); + } + + @Test + void matchPolicy_fromString_nullOrBlank_defaultsToRejectAmbiguous() { + assertEquals( + MultiAttributeCredentialResolver.MatchPolicy.REJECT_AMBIGUOUS, + MultiAttributeCredentialResolver.MatchPolicy.fromString(null)); + assertEquals( + MultiAttributeCredentialResolver.MatchPolicy.REJECT_AMBIGUOUS, + MultiAttributeCredentialResolver.MatchPolicy.fromString(" ")); + } + + @Test + void matchPolicy_fromString_caseInsensitive() { + assertEquals( + MultiAttributeCredentialResolver.MatchPolicy.FIRST_MATCH, + MultiAttributeCredentialResolver.MatchPolicy.fromString("first_match")); + } + + @Test + void matchPolicy_fromString_unknownValue_throws() { + assertThrows( + IllegalArgumentException.class, + () -> MultiAttributeCredentialResolver.MatchPolicy.fromString("bogus")); + } + + @Test + void factory_configPropertiesIncludeMatchPolicyWithSecurityWarning() { + MultiAttributePasswordAuthenticator factory = new MultiAttributePasswordAuthenticator(); + ProviderConfigProperty matchPolicyProp = + factory.getConfigProperties().stream() + .filter(prop -> Utils.MATCH_POLICY.equals(prop.getName())) + .findFirst() + .orElse(null); + + assertTrue(matchPolicyProp != null); + assertEquals( + MultiAttributeCredentialResolver.MatchPolicy.REJECT_AMBIGUOUS.name(), + matchPolicyProp.getDefaultValue()); + assertTrue(matchPolicyProp.getOptions().contains("FIRST_MATCH")); + // The user-facing description must actually carry the safety warning, not just exist as a + // config option - this is the requirement this whole feature was added under. + assertTrue(matchPolicyProp.getHelpText().toLowerCase().contains("unique")); + } + + // ── 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); + } + + @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")); + } + + // ── Date normalization (collectSubmittedValues) ───────────────────────── + + @Test + void collectSubmittedValues_dateTypedAttribute_normalizesFromHtml5Format() { + mockUserProfileAttributes(new UPAttribute("dateOfBirth", Map.of("inputType", "html5-date"))); + MultivaluedMap formData = new MultivaluedHashMap<>(); + formData.add("dateOfBirth", "1990-01-05"); + + Map submitted = + authenticator.collectSubmittedValues(session, List.of("dateOfBirth"), formData); + + assertEquals("1990-01-05", submitted.get("dateOfBirth")); + } + + @Test + void collectSubmittedValues_nonDateAttribute_passesThroughUnchanged() { + mockUserProfileAttributes(new UPAttribute("nationalId", Map.of("inputType", "text"))); + MultivaluedMap formData = new MultivaluedHashMap<>(); + formData.add("nationalId", "X123"); + + Map submitted = + authenticator.collectSubmittedValues(session, List.of("nationalId"), formData); + + assertEquals("X123", submitted.get("nationalId")); + } + + @Test + void collectSubmittedValues_dateTypedAttribute_missingValue_staysNull() { + mockUserProfileAttributes(new UPAttribute("dateOfBirth", Map.of("inputType", "html5-date"))); + MultivaluedMap formData = new MultivaluedHashMap<>(); + + Map submitted = + authenticator.collectSubmittedValues(session, List.of("dateOfBirth"), formData); + + assertNull(submitted.get("dateOfBirth")); + } + + // ── Browser authentication-session user lifecycle ─────────────────────── + + @Test + void action_clearsStaleUserBeforeSettingResolvedUser() { + AuthenticationFlowContext context = mockActionContext(); + UserModel resolvedUser = mock(UserModel.class); + MultiAttributePasswordAuthenticator actionAuthenticator = + actionAuthenticator(Resolution.success(resolvedUser), mock(Response.class)); + + actionAuthenticator.action(context); + + InOrder inOrder = inOrder(context); + inOrder.verify(context).clearUser(); + inOrder.verify(context).setUser(resolvedUser); + inOrder.verify(context).success(); + } + + @Test + void action_failureClearsAttributedUserAfterSignalingFailure() { + AuthenticationFlowContext context = mockActionContext(); + UserModel attributableUser = mock(UserModel.class); + Response challengeResponse = mock(Response.class); + MultiAttributePasswordAuthenticator actionAuthenticator = + actionAuthenticator(Resolution.failureAttributedTo(attributableUser), challengeResponse); + + actionAuthenticator.action(context); + + InOrder inOrder = inOrder(context); + inOrder.verify(context).clearUser(); + inOrder.verify(context).setUser(attributableUser); + inOrder + .verify(context) + .failureChallenge( + org.keycloak.authentication.AuthenticationFlowError.INVALID_CREDENTIALS, + challengeResponse); + inOrder.verify(context).clearUser(); + } + + private AuthenticationFlowContext mockActionContext() { + AuthenticationFlowContext context = mock(AuthenticationFlowContext.class); + HttpRequest request = mock(HttpRequest.class); + AuthenticatorConfigModel authConfig = mock(AuthenticatorConfigModel.class); + MultivaluedMap formData = new MultivaluedHashMap<>(); + formData.add(MultiAttributePasswordAuthenticator.FIELD_PASSWORD, "pin"); + when(context.getHttpRequest()).thenReturn(request); + when(request.getDecodedFormParameters()).thenReturn(formData); + when(context.getAuthenticatorConfig()).thenReturn(authConfig); + when(authConfig.getConfig()).thenReturn(Map.of()); + when(context.getSession()).thenReturn(session); + when(context.getRealm()).thenReturn(realm); + lenient().when(context.getEvent()).thenReturn(mock(EventBuilder.class)); + return context; + } + + private MultiAttributePasswordAuthenticator actionAuthenticator( + Resolution resolution, Response challengeResponse) { + return new MultiAttributePasswordAuthenticator() { + @Override + protected Map collectSubmittedValues( + KeycloakSession session, + List matchAttributes, + MultivaluedMap formData) { + return Map.of(); + } + + @Override + protected Resolution resolveAuthenticatedUser( + KeycloakSession session, + RealmModel realm, + List matchAttributes, + Map submittedValues, + String password, + MultiAttributeCredentialResolver.ThrottleConfig throttleConfig, + MultiAttributeCredentialResolver.MatchPolicy matchPolicy) { + return resolution; + } + + @Override + protected Response challenge( + AuthenticationFlowContext context, + MultivaluedMap formData, + String error) { + return challengeResponse; + } + }; + } + + // ── Utils.normalizeDate ────────────────────────────────────────────────── + + @Test + void normalizeDate_isoFormat_isUnchanged() { + assertEquals("1990-01-05", Utils.normalizeDate("1990-01-05", "YYYY-MM-DD")); + } + + @Test + void normalizeDate_mmddyyyy_reordersToCanonical() { + assertEquals("1990-01-05", Utils.normalizeDate("01051990", "MMDDYYYY")); + } + + @Test + void normalizeDate_yyyymmddDigitsOnly_reordersToCanonical() { + assertEquals("1990-01-05", Utils.normalizeDate("19900105", "YYYYMMDD")); + } + + @Test + void normalizeDate_digitCountMismatch_returnsRawValueUnchanged() { + // Can't confidently reorder if the digit count doesn't match the declared format - fall back + // to returning the original value unchanged rather than guessing. + assertEquals("1-2-3", Utils.normalizeDate("1-2-3", "YYYY-MM-DD")); + } + + @Test + void normalizeDate_null_returnsNull() { + assertNull(Utils.normalizeDate(null, "YYYY-MM-DD")); + } + + // ── 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_configPropertiesIncludeDosMitigationOptions() { + MultiAttributePasswordAuthenticator factory = new MultiAttributePasswordAuthenticator(); + List names = + factory.getConfigProperties().stream().map(prop -> prop.getName()).toList(); + assertTrue(names.contains(Utils.MAX_CANDIDATES)); + assertTrue(names.contains(Utils.TUPLE_MAX_FAILURES)); + assertTrue(names.contains(Utils.TUPLE_FAILURE_WINDOW_SECONDS)); + assertTrue(names.contains(Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS)); + } + + @Test + void factory_isConfigurable() { + MultiAttributePasswordAuthenticator factory = new MultiAttributePasswordAuthenticator(); + assertTrue(factory.isConfigurable()); + assertFalse(factory.isUserSetupAllowed()); + } +} 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..afe948e2c01 --- /dev/null +++ b/packages/keycloak-extensions/message-otp-authenticator/src/test/java/sequent/keycloak/authenticator/forgot_password/MultiAttributePasswordDirectGrantAuthenticatorTest.java @@ -0,0 +1,515 @@ +// 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.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.inOrder; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import jakarta.ws.rs.core.MultivaluedHashMap; +import jakarta.ws.rs.core.MultivaluedMap; +import java.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.credential.hash.PasswordHashProvider; +import org.keycloak.events.EventBuilder; +import org.keycloak.http.HttpRequest; +import org.keycloak.models.AuthenticatorConfigModel; +import org.keycloak.models.KeycloakSession; +import org.keycloak.models.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.keycloak.services.managers.BruteForceProtector; +import org.mockito.InOrder; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +@ExtendWith(MockitoExtension.class) +class MultiAttributePasswordDirectGrantAuthenticatorTest { + + private static final int DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS = + Integer.parseInt(Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS_DEFAULT); + + 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; + @Mock private PasswordHashProvider passwordHashProvider; + @Mock private BruteForceProtector bruteForceProtector; + private final FakeSingleUseObjectProvider singleUseObjects = new FakeSingleUseObjectProvider(); + + @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); + lenient().when(realm.getId()).thenReturn("test-realm"); + // Every "no viable candidate" path performs a dummy password hash for timing equalization + // (see MultiAttributeCredentialResolver#performDummyHash) - stub it as a safe no-op default. + lenient() + .when(session.getProvider(PasswordHashProvider.class)) + .thenReturn(passwordHashProvider); + // Per-tuple failure throttle (see MultiAttributeCredentialResolver.ThrottleConfig) needs a + // SingleUseObjectProvider for every request that reaches a valid attribute-value combination. + lenient().when(session.singleUseObjects()).thenReturn(singleUseObjects); + } + + 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.searchForUserStream( + realm, + Map.of("dob", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + + authenticator.authenticate(context); + + InOrder inOrder = inOrder(context); + inOrder.verify(context).clearUser(); + inOrder.verify(context).setUser(user); + inOrder.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.searchForUserStream( + realm, + Map.of("dob", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .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.searchForUserStream( + realm, + Map.of("dob", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + + authenticator.authenticate(context); + + // The sole candidate is still set on the auth session before failure is signaled, even + // though the PIN was wrong - otherwise Keycloak's brute-force accounting + // (DefaultAuthenticationFlow.processResult() -> AuthenticationProcessor.logFailure()) has no + // user to attribute the failed attempt to, same as the stock ValidateUsername/ValidatePassword. + InOrder inOrder = inOrder(context); + inOrder.verify(context).clearUser(); + inOrder.verify(context).setUser(user); + inOrder.verify(context).failure(any(), any()); + verify(context, never()).success(); + } + + @Test + void dobAndPin_multipleCandidates_wrongPin_doesNotSetUser() { + // Ambiguous candidates: no single account a failure can honestly be attributed to. + ivrConfig("dob##pin", "8##8", "identifier##secret", "dob##password"); + formParams("dob", "19900101", "password", "wrong"); + UserModel alice = mockUser("alice", "alice-pin", true); + UserModel bob = mockUser("bob", "bob-pin", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dob", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob)); + + authenticator.authenticate(context); + + verify(context, never()).setUser(any()); + 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.searchForUserStream( + realm, + Map.of("dob", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .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()); + } + + @Test + void multipleSecretKindEntries_fails() { + // Two "secret" entries - ambiguous which one is the real PIN field. Must be rejected rather + // than silently using whichever entry happens to be last, which would silently ignore + // whatever the admin intended the other "secret" entry to do. + Map config = new HashMap<>(); + config.put("field", "dob##pin##backupPin"); + config.put("max_digits", "8##8##8"); + config.put("kind", "identifier##secret##secret"); + config.put("maps_to", "dob##password##backupPassword"); + lenient().when(authConfig.getConfig()).thenReturn(config); + formParams("dob", "19900101", "password", "1234", "backupPassword", "5678"); + + authenticator.authenticate(context); + + verify(context, never()).setUser(any()); + verify(context).failure(any(), any()); + verify(userProvider, never()) + .searchForUserStream(any(RealmModel.class), any(Map.class), any(), any()); + } + + // ── Brute-force lockout ────────────────────────────────────────────────── + + @Test + void dobAndPin_temporarilyLockedOut_forceChallengesWithoutPinCheck() { + ivrConfig("dob##pin", "8##8", "identifier##secret", "dob##password"); + formParams("dob", "19900101", "password", "1234"); + UserModel user = mockUser("user-1", "1234", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dob", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + when(realm.isBruteForceProtected()).thenReturn(true); + when(session.getProvider(BruteForceProtector.class)).thenReturn(bruteForceProtector); + when(bruteForceProtector.isTemporarilyDisabled(session, realm, user)).thenReturn(true); + + authenticator.authenticate(context); + + verify(context).setUser(user); + verify(context, never()).success(); + // Locked-out accounts use forceChallenge (matching the stock ValidateUsername authenticator's + // brute-force handling), not failure() - the flow engine only logs another brute-force + // failure for FAILED/FAILURE_CHALLENGE results, and this account is already locked out. + verify(context, never()).failure(any(), any()); + verify(context).forceChallenge(any()); + verify(user.credentialManager(), never()).isValid(any(CredentialInput.class)); + } + + // ── Timing side-channel ────────────────────────────────────────────────── + + @Test + void dobAndPin_noCandidates_performsDummyHash() { + ivrConfig("dob##pin", "8##8", "identifier##secret", "dob##password"); + formParams("dob", "19900101", "password", "1234"); + when(userProvider.searchForUserStream( + realm, + Map.of("dob", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.empty()); + + authenticator.authenticate(context); + + verify(passwordHashProvider).encodedCredential(anyString(), anyInt()); + } + + // ── Date normalization (date_format) ──────────────────────────────────── + + @Test + void dobAndPin_ivrDateFormat_normalizesToCanonicalBeforeLookup() { + Map config = new HashMap<>(); + config.put("field", "dob##pin"); + config.put("max_digits", "8##8"); + config.put("kind", "identifier##secret"); + config.put("maps_to", "dob##password"); + config.put("date_format", "MMDDYYYY"); + lenient().when(authConfig.getConfig()).thenReturn(config); + // IVR collects raw digits in MMDDYYYY order; the stored attribute is canonical YYYY-MM-DD. + formParams("dob", "01051990", "password", "1234"); + UserModel user = mockUser("user-1", "1234", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dob", "1990-01-05", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + + authenticator.authenticate(context); + + verify(context).setUser(user); + verify(context).success(); + } + + // ── DoS mitigation ──────────────────────────────────────────────────────── + + @Test + void dobAndPin_candidateCapExceeded_genericFailureNoPinChecks() { + Map config = new HashMap<>(); + config.put("field", "dob##pin"); + config.put("max_digits", "8##8"); + config.put("kind", "identifier##secret"); + config.put("maps_to", "dob##password"); + config.put("maxCandidates", "2"); + lenient().when(authConfig.getConfig()).thenReturn(config); + formParams("dob", "19900101", "password", "alice-pin"); + UserModel alice = mockUser("alice", "alice-pin", true); + UserModel bob = mockUser("bob", "bob-pin", true); + UserModel carol = mockUser("carol", "carol-pin", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dob", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob, carol)); + + authenticator.authenticate(context); + + verify(context, never()).setUser(any()); + verify(context, never()).success(); + verify(alice.credentialManager(), never()).isValid(any(CredentialInput.class)); + verify(bob.credentialManager(), never()).isValid(any(CredentialInput.class)); + verify(carol.credentialManager(), never()).isValid(any(CredentialInput.class)); + } + + @Test + void dobAndPin_tupleThrottled_shortCircuitsWithoutUserSearchOnRepeatedAttempts() { + Map config = new HashMap<>(); + config.put("field", "dob##pin"); + config.put("max_digits", "8##8"); + config.put("kind", "identifier##secret"); + config.put("maps_to", "dob##password"); + config.put("tupleMaxFailures", "1"); + lenient().when(authConfig.getConfig()).thenReturn(config); + formParams("dob", "19900101", "password", "wrong"); + UserModel user = mockUser("user-1", "1234", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dob", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(user)); + + authenticator.authenticate(context); // 1st failure - counted against the tuple. + authenticator.authenticate(context); // 2nd attempt - the tuple is now throttled. + + verify(userProvider, times(1)) + .searchForUserStream( + realm, + Map.of("dob", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS); + } + + @Test + void dobAndPin_attributeLookupPassesConfiguredMaxAttributeLookupResultsAsQueryLimit() { + // Same end-to-end concern as the browser form: bounding retrieval is the user store's job, + // via maxResults - a real SQL LIMIT under the default JPA provider - see + // MultiAttributeCredentialResolver.ThrottleConfig#maxAttributeLookupResults. This verifies + // the configured ceiling is actually passed through end-to-end as the combined query's + // maxResults: the stub only matches maxResults=5, so if the resolver passed a different + // value, the mock would return null and authenticate() would fail instead of succeeding. + Map config = new HashMap<>(); + config.put("field", "dob##pin"); + config.put("max_digits", "8##8"); + config.put("kind", "identifier##secret"); + config.put("maps_to", "dob##password"); + config.put("maxAttributeLookupResults", "5"); + lenient().when(authConfig.getConfig()).thenReturn(config); + formParams("dob", "19900101", "password", "1234"); + UserModel user = mockUser("user-1", "1234", true); + when(userProvider.searchForUserStream( + realm, Map.of("dob", "19900101", UserModel.EXACT, "true"), 0, 5)) + .thenReturn(Stream.of(user)); + + authenticator.authenticate(context); + + verify(context).setUser(user); + verify(context).success(); + } + + // ── MatchPolicy: FIRST_MATCH ───────────────────────────────────────────── + + @Test + void dobAndPin_firstMatch_sharedPin_authenticatesDespiteAmbiguity() { + // Same security tradeoff as the browser form: with FIRST_MATCH, alice and bob sharing a PIN + // no longer generically fails (as REJECT_AMBIGUOUS, the default, would - see + // dobAndPin_multipleCandidates_wrongPin_doesNotSetUser above) but authenticates as whichever + // of them is checked first. This is exactly why the config warns PIN uniqueness is mandatory. + Map config = new HashMap<>(); + config.put("field", "dob##pin"); + config.put("max_digits", "8##8"); + config.put("kind", "identifier##secret"); + config.put("maps_to", "dob##password"); + config.put("matchPolicy", "FIRST_MATCH"); + lenient().when(authConfig.getConfig()).thenReturn(config); + formParams("dob", "19900101", "password", "shared-pin"); + UserModel alice = mockUser("alice", "shared-pin", true); + UserModel bob = mockUser("bob", "shared-pin", true); + when(userProvider.searchForUserStream( + realm, + Map.of("dob", "19900101", UserModel.EXACT, "true"), + 0, + DEFAULT_MAX_ATTRIBUTE_LOOKUP_RESULTS)) + .thenReturn(Stream.of(alice, bob)); + + authenticator.authenticate(context); + + verify(context).success(); + verify(context).setUser(argThat(user -> user == alice || user == bob)); + } + + // ── 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"))); + } + + @Test + void factory_configPropertiesIncludeDosMitigationOptions() { + List props = authenticator.getConfigProperties(); + List names = props.stream().map(ProviderConfigProperty::getName).toList(); + assertTrue( + names.containsAll( + List.of( + Utils.MAX_CANDIDATES, + Utils.TUPLE_MAX_FAILURES, + Utils.TUPLE_FAILURE_WINDOW_SECONDS, + Utils.MAX_ATTRIBUTE_LOOKUP_RESULTS))); + } + + @Test + void factory_configPropertiesIncludeMatchPolicyWithSecurityWarning() { + ProviderConfigProperty matchPolicyProp = + authenticator.getConfigProperties().stream() + .filter(prop -> Utils.MATCH_POLICY.equals(prop.getName())) + .findFirst() + .orElse(null); + + assertTrue(matchPolicyProp != null); + assertEquals( + MultiAttributeCredentialResolver.MatchPolicy.REJECT_AMBIGUOUS.name(), + matchPolicyProp.getDefaultValue()); + assertTrue(matchPolicyProp.getOptions().contains("FIRST_MATCH")); + assertTrue(matchPolicyProp.getHelpText().toLowerCase().contains("unique")); + } +} 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..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,33 +13,61 @@ 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> + <#include "intl-tel-input.ftl"> + + <#list matchAttributes as field> +
+ - + <#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" + /> + +
+ <#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} + + + +
+
-