diff --git a/docs/UAA-APIs.rst b/docs/UAA-APIs.rst index 4835dfd30e8..bfb8adaa1f2 100644 --- a/docs/UAA-APIs.rst +++ b/docs/UAA-APIs.rst @@ -2124,6 +2124,10 @@ Change Email Flow: "email": "example@newemail.com" } +* ``redirect_url`` is the requested ``redirect_uri`` when it matches one of the client's registered redirect URIs. + Otherwise, it is the value of the client's ``change_email_redirect_url`` attribute, or ``null`` when the client has + no such attribute or no client was given. + * Response Codes:: 200 - OK @@ -2136,6 +2140,55 @@ Change Email Flow: $ curl 'http://localhost:8080/email_changes' -i -X POST -H 'Content-Type: application/json' -d 6FfI4o +Create a Code: ``POST /Codes`` +------------------------------ + +Stores a one time code and returns it. Codes are stored per identity zone and can be read back once with ``GET /Codes/{code}``. + +* Request: ``POST /Codes`` + +* Authorization: a token with the scope ``oauth.login`` or a UAA admin token. + +* Request Body:: + + { + "data": "{\"user_id\":\"a-user-id\"}", + "expiresAt": 4102444800000, + "intent": "MY_APPLICATION_CODE" + } + + ``data`` (required) is stored with the code, as a string. ``expiresAt`` (required) is milliseconds since the epoch and must be in the future. + ``intent`` (required, not blank) says what the code may be used for. + +* Intents: + + The UAA flows accept a code only when its intent matches the flow, and the match is case sensitive: + + * ``EMAIL``: email change verification (``POST /email_changes`` and ``/verify_email``) + * ``INVITATION``: invitation acceptance (``/invitations/accept``) + * ``REGISTRATION``: account activation (``/verify_user``) + + Any other value is stored as given; use it for codes that your own application creates and redeems. + Intents that the UAA creates for its own flows (for example the ones for password reset, autologin and passcodes) are also stored as given. + Codes stored without an intent before this requirement was added remain redeemable until they expire. + +* Response Codes:: + + 201 - Created + 400 - Bad Request (the intent is missing or blank, data or expiresAt are missing, or expiresAt is in the past) + 401 - Unauthorized + 403 - Forbidden + +* Error message for a missing intent:: + + intent is required. Known intents: EMAIL, INVITATION, REGISTRATION; custom values are accepted. + +* Example CURL :: + + $ curl 'http://localhost:8080/Codes' -i -X POST -H 'Content-Type: application/json' -H 'Authorization: Bearer ' \ + -d '{"data":"{}","expiresAt":4102444800000,"intent":"MY_APPLICATION_CODE"}' + + Verify User Links: ``GET /Users/{id}/verify-link`` -------------------------------------------------- @@ -2144,8 +2197,8 @@ Verify User Links: ``GET /Users/{id}/verify-link`` * Request Parameters:: - client_id; the id of the client requesting the verification link (optional) - redirect_uri; the eventual URI that will be redirected after the user finishes resetting their password (optional) + client_id; the id of the client requesting the verification link (optional; defaults to the client id from the OAuth2 bearer token when available) + redirect_uri; the eventual URI that will be redirected after the user finishes resetting their password (required) the redirect_uri whitelist that can be set per client accepts wildcards, and matches against this redirect_uri using Ant-style path pattern matching (as per ) * Request Headers: Authorization header containing an `OAuth2`_ bearer token with:: @@ -2353,6 +2406,28 @@ The redirect_uri will be validated against allowed redirect_uri for the client. "failed_invites":[] } +.. note:: + + **Verified users and** ``scim.invite.allow_reinvite`` + + By default an email address that belongs to a verified user is invited like any other address: the response + contains the user's ``userId`` and a new ``inviteLink``. When the server sets ``scim.invite.allow_reinvite: false`` + and the token has ``scim.invite`` but may not read users (``GET /Users`` would refuse it, for example because it has + neither ``scim.read`` nor the zone administrator scope), the address is reported in ``failed_invites`` instead. No + user id or link is returned and no invitation is created:: + + { + "new_invites":[], + "failed_invites":[ + {"email":"user1@cqv4f7.com","userId":null,"origin":null,"success":false,"errorCode":"user.already-verified","errorMessage":"The user is already verified.","inviteLink":null} + ] + } + + The other addresses of the request are processed as usual, and users who are not verified yet, as well as new + users, are always invited. A client that re-invites existing users and needs the user id or the link should hold + ``scim.read`` before the property is set to ``false``. See ``scim.invite.allow_reinvite`` in + ``docs/UAA-Configuration-Reference.md``. + .. note:: **Existing unverified UAA-origin users and password handling** @@ -2372,6 +2447,26 @@ The redirect_uri will be validated against allowed redirect_uri for the client. Newly created users (first-time invitations where the account did not exist before) are unaffected: the supplied password is set normally. +.. note:: + + **Accepting an invitation through an external identity provider** + + When the invited user accepts the invitation by signing in at a SAML or OIDC provider, the response of the provider + must carry an email address, and it must match the invited email address (the comparison ignores case). A sign-in that + carries a different address is rejected. + + * SAML: if the assertion has no email attribute, there is no address to compare. UAA then accepts the sign-in only + when the invited account is still unverified and its username equals the invited email address. That is the state + of an account created by an invitation that has not been accepted yet. Any other account is rejected: one that is + verified, or one whose username differs from its email address. + + UAA does not record whether the invitation created the account, so an existing account in the same state is + accepted too. UAA marks a SAML user verified only when the provider's ``email_verified`` attribute says so. To have + every acceptance compared with the invited address, map the ``email`` attribute for the provider. + * OIDC: if the token has no email claim, UAA derives an address from the username claim. That address has to match + the invited email address as well, so a token without an email claim only works when the username claim equals the + invited email address. + Group Management APIs diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index 6d0abce0c9c..482ee05ad08 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -185,6 +185,7 @@ or `$CLOUDFOUNDRY_CONFIG_PATH/uaa.yml`. | [↪](#scimuserids_enabled) `scim.userids_enabled` | `true`| Enable /ids/Users endpoint| | [↪](#scimuseroverride) `scim.user.override` | `false`| Override existing bootstrap users| | [↪](#scimdeletedeactivate) `scim.delete.deactivate` | `false`| Deactivate instead of delete| +| [↪](#sciminviteallow_reinvite) `scim.invite.allow_reinvite` | `true`| Invite verified users for any `scim.invite` caller| | [↪](#usermaxcount) `userMaxCount` | `500`| Max users returned by list endpoint| | [↪](#groupmaxcount) `groupMaxCount` | `500`| Max groups returned by list endpoint| | [↪](#deleteusers) `delete.users` | — (null)| Users to delete on bootstrap| @@ -1184,6 +1185,16 @@ Bootstrap client definitions loaded at startup. Each entry is keyed by client ID - `jwks` — Inline JWKS for private_key_jwt auth - `jwks_uri` — URI to fetch JWKS - `name` — Display name +- `signup_redirect_url` — Where a user is sent after confirming a self-service signup and setting a password, when the + signup request carries no `redirect_uri` that matches one of the client's registered redirect URIs. Without it the + default is the UAA home page. +- `change_email_redirect_url` — Where a user is sent after verifying an email change, when the verification link + carries no `redirect_uri` that matches one of the client's registered redirect URIs. Without it the user is sent to + the profile page (or the login page when signed out). `POST /email_changes` returns this value as `redirect_url`. + +`signup_redirect_url` and `change_email_redirect_url` are stored as additional information of the client. They are +keys of the client definition (see `scripts/boot/uaa.yml`) and top-level attributes of the client in the client +registration API. [Back to table](#oauth-clients--users) @@ -1848,6 +1859,37 @@ of permanently deleting the record. --- +### `scim.invite.allow_reinvite` + +**Default:** `true` +**Source:** `@Value("${scim.invite.allow_reinvite:true}")` in [`InvitationsEndpoint`](../server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpoint.java) +**Type:** `boolean` + +Controls what `POST /invite_users` returns for an email address that belongs to a user who is already verified. + +- `true` (default): the user is invited like any other user. The response lists the address under `new_invites` with + the user's `userId` and a new `inviteLink`. +- `false`: when the token has `scim.invite` but may not read users, that is when `GET /Users` would refuse it (for + example it has neither `scim.read` nor the zone administrator scope), the address is listed under `failed_invites` + with the error code `user.already-verified` and the message `The user is already verified.`. No user id and no link + are returned, and no invitation code is created. A token that may read users still gets the user id and the link. + +With either value, users who are not verified yet and new users are invited as before, so an expired invitation can +always be sent again, and the other addresses of a batch are processed as usual. + +Before setting `false`, check the clients that call `/invite_users`. A client that holds `scim.invite` without +`scim.read`, re-invites existing users and relies on the user id or the link in the response (for example to assign +permissions or to send the link itself) receives `failed_invites` for verified users. Give such a client `scim.read`, +or leave the property at `true`. + +Which users are verified: users created with `POST /Users` are verified unless the request says otherwise, users of +the `uaa` origin become verified when they accept an invitation, and users of an external origin are verified only +when their identity provider says so (for example the `email_verified` claim of an OpenID Connect provider). + +[Back to table](#scim-user-provisioning) + +--- + ### `userMaxCount` **Default:** `500` @@ -1892,6 +1934,26 @@ List of usernames to delete on bootstrap. Used to remove specific users during s When `true`, "Create Account" and "Forgot Password" links are displayed on the login page. +> **Note on self-service account confirmation.** Clicking the activation link in the confirmation +> email verifies ownership of the email address but does **not** activate the password typed on the +> registration form. The owner is sent to a password-setup page (stating that a password change is +> required due to changes in the system) and must establish their own password through the link +> before the account can be used to sign in. This ensures a password chosen before the email address +> was verified can never be used to authenticate. The redirect configured for the registration is +> applied after the owner has set their password: an explicit `redirect_uri` when it matches one of the +> client's registered redirect URIs, otherwise the client's `signup_redirect_url`, which does not have +> to be a registered redirect URI. Opening another activation link for an account that is already +> confirmed changes nothing: the password stays as it is and the user is sent to the login page. +> +> **Note on addresses that already have an account.** The registration form (`/create_account.do`) and +> the change email form (`/change_email.do`) answer the same way whether or not the address already +> has a verified account in the zone's `uaa` origin: the user is sent to the "email sent" page, and no +> `422` response or "There is already a user with that username" message is shown. After a +> registration the page names the address the email was sent to. When the address already has an +> account, no activation or verification link is created. The owner of the address instead receives an +> email stating that the address already has an account and that nothing has changed. Clients that +> parsed the previous `422` page of these two forms must be updated. + [Back to table](#login--branding) --- @@ -3296,6 +3358,13 @@ URL paths instead of different subdomains. metadata generation in non-default zones, as the zone information is already encoded in the URL path. See the `login.entityBaseURL` documentation for details on how this affects SAML endpoint URLs. +**Required since 2026-10-26:** A zone path request must come from a recognized host, like any other request. The +hostname must be the host of [`uaa.url`](#uaaurl), the host of [`login.url`](#loginurl), `localhost`, or one of the +[`zones.internal.hostnames`](#zonesinternalhostnames); otherwise the request is answered with a `404` before it reaches +the zone. Earlier versions accepted any hostname for a zone path request. Deployments that reach zone paths through a +hostname that is not yet listed must add it to `zones.internal.hostnames`. When only `localhost` is configured, any +hostname is accepted. + [Back to table](#zone-paths) --- @@ -3308,7 +3377,8 @@ metadata generation in non-default zones, as the zone information is already enc A comprehensive list of hostnames that route to the UAA default zone. The UAA uses these to distinguish zone subdomains. If a request's hostname is not in this list and has extra -subdomain levels, it is treated as a zone subdomain. +subdomain levels, it is treated as a zone subdomain. A hostname that matches none of the +configured root domains is not served, including for zone path requests (`/z/{subdomain}/`) since 2026-10-26. ```yaml zones: diff --git a/docs/zone-path-feature.md b/docs/zone-path-feature.md index d15331a40c9..845e79da45d 100644 --- a/docs/zone-path-feature.md +++ b/docs/zone-path-feature.md @@ -72,7 +72,11 @@ This is useful in deployments where wildcard DNS or wildcard TLS certificates ar The refactoring into `resolveZoneBySubdomain()`, `handleZoneNotFound()`, and `doFilterWithZone()` is a readability improvement that also makes each responsibility independently testable. -**Default zone path (`/z/default/`):** `ZonePathContextRewritingFilter` does **not** set `ZONE_SUBDOMAIN_FROM_PATH` for `/z/default/` requests. This means `resolveEffectiveSubdomain()` sees `null` for the path attribute and falls through to hostname-based resolution, which naturally resolves to the default zone. No special-case "default" handling is needed in this filter. +**Default zone path (`/z/default/`):** `ZonePathContextRewritingFilter` sets +`ZONE_SUBDOMAIN_FROM_PATH` to the empty string for `/z/default/` requests. This +explicitly selects the default zone. A tenant hostname combined with +`/z/default/` is rejected as ambiguous instead of allowing the hostname to +override the path. ### 2.2 `OpenIdConnectEndpoints.java` @@ -131,7 +135,13 @@ The outermost filter in the chain (`Ordered.HIGHEST_PRECEDENCE + 1`). For reques - **Rewrites the request** by wrapping it in `ZonePathRewrittenRequest` (a private `HttpServletRequestWrapper`) that moves `/z/{subdomain}` from the servlet path into the context path. Downstream code sees `getContextPath()` = `/uaa/z/myzone` and `getServletPath()` = `/login`. - **Sets request attributes** `ZONE_SUBDOMAIN_FROM_PATH` (the subdomain string) and `ZONE_ORIGINAL_CONTEXT_PATH` (the pre-rewrite context path like `/uaa`), consumed by `IdentityZoneResolvingFilter` and `ZoneAwareStaticResourceLinkBuilder`. - **Wraps the response** in `CookiePathRewritingResponse` which intercepts `addCookie()`, `addHeader("Set-Cookie", ...)`, and `setHeader("Set-Cookie", ...)` to rewrite cookie paths from `/` back to the original context path (e.g., `/uaa`). -- **Default zone path:** For the subdomain `"default"`, rewrites so the context path **includes** `/z/default` (e.g. `/uaa/z/default`), like any other zone path. Does **not** set `ZONE_SUBDOMAIN_FROM_PATH` so the default zone is resolved. `ZoneContextPathSessionRequestWrapper` maps this context path to the same session key as the root, so `/profile` and `/z/default/profile` share the same cookie/session. See "Default zone path" below. +- **Default zone path:** For the subdomain `"default"`, rewrites so the context + path **includes** `/z/default` (e.g. `/uaa/z/default`), like any other zone + path. Sets `ZONE_SUBDOMAIN_FROM_PATH` to the empty string to explicitly select + the default zone. `ZoneContextPathSessionRequestWrapper` maps this context + path to the same session key as the root, so `/profile` and + `/z/default/profile` share the same cookie/session. See "Default zone path" + below. - **Passes through** non-zone requests unchanged (but still wraps the response for cookie path normalization when a context path is present). #### Default zone path diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/AccountCreationService.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/AccountCreationService.java index bd790c6812b..6dd06130100 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/AccountCreationService.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/AccountCreationService.java @@ -1,5 +1,6 @@ package org.cloudfoundry.identity.uaa.account; +import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonProperty; import org.cloudfoundry.identity.uaa.scim.ScimUser; @@ -19,6 +20,8 @@ class AccountCreationResponse { private String email; @JsonProperty("redirect_location") private String redirectLocation; + @JsonIgnore + private String passwordResetCode; public AccountCreationResponse(String userId, String username, String email, String redirectLocation) { this.userId = userId; @@ -50,5 +53,13 @@ public String getRedirectLocation() { public String getEmail() { return email; } + + public String getPasswordResetCode() { + return passwordResetCode; + } + + public void setPasswordResetCode(String passwordResetCode) { + this.passwordResetCode = passwordResetCode; + } } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/AccountsController.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/AccountsController.java index 975be8c2494..8b64a654923 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/AccountsController.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/AccountsController.java @@ -6,6 +6,7 @@ import org.cloudfoundry.identity.uaa.provider.IdentityProviderProvisioning; import org.cloudfoundry.identity.uaa.scim.exception.InvalidPasswordException; import org.cloudfoundry.identity.uaa.util.DomainFilter; +import org.cloudfoundry.identity.uaa.web.UaaSavedRequestAwareAuthenticationSuccessHandler; import org.cloudfoundry.identity.uaa.zone.BrandingInformation; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.springframework.core.env.Environment; @@ -20,11 +21,14 @@ import org.springframework.web.bind.annotation.RequestMethod; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import jakarta.validation.constraints.Email; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.List; @Controller @@ -65,8 +69,11 @@ public String sendActivationEmail(Model model, HttpServletResponse response, @Valid @ModelAttribute ValidEmail email, BindingResult result, @RequestParam String password, @RequestParam("password_confirmation") String passwordConfirmation, - @RequestParam(value = "does_user_consent", required = false) boolean doesUserConsent) { + @RequestParam(value = "does_user_consent", required = false) boolean doesUserConsent, + RedirectAttributes redirectAttributes) { + model.addAttribute("client_id", clientId); + model.addAttribute("redirect_uri", redirectUri); BrandingInformation zoneBranding = IdentityZoneHolder.get().getConfig().getBranding(); if (zoneBranding != null && zoneBranding.getConsent() != null && !doesUserConsent) { return handleUnprocessableEntity(model, response, "error_message_code", "missing_consent"); @@ -95,6 +102,7 @@ public String sendActivationEmail(Model model, HttpServletResponse response, } catch (InvalidPasswordException e) { return handleUnprocessableEntity(model, response, "error_message", e.getMessagesAsOneString()); } + redirectAttributes.addFlashAttribute("email", email.getEmail()); return "redirect:accounts/email_sent"; } @@ -124,12 +132,21 @@ public String verifyUser(Model model, return "accounts/link_prompt"; } - String redirectLocation = accountCreation.getRedirectLocation(); - String res = "redirect:/login?success=verify_success"; - if (!redirectLocation.equals(accountCreationService.getDefaultRedirect())) { - res += "&form_redirect_uri=" + redirectLocation; + if (accountCreation.getPasswordResetCode() == null) { + // the password was set by whoever created the account, so there is nothing left to set + String redirectLocation = accountCreation.getRedirectLocation(); + String res = "redirect:/login?success=verify_success"; + if (!redirectLocation.equals(accountCreationService.getDefaultRedirect())) { + UaaSavedRequestAwareAuthenticationSuccessHandler.saveTrustedRedirect(session, redirectLocation); + res += "&form_redirect_uri=" + URLEncoder.encode(redirectLocation, StandardCharsets.UTF_8); + } + return res; } - return res; + + // Confirmation establishes email ownership but leaves the account without a usable + // password. Send the owner to the reset-password page to set their own password; any + // client redirect is carried by the reset code and applied once the password is set. + return "redirect:/reset_password?code=" + accountCreation.getPasswordResetCode() + "&force_change=true"; } private String handleUnprocessableEntity(Model model, HttpServletResponse response, String attributeKey, String attributeValue) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/ChangeEmailController.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/ChangeEmailController.java index e00a7abe32f..601c6a8ad27 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/ChangeEmailController.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/ChangeEmailController.java @@ -7,6 +7,7 @@ import org.cloudfoundry.identity.uaa.error.UaaException; import org.cloudfoundry.identity.uaa.user.UaaUser; import org.cloudfoundry.identity.uaa.user.UaaUserDatabase; +import org.cloudfoundry.identity.uaa.web.UaaSavedRequestAwareAuthenticationSuccessHandler; import org.springframework.http.HttpStatus; import org.springframework.security.authentication.AnonymousAuthenticationToken; import org.springframework.security.core.context.SecurityContext; @@ -25,6 +26,8 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.validation.Valid; import jakarta.validation.constraints.Email; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.util.Map; @Controller @@ -56,6 +59,8 @@ public String changeEmail(Model model, @Valid @ModelAttribute ValidEmail newEmai @RequestParam(required = false, value = "redirect_uri") String redirectUri, RedirectAttributes redirectAttributes, HttpServletResponse response) { SecurityContext securityContext = SecurityContextHolder.getContext(); + model.addAttribute("client_id", clientId); + model.addAttribute("redirect_uri", redirectUri); if (result.hasErrors()) { model.addAttribute("error_message_code", "invalid_email"); @@ -72,16 +77,7 @@ public String changeEmail(Model model, @Valid @ModelAttribute ValidEmail newEmai String userId = ((UaaPrincipal) securityContext.getAuthentication().getPrincipal()).getId(); String userEmail = ((UaaPrincipal) securityContext.getAuthentication().getPrincipal()).getName(); - try { - changeEmailService.beginEmailChange(userId, userEmail, newEmail.getNewEmail(), clientId, redirectUri); - } catch (UaaException e) { - if (e.getHttpStatus() == 409) { - model.addAttribute("error_message_code", "username_exists"); - model.addAttribute("email", ((UaaPrincipal) securityContext.getAuthentication().getPrincipal()).getEmail()); - response.setStatus(HttpStatus.UNPROCESSABLE_ENTITY.value()); - return "change_email"; - } - } + changeEmailService.beginEmailChange(userId, userEmail, newEmail.getNewEmail(), clientId, redirectUri); return "redirect:email_sent?code=email_change"; } @@ -125,7 +121,10 @@ public String verifyEmail(Model model, @RequestParam String code, RedirectAttrib if (redirectLocation == null) { return "redirect:login?success=change_email_success"; } else { - return "redirect:login?success=change_email_success&form_redirect_uri=" + redirectLocation; + UaaSavedRequestAwareAuthenticationSuccessHandler.saveTrustedRedirect( + request.getSession(), + redirectLocation); + return "redirect:login?success=change_email_success&form_redirect_uri=" + URLEncoder.encode(redirectLocation, StandardCharsets.UTF_8); } } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/EmailAccountCreationService.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/EmailAccountCreationService.java index fab9727183c..e9b75de15e2 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/EmailAccountCreationService.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/EmailAccountCreationService.java @@ -14,6 +14,7 @@ import org.cloudfoundry.identity.uaa.scim.exception.ScimResourceAlreadyExistsException; import org.cloudfoundry.identity.uaa.scim.util.ScimUtils; import org.cloudfoundry.identity.uaa.scim.validate.PasswordValidator; +import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.MergedZoneBrandingInformation; @@ -22,7 +23,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.http.HttpStatus; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.client.HttpClientErrorException; @@ -52,6 +53,7 @@ public class EmailAccountCreationService implements AccountCreationService { private final ScimUserProvisioning scimUserProvisioning; private final MultitenantClientServices clientDetailsService; private final PasswordValidator passwordValidator; + private final ResetPasswordService resetPasswordService; private final IdentityZoneManager identityZoneManager; public EmailAccountCreationService( @@ -61,6 +63,7 @@ public EmailAccountCreationService( ScimUserProvisioning scimUserProvisioning, MultitenantClientServices clientDetailsService, PasswordValidator passwordValidator, + ResetPasswordService resetPasswordService, IdentityZoneManager identityZoneManager) { this.templateEngine = templateEngine; @@ -69,6 +72,7 @@ public EmailAccountCreationService( this.scimUserProvisioning = scimUserProvisioning; this.clientDetailsService = clientDetailsService; this.passwordValidator = passwordValidator; + this.resetPasswordService = resetPasswordService; this.identityZoneManager = identityZoneManager; } @@ -84,7 +88,7 @@ public void beginActivation(String email, String password, String clientId, Stri List users = scimUserProvisioning.retrieveByUsernameAndOriginAndZone(email, OriginKeys.UAA, identityZoneManager.getCurrentIdentityZoneId()); if (!users.isEmpty()) { if (users.getFirst().isVerified()) { - throw new UaaException("User already active.", HttpStatus.CONFLICT.value()); + messageService.sendMessage(email, MessageType.CREATE_ACCOUNT_CONFIRMATION, buildAccountExistsSubjectText(), getAccountExistsEmailHtml(email)); } else { generateAndSendCode(email, clientId, subject, users.getFirst().getId(), redirectUri, identityZoneManager.getCurrentIdentityZone()); } @@ -121,14 +125,57 @@ public AccountCreationResponse completeActivation(String code) { Map data = JsonUtils.readValue(expiringCode.getData(), new TypeReference>() { }); - ScimUser user = scimUserProvisioning.retrieve(data.get("user_id"), identityZoneManager.getCurrentIdentityZoneId()); - user = scimUserProvisioning.verifyUser(user.getId(), user.getVersion(), identityZoneManager.getCurrentIdentityZoneId()); + String zoneId = identityZoneManager.getCurrentIdentityZoneId(); + ScimUser user = scimUserProvisioning.retrieve(data.get("user_id"), zoneId); String clientId = data.get("client_id"); String redirectUri = data.get("redirect_uri") != null ? data.get("redirect_uri") : ""; + + if (user.isVerified()) { + // The account is already verified, for example through another activation link. Leave its password + // alone: the owner has set it, or can set it through forgot password. + return new AccountCreationResponse(user.getId(), user.getUserName(), user.getUserName(), getRedirect(clientId, redirectUri)); + } + + if (Boolean.parseBoolean(data.get(ScimUtils.USER_PROVISIONED)) || !OriginKeys.UAA.equals(user.getOrigin())) { + // The code was requested through the user API, so a caller that may create users set the password. + // Opening the link only has to verify the email address. The same goes for a user of another origin, + // whose password is not managed here: the reset code below is looked up by username in the uaa origin, + // so it could belong to a different account. + user = scimUserProvisioning.verifyUser(user.getId(), user.getVersion(), zoneId); + return new AccountCreationResponse(user.getId(), user.getUserName(), user.getUserName(), getRedirect(clientId, redirectUri)); + } + + // Confirming the activation link proves ownership of the email address, but it does not + // prove that the person confirming is the one who chose the password on the registration + // form. Atomically discard that password, require a change, and verify the account. The + // conditional update also ensures that only one of several concurrent activation links can + // claim the account; a losing request must not overwrite a password the owner has since set. + try { + user = scimUserProvisioning.verifyUserAndSetPassword( + user.getId(), + new AlphanumericRandomValueStringGenerator(40).generate(), + true, + zoneId); + } catch (OptimisticLockingFailureException e) { + user = scimUserProvisioning.retrieve(data.get("user_id"), zoneId); + if (!user.isVerified()) { + throw e; + } + return new AccountCreationResponse(user.getId(), user.getUserName(), user.getUserName(), getRedirect(clientId, redirectUri)); + } + + // Resolve the signup redirect (honouring the client's signup_redirect_url fallback) so it is + // preserved across the reset flow and applied once the owner has set their password. String redirectLocation = getRedirect(clientId, redirectUri); - return new AccountCreationResponse(user.getId(), user.getUserName(), user.getUserName(), redirectLocation); + // Issue an ownership-bound, single-use code so the owner sets their password through the + // same reset flow used for password recovery (no previous password is required). + ForgotPasswordInfo forgotPasswordInfo = resetPasswordService.forgotPassword(user.getUserName(), clientId, redirectLocation); + + AccountCreationResponse response = new AccountCreationResponse(user.getId(), user.getUserName(), user.getUserName(), redirectLocation); + response.setPasswordResetCode(forgotPasswordInfo.getResetPasswordCode().getCode()); + return response; } private String getRedirect(String clientId, String redirectUri) { @@ -188,6 +235,23 @@ private String buildSubjectText() { } } + private String buildAccountExistsSubjectText() { + return "You already have an account"; + } + + private String getAccountExistsEmailHtml(String email) { + IdentityZone currentIdentityZone = identityZoneManager.getCurrentIdentityZone(); + final Context ctx = new Context(); + String companyName = MergedZoneBrandingInformation.resolveBranding().getCompanyName(); + if (currentIdentityZone.isUaa()) { + ctx.setVariable("serviceName", StringUtils.hasText(companyName) ? companyName : "Cloud Foundry"); + } else { + ctx.setVariable("serviceName", currentIdentityZone.getName()); + } + ctx.setVariable("email", email); + return templateEngine.process("account_exists", ctx); + } + private String getEmailHtml(String code, String email, IdentityZone currentIdentityZone) { String accountsUrl = ScimUtils.getVerificationURL(null, currentIdentityZone).toString(); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/EmailChangeEmailService.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/EmailChangeEmailService.java index d6967aa20e7..11e6afd99b4 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/EmailChangeEmailService.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/EmailChangeEmailService.java @@ -69,7 +69,8 @@ public void beginEmailChange(String userId, String email, String newEmail, Strin if (user.getUserName().equals(user.getPrimaryEmail())) { if (!results.isEmpty()) { - throw new UaaException("Conflict", 409); + messageService.sendMessage(newEmail, MessageType.CHANGE_EMAIL, "You already have an account", getAccountExistsEmailHtml(newEmail)); + return; } } @@ -127,7 +128,9 @@ public Map completeVerification(String code) { Set redirectUris = clientDetails.getRegisteredRedirectUri() == null ? Collections.emptySet() : clientDetails.getRegisteredRedirectUri(); String changeEmailRedirectUrl = (String) clientDetails.getAdditionalInformation().get(CHANGE_EMAIL_REDIRECT_URL); - redirectLocation = findMatchingRedirectUri(redirectUris, redirectUri, changeEmailRedirectUrl); + // without a configured fallback, only a requested URI that matches a registered one is used + String matchingRedirectUri = findMatchingRedirectUri(redirectUris, redirectUri, changeEmailRedirectUrl != null ? changeEmailRedirectUrl : ""); + redirectLocation = StringUtils.hasText(matchingRedirectUri) ? matchingRedirectUri : null; } catch (NoSuchClientException _) { // ignore } @@ -150,6 +153,18 @@ private String getSubjectText() { } } + private String getAccountExistsEmailHtml(String newEmail) { + final Context ctx = new Context(); + if (identityZoneManager.isCurrentZoneUaa()) { + String companyName = MergedZoneBrandingInformation.resolveBranding().getCompanyName(); + ctx.setVariable("serviceName", StringUtils.hasText(companyName) ? companyName : "Cloud Foundry"); + } else { + ctx.setVariable("serviceName", identityZoneManager.getCurrentIdentityZone().getName()); + } + ctx.setVariable("email", newEmail); + return templateEngine.process("account_exists", ctx); + } + private String getEmailChangeEmailHtml(String email, String newEmail, String code) { String verifyUrl = UaaUrlUtils.getUaaUrl("/verify_email", identityZoneManager.getCurrentIdentityZone()); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationFilter.java index 47084790f4d..93d7772fdf4 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationFilter.java @@ -19,10 +19,12 @@ import org.cloudfoundry.identity.uaa.codestore.ExpiringCodeStore; import org.cloudfoundry.identity.uaa.error.UaaException; import org.cloudfoundry.identity.uaa.scim.exception.InvalidPasswordException; +import org.cloudfoundry.identity.uaa.web.UaaSavedRequestAwareAuthenticationSuccessHandler; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.springframework.http.HttpMethod; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.InternalAuthenticationServiceException; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; @@ -34,13 +36,15 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; -import java.sql.Timestamp; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; public class ResetPasswordAuthenticationFilter extends OncePerRequestFilter { private final ResetPasswordService service; private final AuthenticationEntryPoint entryPoint; private final ExpiringCodeStore expiringCodeStore; public static final String RESET_PASSWORD_URL = "/reset_password.do"; + private static final String INVALID_CODE_MESSAGE = "Sorry, your reset password link is no longer valid. Please request a new one"; private static final RequestMatcher matcher = PathPatternRequestMatcher.withDefaults().matcher(HttpMethod.POST, RESET_PASSWORD_URL); public ResetPasswordAuthenticationFilter( @@ -75,33 +79,40 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse expiringCode = expiringCodeStore.retrieveCode(code, IdentityZoneHolder.get().getId()); validation.throwIfNotValid(); if (expiringCode == null) { - throw new InvalidCodeException("invalid_code", "Sorry, your reset password link is no longer valid. Please request a new one", 422); + throw new InvalidCodeException("invalid_code", INVALID_CODE_MESSAGE, 422); } ResetPasswordService.ResetPasswordResponse resetPasswordResponse = service.resetPassword(expiringCode, password); String redirectUri = resetPasswordResponse.getRedirectUri(); if (!StringUtils.hasText(redirectUri) || "home".equals(redirectUri)) { response.sendRedirect(request.getContextPath() + "/login?success=password_reset"); } else { - response.sendRedirect(request.getContextPath() + "/login?success=password_reset&form_redirect_uri=" + redirectUri); + UaaSavedRequestAwareAuthenticationSuccessHandler.saveTrustedRedirect( + request.getSession(), + redirectUri); + response.sendRedirect(request.getContextPath() + "/login?success=password_reset&form_redirect_uri=" + URLEncoder.encode(redirectUri, StandardCharsets.UTF_8)); } } catch (InvalidPasswordException e) { - if (expiringCode != null) { - refreshCode(request, expiringCode); - } - entryPoint.commence(request, response, new BadCredentialsException(e.getMessagesAsOneString(), e)); + commenceWithRefreshedCode(request, response, expiringCode, new BadCredentialsException(e.getMessagesAsOneString(), e)); } catch (UaaException e) { entryPoint.commence(request, response, new InternalAuthenticationServiceException(e.getMessage(), e)); } catch (PasswordConfirmationException pe) { - if (expiringCode != null) { - refreshCode(request, expiringCode); - } - entryPoint.commence(request, response, new BadCredentialsException("Password did not pass validation.", pe)); + commenceWithRefreshedCode(request, response, expiringCode, new BadCredentialsException("Password did not pass validation.", pe)); } } - private void refreshCode(HttpServletRequest request, ExpiringCode expiringCode) { - ExpiringCode newCode = expiringCodeStore.generateCode(expiringCode.getData(), new Timestamp(System.currentTimeMillis() + 1000 * 60 * 10), expiringCode.getIntent(), IdentityZoneHolder.get().getId()); - request.setAttribute("code", newCode.getCode()); + private void commenceWithRefreshedCode(HttpServletRequest request, HttpServletResponse response, ExpiringCode expiringCode, AuthenticationException failure) throws IOException, ServletException { + if (expiringCode != null) { + try { + ExpiringCode newCode = expiringCodeStore.generateCode(expiringCode.getData(), expiringCode.getExpiresAt(), expiringCode.getIntent(), IdentityZoneHolder.get().getId()); + request.setAttribute("code", newCode.getCode()); + } catch (IllegalArgumentException _) { + // the code expired while the password was checked, so there is no code left to submit again + InvalidCodeException expired = new InvalidCodeException("invalid_code", INVALID_CODE_MESSAGE, 422); + entryPoint.commence(request, response, new InternalAuthenticationServiceException(expired.getMessage(), expired)); + return; + } + } + entryPoint.commence(request, response, failure); } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/ResetPasswordController.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/ResetPasswordController.java index b10f38d66b7..6a6ed112844 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/ResetPasswordController.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/ResetPasswordController.java @@ -35,7 +35,6 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; -import java.sql.Timestamp; import java.util.Map; import static org.cloudfoundry.identity.uaa.account.UaaResetPasswordService.FORGOT_PASSWORD_INTENT_PREFIX; @@ -176,19 +175,22 @@ public void resetPassword() { @GetMapping(value = "/reset_password", params = {"code"}) public String resetPasswordPage(Model model, HttpServletResponse response, - @RequestParam("code") String code) { + @RequestParam("code") String code, + @RequestParam(value = "force_change", required = false) boolean forceChange) { - ExpiringCode expiringCode = checkIfUserExists(codeStore.retrieveCode(code, identityZoneManager.getCurrentIdentityZone().getId())); + ExpiringCode expiringCode = checkIfUserExists(codeStore.peekCode(code, identityZoneManager.getCurrentIdentityZone().getId())); if (expiringCode == null) { return handleUnprocessableEntity(model, response, "message_code", "bad_code"); } else { PasswordChange passwordChange = JsonUtils.readValue(expiringCode.getData(), PasswordChange.class); String userId = passwordChange.getUserId(); UaaUser uaaUser = userDatabase.retrieveUserById(userId); - String newCode = codeStore.generateCode(expiringCode.getData(), new Timestamp(System.currentTimeMillis() + (10 * 60 * 1000)), expiringCode.getIntent(), identityZoneManager.getCurrentIdentityZoneId()).getCode(); - model.addAttribute("code", newCode); + model.addAttribute("code", expiringCode.getCode()); model.addAttribute("email", uaaUser.getEmail()); model.addAttribute("username", uaaUser.getUsername()); + if (forceChange) { + model.addAttribute("message_code", "force_change"); + } return "reset_password"; } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/UaaResetPasswordService.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/UaaResetPasswordService.java index a7c51da5b32..42ae880822e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/UaaResetPasswordService.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/UaaResetPasswordService.java @@ -135,9 +135,14 @@ private ResetPasswordResponse changePasswordCodeAuthenticated(ExpiringCode expir ClientDetails clientDetails = clientDetailsService.loadClientByClientId(clientId, identityZoneManager.getCurrentIdentityZoneId()); Set redirectUris = clientDetails.getRegisteredRedirectUri() == null ? Collections.emptySet() : clientDetails.getRegisteredRedirectUri(); - String matchingRedirectUri = UaaUrlUtils.findMatchingRedirectUri(redirectUris, redirectUri, redirectLocation); - if (matchingRedirectUri != null) { - redirectLocation = matchingRedirectUri; + if (redirectUri.equals(clientDetails.getAdditionalInformation().get(EmailAccountCreationService.SIGNUP_REDIRECT_URL))) { + // an administrator set it on the client, so it does not have to be a registered redirect URI + redirectLocation = redirectUri; + } else { + String matchingRedirectUri = UaaUrlUtils.findMatchingRedirectUri(redirectUris, redirectUri, redirectLocation); + if (matchingRedirectUri != null) { + redirectLocation = matchingRedirectUri; + } } } catch (NoSuchClientException _) { // ignore diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/PasscodeAuthenticationFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/PasscodeAuthenticationFilter.java index f727afd43d4..52177fb46d6 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/PasscodeAuthenticationFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/PasscodeAuthenticationFilter.java @@ -16,6 +16,7 @@ import org.cloudfoundry.identity.uaa.codestore.ExpiringCode; import org.cloudfoundry.identity.uaa.codestore.ExpiringCodeStore; +import org.cloudfoundry.identity.uaa.codestore.ExpiringCodeType; import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.oauth.provider.OAuth2RequestFactory; import org.cloudfoundry.identity.uaa.passcode.PasscodeInformation; @@ -194,7 +195,7 @@ public Authentication authenticate(Authentication authentication) throws Authent } } - if (pi == null) { + if (pi == null || !(ExpiringCodeType.PASSCODE + " " + pi.getUserId()).equals(eCode.getIntent())) { throw new InsufficientAuthenticationException("Invalid passcode"); } logger.debug("Successful passcode authentication request for {}", pi.getUsername()); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/PasswordChangeUiRequiredFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/PasswordChangeUiRequiredFilter.java index e177ac49f35..02bbe40ead4 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/PasswordChangeUiRequiredFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/PasswordChangeUiRequiredFilter.java @@ -9,6 +9,7 @@ import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatchers; import org.springframework.web.filter.OncePerRequestFilter; import jakarta.servlet.FilterChain; @@ -33,7 +34,9 @@ public PasswordChangeUiRequiredFilter() { public PasswordChangeUiRequiredFilter(UaaSavedRequestCache cache) { this.cache = cache; - this.matchPath = PathPatternRequestMatcher.withDefaults().matcher(MATCH_PATH); + this.matchPath = RequestMatchers.anyOf( + PathPatternRequestMatcher.withDefaults().matcher(MATCH_PATH), + PathPatternRequestMatcher.withDefaults().matcher(MATCH_PATH + "/")); this.completedPath = PathPatternRequestMatcher.withDefaults().matcher(COMPLETED_PATH); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/codestore/CodeStoreEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/codestore/CodeStoreEndpoints.java index f9b47ac1524..a7b1962b50a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/codestore/CodeStoreEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/codestore/CodeStoreEndpoints.java @@ -13,9 +13,16 @@ import jakarta.servlet.http.HttpServletRequest; +import static org.cloudfoundry.identity.uaa.codestore.ExpiringCodeType.EMAIL; +import static org.cloudfoundry.identity.uaa.codestore.ExpiringCodeType.INVITATION; +import static org.cloudfoundry.identity.uaa.codestore.ExpiringCodeType.REGISTRATION; + @Controller public class CodeStoreEndpoints { + private static final String INTENT_REQUIRED_MESSAGE = "intent is required. Known intents: " + + String.join(", ", EMAIL.name(), INVITATION.name(), REGISTRATION.name()) + "; custom values are accepted."; + private final ExpiringCodeStore expiringCodeStore; private final HttpMessageConverter[] messageConverters; private final IdentityZoneManager identityZoneManager; @@ -32,9 +39,13 @@ public class CodeStoreEndpoints { @PostMapping({"/Codes", "/Codes/"}) @ResponseStatus(HttpStatus.CREATED) @ResponseBody - public ExpiringCode generateCode(@RequestBody ExpiringCode expiringCode) { + public ExpiringCode generateCode(@RequestBody(required = false) ExpiringCode expiringCode) { + String intent = expiringCode == null ? null : expiringCode.getIntent(); + if (intent == null || intent.isBlank()) { + throw new CodeStoreException(INTENT_REQUIRED_MESSAGE, HttpStatus.BAD_REQUEST); + } try { - return expiringCodeStore.generateCode(expiringCode.getData(), expiringCode.getExpiresAt(), null, identityZoneManager.getCurrentIdentityZoneId()); + return expiringCodeStore.generateCode(expiringCode.getData(), expiringCode.getExpiresAt(), intent, identityZoneManager.getCurrentIdentityZoneId()); } catch (NullPointerException _) { throw new CodeStoreException("data and expiresAt are required.", HttpStatus.BAD_REQUEST); } catch (IllegalArgumentException _) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/codestore/JdbcExpiringCodeStore.java b/server/src/main/java/org/cloudfoundry/identity/uaa/codestore/JdbcExpiringCodeStore.java index 040fc8a1b9b..7975538f345 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/codestore/JdbcExpiringCodeStore.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/codestore/JdbcExpiringCodeStore.java @@ -154,6 +154,10 @@ public ExpiringCode retrieveCode(String code, String zoneId) { if (expiringCode != null && expiringCode.getExpiresAt().getTime() < timeService.getCurrentTimeMillis()) { expiringCode = null; } + if (expiringCode != null && expiringCode.getIntent() == null) { + // codes stored without an intent predate required intents; each is redeemed once, so this logs at most once per code + logger.debug("Redeemed an expiring code stored without an intent, zoneId={}", zoneId); + } return expiringCode; } catch (EmptyResultDataAccessException _) { return null; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/EmailInvitationsService.java b/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/EmailInvitationsService.java index 8b8c594d1e0..7eb5b874454 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/EmailInvitationsService.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/EmailInvitationsService.java @@ -14,6 +14,7 @@ import org.slf4j.LoggerFactory; import org.springframework.http.HttpStatus; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; +import org.springframework.dao.OptimisticLockingFailureException; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.client.HttpClientErrorException; @@ -65,14 +66,24 @@ public AcceptedInvitation acceptInvitation(String code, String password) { ScimUser user = scimUserProvisioning.retrieve(userId, identityZoneManager.getCurrentIdentityZoneId()); - if (UAA.equals(user.getOrigin())) { - user = scimUserProvisioning.verifyUser(userId, user.getVersion(), identityZoneManager.getCurrentIdentityZoneId()); - - // Only reset the password for newly created users + if (UAA.equals(user.getOrigin()) && !user.isVerified()) { boolean createdNewUser = Boolean.parseBoolean( userData.getOrDefault(CREATED_NEW_USER, "true")); // default true for in-flight codes if (StringUtils.hasText(password) && createdNewUser) { - scimUserProvisioning.changePassword(userId, null, password, identityZoneManager.getCurrentIdentityZoneId()); + try { + user = scimUserProvisioning.verifyUserAndSetPassword( + userId, + password, + false, + identityZoneManager.getCurrentIdentityZoneId()); + } catch (OptimisticLockingFailureException e) { + user = scimUserProvisioning.retrieve(userId, identityZoneManager.getCurrentIdentityZoneId()); + if (!user.isVerified()) { + throw e; + } + } + } else { + user = scimUserProvisioning.verifyUser(userId, user.getVersion(), identityZoneManager.getCurrentIdentityZoneId()); } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsController.java b/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsController.java index e17d5bbb956..8a04044ba07 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsController.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsController.java @@ -22,6 +22,7 @@ import org.cloudfoundry.identity.uaa.scim.ScimUser; import org.cloudfoundry.identity.uaa.scim.ScimUserProvisioning; import org.cloudfoundry.identity.uaa.scim.exception.InvalidPasswordException; +import org.cloudfoundry.identity.uaa.scim.exception.ScimResourceNotFoundException; import org.cloudfoundry.identity.uaa.scim.validate.PasswordValidator; import org.cloudfoundry.identity.uaa.user.UaaAuthority; import org.cloudfoundry.identity.uaa.user.UaaUser; @@ -29,6 +30,7 @@ import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.util.ObjectUtils; import org.cloudfoundry.identity.uaa.util.UaaHttpRequestUtils; +import org.cloudfoundry.identity.uaa.web.UaaSavedRequestAwareAuthenticationSuccessHandler; import org.cloudfoundry.identity.uaa.zone.BrandingInformation; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.springframework.beans.factory.annotation.Qualifier; @@ -61,6 +63,7 @@ import java.util.Enumeration; import java.util.HashMap; import java.util.Map; +import java.util.Objects; import static org.cloudfoundry.identity.uaa.codestore.ExpiringCodeType.INVITATION; import static org.cloudfoundry.identity.uaa.constants.OriginKeys.OAUTH20; @@ -134,14 +137,18 @@ public String acceptInvitePage(@RequestParam String code, Model model, HttpServl boolean isUaaUserAndVerified = UAA.equals(provider.getType()) && user.isVerified(); boolean isExternalUserAndAcceptedInvite = - !UAA.equals(provider.getType()) && UaaHttpRequestUtils.isAcceptedInvitationAuthentication(); + !UAA.equals(provider.getType()) + && UaaHttpRequestUtils.isAcceptedInvitationAuthentication() + && Objects.equals(codeData.get("user_id"), UaaHttpRequestUtils.getInvitedUserId()) + && Objects.equals(origin, UaaHttpRequestUtils.getInvitationOrigin()); if (isUaaUserAndVerified || isExternalUserAndAcceptedInvite) { AcceptedInvitation accepted = invitationsService.acceptInvitation(code, ""); + UaaHttpRequestUtils.clearInvitationSessionAttributes(); String redirect = "redirect:" + accepted.getRedirectUri(); log.debug("Redirecting accepted invitation for email:%s, id:%s to URL:%s".formatted(codeData.get(EMAIL), codeData.get("user_id"), redirect)); return redirect; } else if (SAML.equals(provider.getType())) { - setRequestAttributes(request, code, user); + setRequestAttributes(request, code, user, origin); SamlIdentityProviderDefinition definition = ObjectUtils.castInstance(provider.getConfig(), SamlIdentityProviderDefinition.class); @@ -149,7 +156,7 @@ public String acceptInvitePage(@RequestParam String code, Model model, HttpServl log.debug("Redirecting invitation for email:%s, id:%s single SAML IDP URL:%s".formatted(codeData.get(EMAIL), codeData.get("user_id"), redirect)); return redirect; } else if (OIDC10.equals(provider.getType()) || OAUTH20.equals(provider.getType())) { - setRequestAttributes(request, code, user); + setRequestAttributes(request, code, user, origin); AbstractExternalOAuthIdentityProviderDefinition definition = ObjectUtils.castInstance(provider.getConfig(), AbstractExternalOAuthIdentityProviderDefinition.class); @@ -190,9 +197,8 @@ private void transferErrorParameters(Model model, HttpServletRequest request) { } } - private void setRequestAttributes(HttpServletRequest request, String newCode, UaaUser user) { - RequestContextHolder.getRequestAttributes().setAttribute("IS_INVITE_ACCEPTANCE", true, RequestAttributes.SCOPE_SESSION); - RequestContextHolder.getRequestAttributes().setAttribute("user_id", user.getId(), RequestAttributes.SCOPE_SESSION); + private void setRequestAttributes(HttpServletRequest request, String newCode, UaaUser user, String origin) { + UaaHttpRequestUtils.setInvitationSessionAttributes(user.getId(), origin); HttpServletRequestWrapper wrapper = getNewCodeWrapper(request, newCode); SavedRequest savedRequest = new DefaultSavedRequest(wrapper); @@ -245,6 +251,7 @@ public String acceptInvitation(@RequestParam("password") String password, @RequestParam(value = "does_user_consent", required = false) boolean doesUserConsent, Model model, RedirectAttributes redirectAttributes, + HttpServletRequest request, HttpServletResponse response) { PasswordConfirmationValidation validation = new PasswordConfirmationValidation(password, passwordConfirmation); @@ -253,8 +260,10 @@ public String acceptInvitation(@RequestParam("password") String password, final ExpiringCode expiringCode = expiringCodeStore.retrieveCode(code, identityZoneManager.getCurrentIdentityZoneId()); - if (expiringCode == null || expiringCode.getData() == null) { - log.debug("Failing invitation. Code not found."); + if (expiringCode == null + || expiringCode.getData() == null + || (expiringCode.getIntent() != null && !INVITATION.name().equals(expiringCode.getIntent()))) { + log.debug("Failing invitation. Code not found or intent is invalid."); SecurityContextHolder.clearContext(); return handleUnprocessableEntity(model, response, "error_message_code", "code_expired", "invitations/accept_invite"); } @@ -285,9 +294,13 @@ public String acceptInvitation(@RequestParam("password") String password, } catch (HttpClientErrorException _) { return handleUnprocessableEntity(model, response, "error_message_code", "code_expired", "invitations/accept_invite"); } + UaaHttpRequestUtils.clearInvitationSessionAttributes(); String res = "redirect:/login?success=invite_accepted"; if (!invitation.getRedirectUri().equals("/home")) { - res += "&" + FORM_REDIRECT_PARAMETER + "=" + invitation.getRedirectUri(); + UaaSavedRequestAwareAuthenticationSuccessHandler.saveTrustedRedirect( + request.getSession(), + invitation.getRedirectUri()); + res += "&" + FORM_REDIRECT_PARAMETER + "=" + URLEncoder.encode(invitation.getRedirectUri(), StandardCharsets.UTF_8); } return res; } @@ -314,14 +327,31 @@ public String acceptLdapInvitation(@RequestParam("enterprise_username") String u @RequestParam("enterprise_password") String password, @RequestParam("enterprise_email") String email, @RequestParam String code, - Model model, HttpServletResponse response) { + Model model, HttpServletRequest request, HttpServletResponse response) { ExpiringCode expiringCode = expiringCodeStore.retrieveCode(code, identityZoneManager.getCurrentIdentityZoneId()); - if (expiringCode == null) { - return handleUnprocessableEntity(model, response, "error_message_code", "code_expired", "invitations/accept_enterprise.do"); + if (expiringCode == null + || (expiringCode.getIntent() != null && !INVITATION.name().equals(expiringCode.getIntent()))) { + return handleUnprocessableEntity(model, response, "error_message_code", "code_expired", "invitations/accept_invite"); + } + + Map data = JsonUtils.readValue(expiringCode.getData(), new TypeReference<>() { + }); + if (!OriginKeys.LDAP.equals(data.get(ORIGIN))) { + return handleUnprocessableEntity(model, response, "error_message_code", "code_expired", "invitations/accept_invite"); + } + + ScimUser user; + try { + user = userProvisioning.retrieve(data.get("user_id"), identityZoneManager.getCurrentIdentityZoneId()); + } catch (ScimResourceNotFoundException _) { + return handleUnprocessableEntity(model, response, "error_message_code", "code_expired", "invitations/accept_invite"); + } + if (!OriginKeys.LDAP.equals(user.getOrigin())) { + return handleUnprocessableEntity(model, response, "error_message_code", "code_expired", "invitations/accept_invite"); } - String newCode = expiringCodeStore.generateCode(expiringCode.getData(), new Timestamp(System.currentTimeMillis() + (1000 * 60 * 10)), null, identityZoneManager.getCurrentIdentityZoneId()).getCode(); + String newCode = expiringCodeStore.generateCode(expiringCode.getData(), new Timestamp(System.currentTimeMillis() + (1000 * 60 * 10)), expiringCode.getIntent(), identityZoneManager.getCurrentIdentityZoneId()).getCode(); UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(username, password); AuthenticationManager authenticationManager; @@ -340,13 +370,10 @@ public String acceptLdapInvitation(@RequestParam("enterprise_username") String u Authentication authentication; try { authentication = authenticationManager.authenticate(token); - Map data = JsonUtils.readValue(expiringCode.getData(), new TypeReference<>() { - }); - ScimUser user = userProvisioning.retrieve(data.get("user_id"), identityZoneManager.getCurrentIdentityZoneId()); if (!user.getPrimaryEmail().equalsIgnoreCase(((ExtendedLdapUserDetails) authentication.getPrincipal()).getEmailAddress())) { model.addAttribute(EMAIL, data.get(EMAIL)); model.addAttribute("provider", OriginKeys.LDAP); - model.addAttribute("code", expiringCodeStore.generateCode(expiringCode.getData(), new Timestamp(System.currentTimeMillis() + (10 * 60 * 1000)), null, identityZoneManager.getCurrentIdentityZoneId()).getCode()); + model.addAttribute("code", expiringCodeStore.generateCode(expiringCode.getData(), new Timestamp(System.currentTimeMillis() + (10 * 60 * 1000)), expiringCode.getIntent(), identityZoneManager.getCurrentIdentityZoneId()).getCode()); return handleUnprocessableEntity(model, response, "error_message", "invite.email_mismatch", "invitations/accept_invite"); } @@ -356,6 +383,10 @@ public String acceptLdapInvitation(@RequestParam("enterprise_username") String u userProvisioning.update(user.getId(), user, identityZoneManager.getCurrentIdentityZoneId()); zoneAwareAuthenticationManager.getLdapAuthenticationManager(identityZoneManager.getCurrentIdentityZone(), ldapProvider).authenticate(token); AcceptedInvitation accept = invitationsService.acceptInvitation(newCode, ""); + UaaHttpRequestUtils.clearInvitationSessionAttributes(); + UaaSavedRequestAwareAuthenticationSuccessHandler.saveTrustedRedirect( + request.getSession(), + accept.getRedirectUri()); return "redirect:" + "/login?success=invite_accepted&form_redirect_uri=" + URLEncoder.encode(accept.getRedirectUri(), StandardCharsets.UTF_8); } else { return handleUnprocessableEntity(model, response, "error_message", "not authenticated", "invitations/accept_invite"); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpoint.java b/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpoint.java index b87a446d6c0..e1cfc11931a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpoint.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpoint.java @@ -21,6 +21,7 @@ import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.springframework.stereotype.Controller; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; @@ -40,6 +41,7 @@ import static org.cloudfoundry.identity.uaa.codestore.ExpiringCodeType.INVITATION; import static org.cloudfoundry.identity.uaa.constants.OriginKeys.ORIGIN; import static org.cloudfoundry.identity.uaa.util.DomainFilter.filter; +import static org.cloudfoundry.identity.uaa.web.AuthorizationManagersUtils.anyOf; import static org.cloudfoundry.identity.uaa.zone.IdentityZoneSwitchingFilter.HEADER; import static org.cloudfoundry.identity.uaa.zone.IdentityZoneSwitchingFilter.SUBDOMAIN_HEADER; import static org.cloudfoundry.identity.uaa.oauth.common.util.OAuth2Utils.CLIENT_ID; @@ -58,15 +60,18 @@ public class InvitationsEndpoint { private final IdentityProviderProvisioning identityProviderProvisioning; private final MultitenantClientServices multitenantClientServices; private final ExpiringCodeStore expiringCodeStore; + private final boolean allowReinvite; public InvitationsEndpoint(final ScimUserProvisioning scimUserProvisioning, final @Qualifier("identityProviderProvisioning") IdentityProviderProvisioning identityProviderProvisioning, final MultitenantClientServices multitenantClientServices, - final ExpiringCodeStore expiringCodeStore) { + final ExpiringCodeStore expiringCodeStore, + final @Value("${scim.invite.allow_reinvite:true}") boolean allowReinvite) { this.scimUserProvisioning = scimUserProvisioning; this.identityProviderProvisioning = identityProviderProvisioning; this.multitenantClientServices = multitenantClientServices; this.expiringCodeStore = expiringCodeStore; + this.allowReinvite = allowReinvite; } @PostMapping(value = {"/invite_users", "/invite_users/"}, consumes = "application/json") @@ -103,6 +108,10 @@ public ResponseEntity inviteUsers(@RequestBody InvitationsR if (providers.size() == 1) { FindResult findResult = findOrCreateUser(email, providers.getFirst().getOriginKey()); ScimUser user = findResult.user(); + if (!allowReinvite && user.isVerified() && !canReadUsers(authentication)) { + invitationsResponse.getFailedInvites().add(InvitationsResponse.failure(email, "user.already-verified", "The user is already verified.")); + continue; + } String accountsUrl = UaaUrlUtils.getUaaUrl("/invitations/accept", !IdentityZoneHolder.isUaa(), IdentityZoneHolder.get()); Map data = new HashMap<>(); @@ -159,6 +168,11 @@ private FindResult findOrCreateUser(String email, String origin) { record FindResult(ScimUser user, boolean newUser) {} + // same rule that grants GET /Users + private static boolean canReadUsers(Authentication authentication) { + return anyOf().hasScope("scim.read").isZoneAdmin().authorize(() -> authentication, null).isGranted(); + } + private boolean validateEmail(String email) { boolean valid = true; try { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeController.java b/server/src/main/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeController.java index 975ba488652..568ed763492 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeController.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeController.java @@ -45,6 +45,9 @@ public String handleForcePasswordChange(Model model, @RequestParam("password_confirmation") String passwordConfirmation, HttpServletRequest request, HttpServletResponse response, HttpSession httpSession) { + if (!SessionUtils.isPasswordChangeRequired(httpSession)) { + return "redirect:/"; + } SecurityContext securityContext = SecurityContextHolder.getContext(); UaaAuthentication authentication = (UaaAuthentication) securityContext.getAuthentication(); UaaPrincipal principal = authentication.getPrincipal(); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java index 3dbe68d06a6..eec0b15e730 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/oauth/ExternalOAuthAuthenticationManager.java @@ -94,7 +94,6 @@ import org.springframework.util.StringUtils; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.client.RestTemplate; -import org.springframework.web.context.request.RequestAttributes; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; @@ -138,6 +137,8 @@ import static org.cloudfoundry.identity.uaa.provider.ExternalIdentityProviderDefinition.PHONE_NUMBER_ATTRIBUTE_NAME; import static org.cloudfoundry.identity.uaa.provider.ExternalIdentityProviderDefinition.USER_NAME_ATTRIBUTE_NAME; import static org.cloudfoundry.identity.uaa.util.JwtTokenSignedByThisUAA.buildIdTokenValidator; +import static org.cloudfoundry.identity.uaa.util.UaaHttpRequestUtils.getInvitationOrigin; +import static org.cloudfoundry.identity.uaa.util.UaaHttpRequestUtils.getInvitedUserId; import static org.cloudfoundry.identity.uaa.util.UaaHttpRequestUtils.isAcceptedInvitationAuthentication; import static org.cloudfoundry.identity.uaa.util.UaaStringUtils.retainAllMatches; import static org.springframework.http.HttpMethod.GET; @@ -563,7 +564,10 @@ protected UaaUser userAuthenticated(Authentication request, UaaUser userFromRequ String email = userFromRequest.getEmail(); log.debug("ExternalOAuth user authenticated:{}", email); if (isInvitationAcceptance) { - String invitedUserId = (String) RequestContextHolder.currentRequestAttributes().getAttribute("user_id", RequestAttributes.SCOPE_SESSION); + if (!Objects.equals(authenticationData.getOrigin(), getInvitationOrigin())) { + throw new BadCredentialsException("OAuth identity provider does not match the invited identity provider."); + } + String invitedUserId = getInvitedUserId(); log.debug("ExternalOAuth user accepted invitation, user_id:{}", invitedUserId); userFromDb = new UaaUser(getUserDatabase().retrieveUserPrototypeById(invitedUserId)); if (email != null && !email.equalsIgnoreCase(userFromDb.getEmail())) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlUaaAuthenticationUserManager.java b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlUaaAuthenticationUserManager.java index e4a491667e6..89998b18024 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlUaaAuthenticationUserManager.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/provider/saml/SamlUaaAuthenticationUserManager.java @@ -52,6 +52,8 @@ import static org.cloudfoundry.identity.uaa.provider.ExternalIdentityProviderDefinition.FAMILY_NAME_ATTRIBUTE_NAME; import static org.cloudfoundry.identity.uaa.provider.ExternalIdentityProviderDefinition.GIVEN_NAME_ATTRIBUTE_NAME; import static org.cloudfoundry.identity.uaa.provider.ExternalIdentityProviderDefinition.PHONE_NUMBER_ATTRIBUTE_NAME; +import static org.cloudfoundry.identity.uaa.util.UaaHttpRequestUtils.getInvitationOrigin; +import static org.cloudfoundry.identity.uaa.util.UaaHttpRequestUtils.getInvitedUserId; import static org.cloudfoundry.identity.uaa.util.UaaHttpRequestUtils.isAcceptedInvitationAuthentication; /** @@ -137,12 +139,19 @@ private UaaUser getAcceptedInvitationUser(UaaPrincipal samlPrincipal, CreateIfMi } context.setAddNew(false); - String invitedUserId = (String) RequestContextHolder.currentRequestAttributes().getAttribute("user_id", RequestAttributes.SCOPE_SESSION); + if (!Objects.equals(samlPrincipal.getOrigin(), getInvitationOrigin())) { + throw new BadCredentialsException("SAML identity provider does not match the invited identity provider."); + } + String invitedUserId = getInvitedUserId(); UaaUser user = userDatabase.retrieveUserById(invitedUserId); if (context.hasEmailAttribute()) { if (!context.getEmailAttribute().equalsIgnoreCase(user.getEmail())) { throw new BadCredentialsException("SAML User email mismatch. Authenticated email doesn't match invited email."); } + } else if (user.isVerified()) { + throw new BadCredentialsException("SAML User email missing. Authenticated email is required because the invited user is already verified."); + } else if (!user.getUsername().equals(user.getEmail())) { + throw new BadCredentialsException("SAML User email missing. Authenticated email is required because the invited user's username differs from the invited email."); } else { context.addEmailAttribute(user.getEmail()); } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/ScimUserProvisioning.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/ScimUserProvisioning.java index 7026305f815..cc1cf412e88 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/ScimUserProvisioning.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/ScimUserProvisioning.java @@ -48,6 +48,13 @@ List retrieveByScimFilterOnlyActive( ScimUser verifyUser(String id, int version, String zoneId) throws ScimResourceNotFoundException, InvalidScimResourceException; + /** + * Atomically replaces the password and verifies an unverified user. + * + * @throws org.springframework.dao.OptimisticLockingFailureException if the user is already verified + */ + ScimUser verifyUserAndSetPassword(String id, String newPassword, boolean passwordChangeRequired, String zoneId) throws ScimResourceNotFoundException; + boolean checkPasswordMatches(String id, CharSequence password, String zoneId) throws ScimResourceNotFoundException; boolean checkPasswordChangeIndividuallyRequired(String id, String zoneId) throws ScimResourceNotFoundException; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/beans/ScimAopConfig.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/beans/ScimAopConfig.java index b85be48b2e0..b15fb1e3bcf 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/beans/ScimAopConfig.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/beans/ScimAopConfig.java @@ -50,6 +50,12 @@ public void userVerified(ScimUser user) { scimEventPublisher.userVerified(user); } + @AfterReturning(pointcut = "execution(* *..ScimUserProvisioning+.verifyUserAndSetPassword(..)) && bean(scimUserProvisioning)", returning = "user") + public void userVerifiedAndPasswordChanged(ScimUser user) { + passwordEventPublisher.passwordChange(user.getId()); + scimEventPublisher.userVerified(user); + } + @AfterReturning(pointcut = "execution(* *..ScimUserEndpoints+.deleteUser(..)) && bean(scimUserEndpoints)", returning = "user") public void userDeleted(ScimUser user) { scimEventPublisher.userDeleted(user); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java index 3bfef100c4a..2b332c3e02d 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpoints.java @@ -455,7 +455,7 @@ public ResponseEntity getUserVerificationLink(@PathVariabl throw new UserAlreadyVerifiedException(); } - ExpiringCode expiringCode = ScimUtils.getExpiringCode(codeStore, userId, user.getPrimaryEmail(), clientId, redirectUri, REGISTRATION, identityZoneManager.getCurrentIdentityZoneId()); + ExpiringCode expiringCode = ScimUtils.getExpiringCode(codeStore, userId, user.getPrimaryEmail(), clientId, redirectUri, REGISTRATION, identityZoneManager.getCurrentIdentityZoneId(), Map.of(ScimUtils.USER_PROVISIONED, "true")); responseBody.setVerifyLink(ScimUtils.getVerificationURL(expiringCode, identityZoneManager.getCurrentIdentityZone())); return new ResponseEntity<>(responseBody, HttpStatus.OK); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioning.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioning.java index 74c6903a457..599faace05b 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioning.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/jdbc/JdbcScimUserProvisioning.java @@ -97,6 +97,8 @@ public Logger getLogger() { public static final String VERIFY_USER_SQL = "update users set verified=? where id=? and identity_zone_id=?"; + public static final String VERIFY_USER_AND_SET_PASSWORD_SQL = "update users set lastModified=?, password=?, passwd_lastmodified=?, passwd_change_required=?, verified=? where id=? and identity_zone_id=? and verified=?"; + public static final String DELETE_USER_SQL = "delete from users where id=? and identity_zone_id=?"; public static final String CHANGE_PASSWORD_SQL = "update users set lastModified=?, password=?, passwd_lastmodified=? where id=? and identity_zone_id=?"; @@ -492,6 +494,32 @@ public ScimUser verifyUser(String id, int version, String zoneId) throws ScimRes return user; } + @Override + public ScimUser verifyUserAndSetPassword(String id, String newPassword, boolean passwordChangeRequired, String zoneId) + throws ScimResourceNotFoundException { + String encodedPassword = passwordEncoder.encode(newPassword); + int updated = jdbcTemplate.update(VERIFY_USER_AND_SET_PASSWORD_SQL, ps -> { + Timestamp timestamp = new Timestamp(System.currentTimeMillis()); + ps.setTimestamp(1, timestamp); + ps.setString(2, encodedPassword); + ps.setTimestamp(3, getPasswordLastModifiedTimestamp(timestamp)); + ps.setBoolean(4, passwordChangeRequired); + ps.setBoolean(5, true); + ps.setString(6, id); + ps.setString(7, zoneId); + ps.setBoolean(8, false); + }); + + ScimUser user = retrieve(id, zoneId); + if (updated == 0) { + throw new OptimisticLockingFailureException("Attempt to activate already verified user " + id); + } + if (updated > 1) { + throw new IncorrectResultSizeDataAccessException(1); + } + return user; + } + protected ScimUser deleteUser(ScimUser user, int version, String zoneId) { int updated = deleteUser(user.getId(), version, zoneId); if (updated == 0) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/util/ScimUtils.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/util/ScimUtils.java index 729c16d2dd6..5c4ab1ff50e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/util/ScimUtils.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/util/ScimUtils.java @@ -24,6 +24,12 @@ public final class ScimUtils { + /** + * Data entry of a verification code that was requested through the user API: the password of the + * account was set by the caller, so opening the link only has to verify the email address. + */ + public static final String USER_PROVISIONED = "user_provisioned"; + private static final Logger logger = LoggerFactory.getLogger(ScimUtils.class); private ScimUtils() { @@ -49,6 +55,21 @@ public static ExpiringCode getExpiringCode( String redirectUri, ExpiringCodeType intent, String currentZoneId) { + return getExpiringCode(codeStore, userId, email, clientId, redirectUri, intent, currentZoneId, Map.of()); + } + + /** + * Same as above, with extra entries for the data stored in the code. + */ + public static ExpiringCode getExpiringCode( + ExpiringCodeStore codeStore, + String userId, + String email, + String clientId, + String redirectUri, + ExpiringCodeType intent, + String currentZoneId, + Map additionalData) { Assert.notNull(codeStore, "codeStore must not be null"); Assert.notNull(userId, "userId must not be null"); Assert.notNull(email, "email must not be null"); @@ -61,6 +82,7 @@ public static ExpiringCode getExpiringCode( if (redirectUri != null) { codeData.put("redirect_uri", redirectUri); } + codeData.putAll(additionalData); String codeDataString = JsonUtils.writeValueAsString(codeData); Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + (60 * 60 * 1000)); // 1 hour diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/util/UaaHttpRequestUtils.java b/server/src/main/java/org/cloudfoundry/identity/uaa/util/UaaHttpRequestUtils.java index 4e35d6d6492..dfa7d351fd4 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/util/UaaHttpRequestUtils.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/util/UaaHttpRequestUtils.java @@ -70,6 +70,9 @@ public abstract class UaaHttpRequestUtils { private static final Logger logger = LoggerFactory.getLogger(UaaHttpRequestUtils.class); + private static final String INVITATION_ACCEPTANCE_ATTRIBUTE = "IS_INVITE_ACCEPTANCE"; + private static final String INVITED_USER_ID_ATTRIBUTE = "user_id"; + private static final String INVITATION_ORIGIN_ATTRIBUTE = "invitation_origin"; record HttpClientConfig( int poolSize, @@ -259,20 +262,42 @@ private static String encodeParameter(String value) { } public static boolean isAcceptedInvitationAuthentication() { + return Boolean.TRUE.equals(getInvitationSessionAttribute(INVITATION_ACCEPTANCE_ATTRIBUTE)); + } + + public static String getInvitedUserId() { + return (String) getInvitationSessionAttribute(INVITED_USER_ID_ATTRIBUTE); + } + + public static String getInvitationOrigin() { + return (String) getInvitationSessionAttribute(INVITATION_ORIGIN_ATTRIBUTE); + } + + public static void setInvitationSessionAttributes(String userId, String origin) { + RequestAttributes attributes = RequestContextHolder.currentRequestAttributes(); + attributes.setAttribute(INVITATION_ACCEPTANCE_ATTRIBUTE, true, RequestAttributes.SCOPE_SESSION); + attributes.setAttribute(INVITED_USER_ID_ATTRIBUTE, userId, RequestAttributes.SCOPE_SESSION); + attributes.setAttribute(INVITATION_ORIGIN_ATTRIBUTE, origin, RequestAttributes.SCOPE_SESSION); + } + + public static void clearInvitationSessionAttributes() { try { - RequestAttributes attr = RequestContextHolder.currentRequestAttributes(); - if (attr != null) { - Boolean result = (Boolean) attr.getAttribute("IS_INVITE_ACCEPTANCE", RequestAttributes.SCOPE_SESSION); - if (result != null) { - return result; - } - } + RequestAttributes attributes = RequestContextHolder.currentRequestAttributes(); + attributes.removeAttribute(INVITATION_ACCEPTANCE_ATTRIBUTE, RequestAttributes.SCOPE_SESSION); + attributes.removeAttribute(INVITED_USER_ID_ATTRIBUTE, RequestAttributes.SCOPE_SESSION); + attributes.removeAttribute(INVITATION_ORIGIN_ATTRIBUTE, RequestAttributes.SCOPE_SESSION); } catch (IllegalStateException _) { - //nothing bound on thread. - logger.debug("Unable to retrieve request attributes looking for invitation."); + logger.debug("Unable to retrieve request attributes while clearing invitation."); + } + } + private static Object getInvitationSessionAttribute(String name) { + try { + return RequestContextHolder.currentRequestAttributes().getAttribute(name, RequestAttributes.SCOPE_SESSION); + } catch (IllegalStateException _) { + logger.debug("Unable to retrieve request attributes looking for invitation."); + return null; } - return false; } private static class UaaConnectionKeepAliveStrategy implements ConnectionKeepAliveStrategy { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/web/UaaSavedRequestAwareAuthenticationSuccessHandler.java b/server/src/main/java/org/cloudfoundry/identity/uaa/web/UaaSavedRequestAwareAuthenticationSuccessHandler.java index 4d7269ddee9..bd2883010e9 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/web/UaaSavedRequestAwareAuthenticationSuccessHandler.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/web/UaaSavedRequestAwareAuthenticationSuccessHandler.java @@ -18,6 +18,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; import lombok.extern.slf4j.Slf4j; import org.cloudfoundry.identity.uaa.util.UaaStringUtils; import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; @@ -33,10 +34,14 @@ import java.util.List; import java.util.Optional; +import static org.cloudfoundry.identity.uaa.util.SessionUtils.SAVED_REQUEST_SESSION_ATTRIBUTE; + @Slf4j public class UaaSavedRequestAwareAuthenticationSuccessHandler extends SavedRequestAwareAuthenticationSuccessHandler { public static final String URI_OVERRIDE_ATTRIBUTE = "override.redirect_uri"; public static final String FORM_REDIRECT_PARAMETER = "form_redirect_uri"; + private static final String TRUSTED_REDIRECT_SESSION_ATTRIBUTE = + UaaSavedRequestAwareAuthenticationSuccessHandler.class.getName() + ".TRUSTED_REDIRECT"; private final HttpSessionRequestCache requestCache = new HttpSessionRequestCache(); @@ -44,6 +49,14 @@ public UaaSavedRequestAwareAuthenticationSuccessHandler() { requestCache.setMatchingRequestParameterName(null); } + public static void saveTrustedRedirect(HttpSession session, String redirectUri) { + if (session == null || !UaaStringUtils.hasText(redirectUri)) { + return; + } + session.setAttribute(TRUSTED_REDIRECT_SESSION_ATTRIBUTE, redirectUri); + session.removeAttribute(SAVED_REQUEST_SESSION_ATTRIBUTE); + } + @Override public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { SavedRequest savedRequest = this.requestCache.getRequest(request, response); @@ -75,15 +88,29 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo @Override public String determineTargetUrl(HttpServletRequest request, HttpServletResponse response) { + String trustedRedirect = consumeTrustedRedirect(request); Object redirectAttribute = request.getAttribute(URI_OVERRIDE_ATTRIBUTE); String redirectFormParam = request.getParameter(FORM_REDIRECT_PARAMETER); if (redirectAttribute != null) { log.debug("Returning redirectAttribute saved URI: {}", redirectAttribute); return (String) redirectAttribute; + } else if (UaaStringUtils.hasText(trustedRedirect)) { + log.debug("Returning server-validated saved URI: {}", trustedRedirect); + return trustedRedirect; } else if (UaaUrlUtils.uriHasMatchingHost(redirectFormParam, request.getServerName())) { return redirectFormParam; } else { return super.determineTargetUrl(request, response); } } + + private String consumeTrustedRedirect(HttpServletRequest request) { + HttpSession session = request.getSession(false); + if (session == null) { + return null; + } + Object redirect = session.getAttribute(TRUSTED_REDIRECT_SESSION_ATTRIBUTE); + session.removeAttribute(TRUSTED_REDIRECT_SESSION_ATTRIBUTE); + return redirect instanceof String string ? string : null; + } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilter.java index adf19d1c93f..7b2c07afc8b 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilter.java @@ -52,12 +52,17 @@ public IdentityZoneResolvingFilter(final IdentityZoneProvisioning dao, private static final String INTERNAL_ERROR_MESSAGE = "Internal server error while fetching identity zone for subdomain "; private static final String NOT_FOUND_MESSAGE = "Cannot find identity zone for subdomain "; + private static final String UNRECOGNIZED_HOSTNAME_MESSAGE = "Cannot find identity zone, unrecognized hostname: "; @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { String effectiveSubdomain = resolveEffectiveSubdomain(request, response); - if (effectiveSubdomain == null && response.isCommitted()) { + if (effectiveSubdomain == null) { + // no zone to look up: either the response is already sent (400), or the host is not a recognized one + if (!response.isCommitted()) { + handleZoneNotFound(null, request, response, filterChain); + } return; } @@ -85,6 +90,10 @@ private String resolveEffectiveSubdomain(HttpServletRequest request, HttpServlet response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Cannot use both subdomain and zone path"); return null; } + if (subdomainFromHost == null && !defaultZoneHostnames.isEmpty()) { + // the zone comes from the path, but the host is still none of the configured root domains + return null; + } return subdomainFromPath; } return subdomainFromHost; @@ -94,9 +103,6 @@ private String resolveEffectiveSubdomain(HttpServletRequest request, HttpServlet * Looks up identity zone by subdomain. On generic exception sends 500 and returns null; caller should check response.isCommitted(). */ private IdentityZone resolveZoneBySubdomain(String subdomain, HttpServletResponse response) throws IOException { - if (subdomain == null) { - return null; - } try { return dao.retrieveBySubdomain(subdomain); } catch (EmptyResultDataAccessException _) { @@ -119,7 +125,8 @@ private void handleZoneNotFound(String subdomain, HttpServletRequest request, Ht return; } request.setAttribute("error_message_code", "zone.not.found"); - response.sendError(HttpServletResponse.SC_NOT_FOUND, NOT_FOUND_MESSAGE + subdomain); + String message = subdomain == null ? UNRECOGNIZED_HOSTNAME_MESSAGE + request.getServerName() : NOT_FOUND_MESSAGE + subdomain; + response.sendError(HttpServletResponse.SC_NOT_FOUND, message); } private void doFilterWithZone(IdentityZone identityZone, HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZonePathContextRewritingFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZonePathContextRewritingFilter.java index 72969fcd961..ebe6be77cd5 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZonePathContextRewritingFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/ZonePathContextRewritingFilter.java @@ -115,7 +115,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (DEFAULT_ZONE_SUBDOMAIN_PATH.equalsIgnoreCase(subdomain)) { // /z/default/... keeps /z/default in context path (like any other zone); same session as root via session key mapping. // Use canonical "default" in path so session key mapping (endsWith "/z/default") matches. - // Do not set ZONE_SUBDOMAIN_FROM_PATH so IdentityZoneResolvingFilter resolves default zone from hostname. + // An empty path subdomain explicitly selects the default zone and prevents a tenant hostname from taking precedence. String newContextPath = getNewContextPath(contextPath, DEFAULT_ZONE_SUBDOMAIN_PATH); HttpServletRequest wrappedRequest = new ZonePathRewrittenRequest( request, @@ -124,6 +124,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse pathResult.pathInfo() ); wrappedRequest.setAttribute(ZONE_ORIGINAL_CONTEXT_PATH, contextPath); + wrappedRequest.setAttribute(ZONE_SUBDOMAIN_FROM_PATH, ""); HttpServletResponse wrappedResponse = new CookiePathRewritingResponse(response, contextPath); filterChain.doFilter(wrappedRequest, wrappedResponse); return; diff --git a/server/src/main/resources/templates/mail/account_exists.html b/server/src/main/resources/templates/mail/account_exists.html new file mode 100644 index 00000000000..5012abe1126 --- /dev/null +++ b/server/src/main/resources/templates/mail/account_exists.html @@ -0,0 +1,22 @@ + + + + + + +

+ A request has been made to use user@example.com for a ExampleService account. +

+

+ This email address already has an account, so no new account was created and your account has not changed. + You can sign in with your existing password, or use the forgot password link on the sign in page if you do not remember it. +

+

+ If you did not make this request, simply ignore this message. +

+

+ Thank you,
+ The Administration +

+ + diff --git a/server/src/main/resources/templates/web/accounts/email_sent.html b/server/src/main/resources/templates/web/accounts/email_sent.html index 389d37b61b0..82ff6d04880 100644 --- a/server/src/main/resources/templates/web/accounts/email_sent.html +++ b/server/src/main/resources/templates/web/accounts/email_sent.html @@ -11,9 +11,12 @@

Create your -

+

Check email for an activation link

+

+ Check user@example.com for an activation link +