-
Notifications
You must be signed in to change notification settings - Fork 846
Add an optional consent modal before login #3792
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
ab72108
Add an optional consent modal before login
duanemay b313ea3
Incorporate feedback
duanemay 4df5c46
Add configurable declineButtonText
duanemay 01de39e
Prevent hash collisions by using length-prefixed encoding
duanemay bd51ac0
Document loginConsent configuration options in UAA-Configuration-Refe…
duanemay 602fb34
Clarify client-side only in loginConsent documentation
duanemay File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,163 @@ | ||
| # Login Consent | ||
|
|
||
| The Login Consent feature displays a mandatory modal on the UAA login page. Users must accept the displayed terms/notice (or follow a decline link) before they can interact with the login form. This is **separate from** the OAuth approval/consent flow; it is a pre-login notice (e.g., terms of use or acceptable use policy) configured per identity zone. | ||
|
|
||
| **Important**: This is a **UI-only verified consent** mechanism. The consent acceptance is tracked client-side via cookies and is not verified or enforced on the server side during authentication. It serves as a notice/acknowledgment interface rather than a security control. | ||
|
|
||
| ## Overview | ||
|
|
||
| - **Scope**: Configured per identity zone under `branding.loginConsent`. | ||
| - **Behavior**: When enabled, the login page shows a modal with a configurable title, HTML body text, an “Accept” button, and an optional “Decline” link. Consent state is remembered in a cookie for a configurable duration; if the consent text changes or the duration expires, the modal is shown again. | ||
| - **Implementation**: Server provides config and a content hash; the browser shows/hides the modal and sets a cookie (`UAA-Login-Consent`) with format `hash:timestamp` to avoid re-prompting until content or duration changes. | ||
| - **Verification Level**: **Client-side only** - consent acceptance is tracked via browser cookies and is not validated during server-side authentication flows. | ||
|
|
||
| --- | ||
|
|
||
| ## Configuration | ||
|
|
||
| Login consent is part of zone branding. Example (e.g. in `uaa.yml` or zone config API): | ||
|
|
||
| ```yaml | ||
| branding: | ||
| loginConsent: | ||
| enabled: true | ||
| title: "Notice and Consent" | ||
| text: | | ||
| <p>You are accessing a site that is provided for authorized use only.</p> | ||
| <p>By using this site, you consent to the following conditions:</p> | ||
| <ul> | ||
| <li>You are responsible for compliance with all applicable laws and regulations.</li> | ||
| <li>All communications are subject to routine monitoring and may be disclosed.</li> | ||
| </ul> | ||
| acceptButtonText: "I Accept" | ||
| declineButtonText: "Decline" # text for decline button when declineLink is provided | ||
| declineLink: https://example.com/decline # optional; if absent, a Cancel button is shown | ||
| consentValidDuration: "15m" # how long acceptance is remembered | ||
| ``` | ||
|
|
||
| ### Configuration Fields | ||
|
|
||
| | Field | Type | Default | Description | | ||
| |-------|------|---------|-------------| | ||
| | `enabled` | boolean | `false` | When `true`, the login consent modal is active for the zone. | | ||
| | `title` | string | `"Terms and Conditions"` | Modal heading. Has default value but must not be empty if explicitly set. | | ||
| | `text` | string | — | Body content; **HTML is allowed** (rendered with `th:utext`). Required when enabled. | | ||
| | `acceptButtonText` | string | `"I Agree"` | Label for the accept button. Has default value but must not be empty if explicitly set. | | ||
| | `declineButtonText` | string | `"Decline"` | Label for the decline button when `declineLink` is provided. Has default value but must not be empty if explicitly set. | | ||
| | `declineLink` | string | — | Optional. If set, must be a valid `http` or `https` URL; shown as a “Decline” link. If unset, a “Cancel” button is shown (modal can be re-shown on next load). | | ||
| | `consentValidDuration` | string | `"1d"` | How long acceptance is remembered. After this period, or if consent text changes, the modal is shown again. | | ||
|
|
||
| ### Duration Format | ||
|
|
||
| `consentValidDuration` supports: | ||
|
|
||
| - **`0`** – Always show the modal (do not remember acceptance). | ||
| - **`<number><unit>`** – Remember acceptance for the given amount of time. | ||
| Units: `m` (minutes), `h` (hours), `d` (days), `w` (weeks), `y` (years). | ||
| Examples: `15m`, `12h`, `7d`, `1w`. | ||
|
|
||
| Invalid or missing duration falls back to 24 hours. | ||
|
|
||
| --- | ||
|
|
||
| ## Validation | ||
|
|
||
| `LoginConsentValidator` is used when zone configuration is saved (e.g. via `GeneralIdentityZoneConfigurationValidator`): | ||
|
|
||
| - If `loginConsent` is `null` or `enabled` is `false`, no validation is applied. | ||
| - When **enabled**: | ||
| - `text` is **required** (no default value). | ||
| - `title`, `acceptButtonText`, and `declineButtonText` must not be empty if explicitly set (they have default values). | ||
| - If present, `declineLink` must be a valid HTTP/HTTPS URL. | ||
| - If present, `consentValidDuration` must be `0` or a positive number followed by `m`, `h`, `d`, or `w`. | ||
|
|
||
| Errors are reported as part of zone config validation (e.g. `InvalidIdentityZoneConfigurationException`). | ||
|
|
||
| --- | ||
|
|
||
| ## Server-Side Behavior | ||
|
|
||
| ### Model and placement | ||
|
|
||
| - **Model**: `org.cloudfoundry.identity.uaa.zone.LoginConsent` (under `model`). | ||
| - **Zone config**: `IdentityZoneConfiguration` → `BrandingInformation` → `LoginConsent` (`getLoginConsent()` / `setLoginConsent()`). | ||
|
|
||
| ### LoginInfoEndpoint | ||
|
|
||
| When rendering the login page (HTML), `LoginInfoEndpoint` calls `addLoginConsentToModel(model)`: | ||
|
|
||
| 1. Reads `zone.getConfig().getBranding().getLoginConsent()`. | ||
| 2. If present and `enabled`: | ||
| - Adds `loginConsent` (the config object) to the model. | ||
| - Adds `loginConsentHash` via `LoginConsentHashUtil.calculateConsentHash(loginConsent)` (SHA-256 of `title|text`). | ||
| - Adds `loginConsentDurationSeconds` via `LoginConsentHashUtil.parseDurationToSeconds(consentValidDuration)`. | ||
|
|
||
| These attributes are used by the login template and the client-side script. | ||
|
|
||
| ### Consent hash | ||
|
|
||
| `LoginConsentHashUtil.calculateConsentHash(LoginConsent)`: | ||
|
|
||
| - Returns `null` if consent is `null` or disabled. | ||
| - Otherwise computes SHA-256 of `title + "|" + text` (empty string if null) and returns the hex string. | ||
| - Used to detect when consent text has changed so the modal can be shown again even if the cookie is still within its duration. | ||
|
|
||
| --- | ||
|
|
||
| ## Frontend Behavior | ||
|
|
||
| ### Template (login.html) | ||
|
|
||
| - If `loginConsent != null`, the page includes: | ||
| - `login-consent.css` | ||
| - `consent.js` | ||
| - A modal element `#loginConsentModal` is rendered with: | ||
| - `data-consent-hash`: current consent hash | ||
| - `data-consent-duration`: duration in seconds (for cookie `max-age`) | ||
| - Title from `loginConsent.title`, body from `loginConsent.text` (HTML), and buttons from `acceptButtonText` and `declineButtonText` (when `declineLink` is provided) or a Cancel button. | ||
|
|
||
| ### Cookie | ||
|
|
||
| - **Name**: `UAA-Login-Consent` | ||
| - **Value**: `hash:timestamp` (e.g. `abc123...:1710000000`) | ||
| - **Path**: Same as the login page path (e.g. `/login` or `/uaa/login`). | ||
| - **Attributes**: `SameSite=Strict`, `Secure` on HTTPS. `max-age` set from `loginConsentDurationSeconds` when duration > 0; no `max-age` when duration is 0. | ||
|
|
||
| ### consent.js | ||
|
|
||
| - On `DOMContentLoaded`: | ||
| - **Should show modal?** | ||
| Yes if: no modal element, or duration is `0`, or no cookie, or cookie value is not `hash:timestamp`, or stored hash ≠ current `data-consent-hash`, or `(now - timestamp) > consentDuration` (when duration > 0). | ||
| - If the modal should be shown: | ||
| - Binds Accept button to set cookie (when duration > 0) and close modal. | ||
| - Cancel/Decline: Cancel closes and re-opens modal after 100 ms (user cannot proceed without accepting or using the decline link). Decline uses `declineLink` to leave the page. | ||
| - Prevents closing via overlay click or Escape. | ||
| - Modal is shown with `display: flex` and focus moved to the accept button. | ||
|
|
||
| So the modal is blocking until the user either accepts (and optionally gets a cookie) or follows the decline link. | ||
|
|
||
| --- | ||
|
|
||
| ## Files Involved | ||
|
|
||
| | Area | File | Role | | ||
| |------|------|------| | ||
| | Model | `model/.../zone/LoginConsent.java` | Config POJO for login consent. | | ||
| | Model | `model/.../zone/BrandingInformation.java` | Holds `loginConsent`. | | ||
| | Server | `server/.../login/LoginInfoEndpoint.java` | Adds consent config and hash/duration to login model. | | ||
| | Server | `server/.../login/LoginConsentHashUtil.java` | Consent hash (SHA-256 of title+text) and duration parsing. | | ||
| | Server | `server/.../zone/LoginConsentValidator.java` | Validates `LoginConsent` when enabled. | | ||
| | Server | `server/.../zone/GeneralIdentityZoneConfigurationValidator.java` | Invokes `LoginConsentValidator` for zone config. | | ||
| | Server | `server/.../templates/web/login.html` | Renders consent modal and includes CSS/JS when `loginConsent != null`. | | ||
| | Webapp | `uaa/.../javascripts/login_consent/consent.js` | Modal show/hide logic and cookie handling. | | ||
| | Webapp | `uaa/.../stylesheets/login-consent.css` | Styles for the consent modal (including responsive and a11y). | | ||
|
|
||
| --- | ||
|
|
||
| ## Security and UX Notes | ||
|
|
||
| - **Client-side verification only**: This feature provides UI-level consent tracking but does not enforce consent at the server authentication level. Do not rely on this mechanism for security-critical consent verification. | ||
| - Consent text is rendered as HTML (`th:utext`); only trusted, zone-controlled content should be used to avoid XSS. | ||
| - The cookie is used only to avoid re-prompting; it does not by itself grant access. Authentication still requires successful login. | ||
| - Decline link should point to a safe, zone-approved URL (e.g. exit or policy page). | ||
| - Modal is non-dismissible (no overlay click or Escape) except via Accept or Decline/Cancel, so users cannot bypass the notice. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
35 changes: 35 additions & 0 deletions
35
model/src/main/java/org/cloudfoundry/identity/uaa/zone/LoginConsent.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package org.cloudfoundry.identity.uaa.zone; | ||
|
|
||
| import com.fasterxml.jackson.annotation.JsonIgnoreProperties; | ||
| import com.fasterxml.jackson.annotation.JsonInclude; | ||
| import lombok.Data; | ||
|
|
||
| /** | ||
| * Configuration for the login consent modal that can be displayed on the login page. | ||
| * This is separate from the OAuth approval consent feature. | ||
| */ | ||
| @Data | ||
| @JsonInclude(JsonInclude.Include.NON_NULL) | ||
| @JsonIgnoreProperties(ignoreUnknown = true) | ||
| public class LoginConsent { | ||
| private boolean enabled = false; | ||
| private String title = "Terms and Conditions"; | ||
| private String text; | ||
| private String acceptButtonText = "I Agree"; | ||
| private String declineButtonText = "Decline"; | ||
| private String declineLink; | ||
| private String consentValidDuration = "1d"; | ||
|
|
||
| public LoginConsent() { | ||
| } | ||
|
|
||
| public LoginConsent(boolean enabled, String title, String text, String acceptButtonText, String declineButtonText, String declineLink, String consentValidDuration) { | ||
| this.enabled = enabled; | ||
| this.title = title; | ||
| this.text = text; | ||
| this.acceptButtonText = acceptButtonText; | ||
| this.declineButtonText = declineButtonText; | ||
| this.declineLink = declineLink; | ||
| this.consentValidDuration = consentValidDuration; | ||
| } | ||
| } |
156 changes: 156 additions & 0 deletions
156
model/src/test/java/org/cloudfoundry/identity/uaa/zone/LoginConsentTest.java
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,156 @@ | ||
| package org.cloudfoundry.identity.uaa.zone; | ||
|
|
||
| import com.fasterxml.jackson.databind.ObjectMapper; | ||
| import org.junit.jupiter.api.Test; | ||
|
|
||
| import static org.assertj.core.api.Assertions.assertThat; | ||
|
|
||
| class LoginConsentTest { | ||
|
|
||
| private final ObjectMapper objectMapper = new ObjectMapper(); | ||
|
|
||
| @Test | ||
| void testDefaultConstructor() { | ||
| LoginConsent consent = new LoginConsent(); | ||
| assertThat(consent.isEnabled()).isFalse(); | ||
| assertThat(consent.getTitle()).isNotNull(); | ||
| assertThat(consent.getText()).isNull(); | ||
| assertThat(consent.getAcceptButtonText()).isNotNull(); | ||
| assertThat(consent.getDeclineButtonText()).isNotNull(); | ||
| assertThat(consent.getDeclineLink()).isNull(); | ||
| assertThat(consent.getConsentValidDuration()).isNotNull(); | ||
| } | ||
|
|
||
| @Test | ||
| void testFullConstructor() { | ||
| LoginConsent consent = new LoginConsent( | ||
| true, | ||
| "Test Title", | ||
| "Test Text", | ||
| "Accept", | ||
| "Decline", | ||
| "https://example.com", | ||
| "12h" | ||
| ); | ||
|
|
||
| assertThat(consent.isEnabled()).isTrue(); | ||
| assertThat(consent.getTitle()).isEqualTo("Test Title"); | ||
| assertThat(consent.getText()).isEqualTo("Test Text"); | ||
| assertThat(consent.getAcceptButtonText()).isEqualTo("Accept"); | ||
| assertThat(consent.getDeclineButtonText()).isEqualTo("Decline"); | ||
| assertThat(consent.getDeclineLink()).isEqualTo("https://example.com"); | ||
| assertThat(consent.getConsentValidDuration()).isEqualTo("12h"); | ||
| } | ||
|
|
||
| @Test | ||
| void testSettersAndGetters() { | ||
| LoginConsent consent = new LoginConsent(); | ||
|
|
||
| consent.setEnabled(true); | ||
| consent.setTitle("Notice"); | ||
| consent.setText("You are accessing a system for authorized use only"); | ||
| consent.setAcceptButtonText("I Accept"); | ||
| consent.setDeclineButtonText("No Thanks"); | ||
| consent.setDeclineLink("https://www.cloudfoundry.org"); | ||
| consent.setConsentValidDuration("24h"); | ||
|
|
||
| assertThat(consent.isEnabled()).isTrue(); | ||
| assertThat(consent.getTitle()).isEqualTo("Notice"); | ||
| assertThat(consent.getText()).isEqualTo("You are accessing a system for authorized use only"); | ||
| assertThat(consent.getAcceptButtonText()).isEqualTo("I Accept"); | ||
| assertThat(consent.getDeclineButtonText()).isEqualTo("No Thanks"); | ||
| assertThat(consent.getDeclineLink()).isEqualTo("https://www.cloudfoundry.org"); | ||
| assertThat(consent.getConsentValidDuration()).isEqualTo("24h"); | ||
| } | ||
|
|
||
| @Test | ||
| void testJsonSerialization() throws Exception { | ||
| LoginConsent consent = new LoginConsent( | ||
| true, | ||
| "Test Title", | ||
| "Test Text", | ||
| "Accept", | ||
| "Decline", | ||
| "https://example.com", | ||
| "12h" | ||
| ); | ||
|
|
||
| String json = objectMapper.writeValueAsString(consent); | ||
| assertThat(json).isNotNull() | ||
| .contains("\"enabled\":true") | ||
| .contains("\"title\":\"Test Title\"") | ||
| .contains("\"text\":\"Test Text\""); | ||
| } | ||
|
|
||
| @Test | ||
| void testJsonDeserialization() throws Exception { | ||
| String json = """ | ||
| { | ||
| "enabled": true, | ||
| "title": "Test Title", | ||
| "text": "Test Text", | ||
| "acceptButtonText": "Accept", | ||
| "declineButtonText": "Decline", | ||
| "declineLink": "https://example.com", | ||
| "consentValidDuration": "12h" | ||
| } | ||
| """; | ||
|
|
||
| LoginConsent consent = objectMapper.readValue(json, LoginConsent.class); | ||
|
|
||
| assertThat(consent.isEnabled()).isTrue(); | ||
| assertThat(consent.getTitle()).isEqualTo("Test Title"); | ||
| assertThat(consent.getText()).isEqualTo("Test Text"); | ||
| assertThat(consent.getAcceptButtonText()).isEqualTo("Accept"); | ||
| assertThat(consent.getDeclineButtonText()).isEqualTo("Decline"); | ||
| assertThat(consent.getDeclineLink()).isEqualTo("https://example.com"); | ||
| assertThat(consent.getConsentValidDuration()).isEqualTo("12h"); | ||
| } | ||
|
|
||
| @Test | ||
| void testJsonSerializationWithNulls() throws Exception { | ||
| LoginConsent consent = new LoginConsent(); | ||
| consent.setEnabled(true); | ||
| consent.setTitle("Title"); | ||
| consent.setText("Text"); | ||
|
|
||
| String json = objectMapper.writeValueAsString(consent); | ||
| assertThat(json).isNotNull() | ||
| // Non-null fields should be included | ||
| .contains("\"enabled\":true") | ||
| .contains("\"title\":\"Title\"") | ||
| // Null fields should not be included (JsonInclude.Include.NON_NULL) | ||
| .doesNotContain("\"declineLink\""); | ||
| } | ||
|
|
||
| @Test | ||
| void testEquals() { | ||
| LoginConsent consent1 = new LoginConsent(true, "Title", "Text", "Accept", "Decline", "https://example.com", "12h"); | ||
| LoginConsent consent2 = new LoginConsent(true, "Title", "Text", "Accept", "Decline", "https://example.com", "12h"); | ||
| LoginConsent consent3 = new LoginConsent(true, "Different", "Text", "Accept", "Decline", "https://example.com", "12h"); | ||
|
|
||
| assertThat(consent1).isNotNull() | ||
| .isEqualTo(consent2) | ||
| .isNotEqualTo(consent3) | ||
| .isNotEqualTo(new Object()); | ||
| } | ||
|
|
||
| @Test | ||
| void testHashCode() { | ||
| LoginConsent consent1 = new LoginConsent(true, "Title", "Text", "Accept", "Decline", "https://example.com", "12h"); | ||
| LoginConsent consent2 = new LoginConsent(true, "Title", "Text", "Accept", "Decline", "https://example.com", "12h"); | ||
|
|
||
| assertThat(consent2).hasSameHashCodeAs(consent1); | ||
| } | ||
|
|
||
| @Test | ||
| void testToString() { | ||
| LoginConsent consent = new LoginConsent(true, "Title", "Test Text", "Accept", "Decline", "https://example.com", "12h"); | ||
| String toString = consent.toString(); | ||
|
|
||
| assertThat(toString).isNotNull() | ||
| .contains("enabled=true") | ||
| .contains("title=Title") | ||
| .contains("acceptButtonText=Accept"); | ||
| } | ||
| } |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.