From 9b2013ead4b30141321c26f33ab2064582551591 Mon Sep 17 00:00:00 2001 From: Filip Hanik Date: Thu, 29 Oct 2026 11:36:02 -0700 Subject: [PATCH 01/10] fix: confirming a self-service signup leads the user to set a password Confirming the email address of a self-service signup no longer activates the password entered on the signup form. The account is verified after that password is replaced, and the user is sent to the password reset page (force_change=true) to set a password. A signup redirect (an explicit redirect_uri or the client's signup_redirect_url) is applied after the password is set. --- docs/UAA-Configuration-Reference.md | 9 + .../uaa/account/AccountCreationService.java | 10 + .../uaa/account/AccountsController.java | 10 +- .../account/EmailAccountCreationService.java | 29 ++- .../uaa/account/ResetPasswordController.java | 6 +- .../uaa/login/AccountsControllerTest.java | 14 +- .../EmailAccountCreationServiceTests.java | 39 +++ uaa/src/main/resources/messages.properties | 1 + .../integration/feature/CreateAccountIT.java | 50 +++- .../util/IntegrationTestUtils.java | 6 + .../login/AccountsControllerMockMvcTests.java | 223 +++++++++++++++--- ...ccountsControllerMockMvcZonePathTests.java | 99 +++++--- 12 files changed, 408 insertions(+), 88 deletions(-) diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index 6d0abce0c9c..ad3017b1fd9 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -1892,6 +1892,15 @@ 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. Any client or signup redirect configured for the +> registration (an explicit `redirect_uri` or the client's `signup_redirect_url`) is applied after the +> owner has set their password, provided it matches one of the client's registered redirect URIs. + [Back to table](#login--branding) --- 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..3292a91c11a 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 @@ -19,6 +19,8 @@ class AccountCreationResponse { private String email; @JsonProperty("redirect_location") private String redirectLocation; + @JsonProperty("password_reset_code") + private String passwordResetCode; public AccountCreationResponse(String userId, String username, String email, String redirectLocation) { this.userId = userId; @@ -50,5 +52,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..aab576a47a9 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 @@ -124,12 +124,10 @@ 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; - } - 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/EmailAccountCreationService.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/EmailAccountCreationService.java index fab9727183c..3b3603ec3bb 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; @@ -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; } @@ -121,14 +125,33 @@ 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); + + // 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. Discard the registration password so it can never authenticate, and require the + // confirming owner to establish their own password through the ownership-proving link. + // Replace the password and set the flag while the account is still unverified, then verify + // it last: these are separate updates, so verifying first would briefly leave the account + // verified while still holding the registration password, which a concurrent login could use. + scimUserProvisioning.changePassword(user.getId(), null, new AlphanumericRandomValueStringGenerator(40).generate(), zoneId); + scimUserProvisioning.updatePasswordChangeRequired(user.getId(), true, zoneId); + user = scimUserProvisioning.verifyUser(user.getId(), -1, zoneId); String clientId = data.get("client_id"); String redirectUri = data.get("redirect_uri") != null ? data.get("redirect_uri") : ""; + // 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) { 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..3c98b278aaa 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 @@ -176,7 +176,8 @@ 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())); if (expiringCode == null) { @@ -189,6 +190,9 @@ public String resetPasswordPage(Model model, model.addAttribute("code", newCode); 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/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerTest.java index 732fb7efb76..63199a8e9cf 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerTest.java @@ -203,30 +203,32 @@ void passwordMismatch() throws Exception { @Test void verifyUser() throws Exception { - when(accountCreationService.completeActivation("the_secret_code")) - .thenReturn(new AccountCreationService.AccountCreationResponse("newly-created-user-id", "username", "user@example.com", "//example.com/callback")); + AccountCreationService.AccountCreationResponse response = new AccountCreationService.AccountCreationResponse("newly-created-user-id", "username", "user@example.com", "//example.com/callback"); + response.setPasswordResetCode("the_reset_code"); + when(accountCreationService.completeActivation("the_secret_code")).thenReturn(response); MockHttpServletRequestBuilder get = get("/verify_user") .param("code", "the_secret_code"); mockMvc.perform(get) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/login?success=verify_success&form_redirect_uri=//example.com/callback")); + .andExpect(redirectedUrl("/reset_password?code=the_reset_code&force_change=true")); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); } @Test void verifyUserWithPriorHeadRequest() throws Exception { - when(accountCreationService.completeActivation("the_secret_code")) - .thenReturn(new AccountCreationService.AccountCreationResponse("newly-created-user-id", "username", "user@example.com", "//example.com/callback")); + AccountCreationService.AccountCreationResponse response = new AccountCreationService.AccountCreationResponse("newly-created-user-id", "username", "user@example.com", "//example.com/callback"); + response.setPasswordResetCode("the_reset_code"); + when(accountCreationService.completeActivation("the_secret_code")).thenReturn(response); mockMvc.perform(head("/verify_user").param("code", "the_secret_code")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/login")); mockMvc.perform(get("/verify_user").param("code", "the_secret_code")) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/login?success=verify_success&form_redirect_uri=//example.com/callback")); + .andExpect(redirectedUrl("/reset_password?code=the_reset_code&force_change=true")); assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); Mockito.verify(accountCreationService, times(1)).completeActivation("the_secret_code"); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/login/EmailAccountCreationServiceTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/login/EmailAccountCreationServiceTests.java index eb1e2bde8bc..edf7870ebe8 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/login/EmailAccountCreationServiceTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/login/EmailAccountCreationServiceTests.java @@ -3,6 +3,8 @@ import org.assertj.core.api.InstanceOfAssertFactories; import org.cloudfoundry.identity.uaa.account.AccountCreationService; import org.cloudfoundry.identity.uaa.account.EmailAccountCreationService; +import org.cloudfoundry.identity.uaa.account.ForgotPasswordInfo; +import org.cloudfoundry.identity.uaa.account.ResetPasswordService; import org.cloudfoundry.identity.uaa.codestore.ExpiringCode; import org.cloudfoundry.identity.uaa.codestore.ExpiringCodeStore; import org.cloudfoundry.identity.uaa.constants.OriginKeys; @@ -30,6 +32,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import org.mockito.InOrder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.http.HttpStatus; @@ -50,7 +53,9 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.codestore.ExpiringCodeType.REGISTRATION; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.isNull; import static org.mockito.Mockito.anyInt; +import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.anyString; import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.eq; @@ -72,6 +77,7 @@ class EmailAccountCreationServiceTests { private MultitenantClientServices mockClientDetailsService; private ClientDetails mockClientDetails; private PasswordValidator mockPasswordValidator; + private ResetPasswordService mockResetPasswordService; private IdentityZoneManager mockIdentityZoneManager; private ScimUser user; private ExpiringCode code; @@ -91,6 +97,7 @@ void setUp() { mockClientDetailsService = mock(MultitenantClientServices.class); mockClientDetails = mock(ClientDetails.class); mockPasswordValidator = mock(PasswordValidator.class); + mockResetPasswordService = mock(ResetPasswordService.class); mockIdentityZoneManager = mock(IdentityZoneManager.class); emailAccountCreationService = initEmailAccountCreationService(); @@ -104,6 +111,10 @@ void setUp() { currentIdentityZoneId = "zoneId" + randomValueStringGenerator.generate(); when(mockIdentityZoneManager.getCurrentIdentityZoneId()).thenReturn(currentIdentityZoneId); + + ExpiringCode resetCode = new ExpiringCode("reset_password_code", new Timestamp(System.currentTimeMillis()), "{}", "forgot_password_for_id:newly-created-user-id"); + when(mockResetPasswordService.forgotPassword(anyString(), any(), any())) + .thenReturn(new ForgotPasswordInfo("newly-created-user-id", "user@example.com", resetCode)); } private EmailAccountCreationService initEmailAccountCreationService() { @@ -114,6 +125,7 @@ private EmailAccountCreationService initEmailAccountCreationService() { mockScimUserProvisioning, mockClientDetailsService, mockPasswordValidator, + mockResetPasswordService, mockIdentityZoneManager ); } @@ -258,6 +270,33 @@ void completeActivation() { assertThat(accountCreation.getUserId()).isNotNull(); } + @Test + void completeActivationDiscardsRegistrationPasswordAndRequiresPasswordReset() { + setUpForSuccess(""); + when(mockCodeStore.retrieveCode("the_secret_code", currentIdentityZoneId)).thenReturn(code); + when(mockScimUserProvisioning.retrieve(anyString(), eq(currentIdentityZoneId))).thenReturn(user); + when(mockScimUserProvisioning.verifyUser(anyString(), anyInt(), eq(currentIdentityZoneId))).thenReturn(user); + when(mockClientDetailsService.loadClientByClientId(anyString(), anyString())).thenReturn(mockClientDetails); + + AccountCreationService.AccountCreationResponse accountCreation = emailAccountCreationService.completeActivation("the_secret_code"); + + // The password supplied on the registration form must be discarded (replaced with a value + // that is not it) and the change-required flag set while the account is still unverified, + // and the account must be verified last. Verifying first would briefly leave the account + // verified while still accepting the registration password. + ArgumentCaptor passwordCaptor = ArgumentCaptor.forClass(String.class); + InOrder inOrder = inOrder(mockScimUserProvisioning); + inOrder.verify(mockScimUserProvisioning).changePassword(eq("newly-created-user-id"), isNull(), passwordCaptor.capture(), eq(currentIdentityZoneId)); + inOrder.verify(mockScimUserProvisioning).updatePasswordChangeRequired("newly-created-user-id", true, currentIdentityZoneId); + inOrder.verify(mockScimUserProvisioning).verifyUser(eq("newly-created-user-id"), anyInt(), eq(currentIdentityZoneId)); + assertThat(passwordCaptor.getValue()).isNotEqualTo("password"); + + // An ownership-bound reset code must be issued so the confirming owner can set their own + // password through the link. + verify(mockResetPasswordService).forgotPassword(eq("user@example.com"), anyString(), anyString()); + assertThat(accountCreation.getPasswordResetCode()).isEqualTo("reset_password_code"); + } + @Test void completeActivation_usesAntPathMatching() { setUpForSuccess("http://redirect.uri/"); diff --git a/uaa/src/main/resources/messages.properties b/uaa/src/main/resources/messages.properties index 8cac851020c..80ec61c0705 100644 --- a/uaa/src/main/resources/messages.properties +++ b/uaa/src/main/resources/messages.properties @@ -39,6 +39,7 @@ change_password.unauthorized=Please enter a valid current password. forgot_password.form_error=Please enter a valid email address. forgot_password.bad_code=Sorry, your reset password link is no longer valid. You can request another one below. reset_password.form_error=Passwords must match and not be empty. +reset_password.force_change=A password change is required due to changes in the system. Please set a new password. create_account.form_error=Passwords must match and not be empty. create_account.other_idp=Account sign-up is not required for this email domain. Please login with the identity provider force_password_change.form_error=Passwords must match and not be empty. diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/CreateAccountIT.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/CreateAccountIT.java index e0db98e33e7..bdd63968a5a 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/CreateAccountIT.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/CreateAccountIT.java @@ -95,8 +95,14 @@ void userInitiatedSignup() throws Exception { .doesNotContain("%40"); webDriver.get(link); - assertThat(webDriver.findElement(By.cssSelector("h1")).getText()).doesNotContain("Where to?"); + // Confirming the account lands on the forced password-set page, not a sign-in page. + assertThat(webDriver.findElement(By.cssSelector("h1")).getText()).isEqualTo("Reset Password"); + webDriver.findElement(By.name("password")).sendKeys(SECRET); + webDriver.findElement(By.name("password_confirmation")).sendKeys(SECRET); + webDriver.clickAndWait(By.xpath("//input[@value='Create new password']")); + + // The owner can now sign in with the password they set. webDriver.findElement(By.name("username")).sendKeys(userEmail); webDriver.findElement(By.name("password")).sendKeys(SECRET); webDriver.clickAndWait(By.xpath("//input[@value='Sign in']")); @@ -104,6 +110,40 @@ void userInitiatedSignup() throws Exception { assertThat(webDriver.findElement(By.cssSelector("h1")).getText()).contains("Where to?"); } + @Test + void confirmationDoesNotActivateTheRegistrationFormPassword() throws Exception { + String registrationPassword = SECRET; + String ownerPassword = "0wnerPass"; + + int receivedEmailSize = greenMail.getReceivedMessages().length; + String userEmail = startCreateUserFlow(registrationPassword); + + greenMail.waitForIncomingEmail(5000, receivedEmailSize + 1); + MimeMessage message = greenMail.getReceivedMessages()[receivedEmailSize]; + String link = testClient.extractLink(GreenMailUtil.getBody(message)); + + webDriver.get(link); + // Confirmation requires the owner to establish their own password through the link. + assertThat(webDriver.findElement(By.cssSelector("h1")).getText()).isEqualTo("Reset Password"); + webDriver.findElement(By.name("password")).sendKeys(ownerPassword); + webDriver.findElement(By.name("password_confirmation")).sendKeys(ownerPassword); + webDriver.clickAndWait(By.xpath("//input[@value='Create new password']")); + + // The password typed on the registration form must not grant access. + webDriver.get(baseUrl + "/login"); + webDriver.findElement(By.name("username")).sendKeys(userEmail); + webDriver.findElement(By.name("password")).sendKeys(registrationPassword); + webDriver.clickAndWait(By.xpath("//input[@value='Sign in']")); + assertThat(webDriver.findElement(By.className("alert-error")).getText()).contains("Provided credentials are invalid. Please try again."); + + // Only the password the owner set through the link works. + webDriver.get(baseUrl + "/login"); + webDriver.findElement(By.name("username")).sendKeys(userEmail); + webDriver.findElement(By.name("password")).sendKeys(ownerPassword); + webDriver.clickAndWait(By.xpath("//input[@value='Sign in']")); + assertThat(webDriver.findElement(By.cssSelector("h1")).getText()).contains("Where to?"); + } + @Test void clientInitiatedSignup() throws Exception { String userEmail = "user" + new SecureRandom().nextInt() + "@example.com"; @@ -130,8 +170,14 @@ void clientInitiatedSignup() throws Exception { assertThat(link).isNotEmpty(); webDriver.get(link); - assertThat(webDriver.findElement(By.cssSelector("h1")).getText()).doesNotContain("Where to?"); + // Confirming the account lands on the forced password-set page, not a sign-in page. + assertThat(webDriver.findElement(By.cssSelector("h1")).getText()).isEqualTo("Reset Password"); + + webDriver.findElement(By.name("password")).sendKeys(SECRET); + webDriver.findElement(By.name("password_confirmation")).sendKeys(SECRET); + webDriver.clickAndWait(By.xpath("//input[@value='Create new password']")); + // The owner can now sign in with the password they set. webDriver.findElement(By.name("username")).sendKeys(userEmail); webDriver.findElement(By.name("password")).sendKeys(SECRET); webDriver.clickAndWait(By.xpath("//input[@value='Sign in']")); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/util/IntegrationTestUtils.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/util/IntegrationTestUtils.java index 922c8083d56..96b8d340409 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/util/IntegrationTestUtils.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/util/IntegrationTestUtils.java @@ -1618,6 +1618,12 @@ public static String createAnotherUser(UaaWebDriver webDriver, String password, throw new RuntimeException(e); } + // Confirming the account now lands on a forced password-set page; the owner must establish + // their password through the link before the account can be used to log in. + webDriver.findElement(By.name("password")).sendKeys(password); + webDriver.findElement(By.name("password_confirmation")).sendKeys(password); + webDriver.clickAndWait(By.xpath("//input[@value='Create new password']")); + return userEmail; } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcTests.java index 288012441b1..92fdf613db7 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcTests.java @@ -42,6 +42,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.support.StandardServletEnvironment; +import org.springframework.web.util.UriComponentsBuilder; import java.util.Collections; @@ -49,6 +50,7 @@ import static org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.CookieCsrfPostProcessor.cookieCsrf; import static org.springframework.http.MediaType.APPLICATION_JSON; import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; +import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.unauthenticated; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; @@ -62,7 +64,6 @@ @DefaultTestContext class AccountsControllerMockMvcTests { - private static final String LOGIN_REDIRECT = "/login?success=verify_success"; private static final String USER_PASSWORD = "secr3T"; private final AlphanumericRandomValueStringGenerator generator = new AlphanumericRandomValueStringGenerator(); private String userEmail; @@ -241,11 +242,7 @@ void creatingAnAccount() throws Exception { ScimUser scimUser = scimUserProvisioning.query("userName eq '" + userEmail + "' and origin eq '" + OriginKeys.UAA + "'", IdentityZoneHolder.get().getId()).getFirst(); assertThat(scimUser.isVerified()).isFalse(); - mockMvc.perform(get("/verify_user") - .param("code", "test" + generator.counter.get())) - .andExpect(status().isFound()) - .andExpect(redirectedUrl(LOGIN_REDIRECT)) - .andReturn(); + confirmAccountAndSetPassword("test" + generator.counter.get(), "", userEmail); MvcResult mvcResult = loginWithAccount("") .andExpect(authenticated()) @@ -260,26 +257,146 @@ void creatingAnAccount() throws Exception { } @Test - void creatingAnAccountWithAnEmptyClientId() throws Exception { + void confirmingAccountDoesNotAuthorizeThePreRegisteredPassword() throws Exception { PredictableGenerator generator = new PredictableGenerator(); JdbcExpiringCodeStore store = webApplicationContext.getBean(JdbcExpiringCodeStore.class); store.setGenerator(generator); + String registrationPassword = "firstPass1"; + mockMvc.perform(post("/create_account.do") .with(cookieCsrf()) .param("email", userEmail) - .param("password", "secr3T") - .param("password_confirmation", "secr3T") - .param("client_id", "")) + .param("password", registrationPassword) + .param("password_confirmation", registrationPassword)) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("accounts/email_sent")); + + mockMvc.perform(get("/verify_user") + .param("code", "test" + generator.counter.get())) + .andExpect(status().isFound()) + .andReturn(); + + // Clicking the confirmation link only proves ownership of the email address. The + // password supplied on the registration form must not become a usable credential once + // the account is confirmed; the owner must establish the password through the link. + mockMvc.perform(post("/login.do") + .with(cookieCsrf()) + .param("username", userEmail) + .param("password", registrationPassword)) + .andExpect(unauthenticated()); + } + + @Test + void confirmingAccountDoesNotAuthorizePasswordFromEarlierRegistration() throws Exception { + PredictableGenerator generator = new PredictableGenerator(); + JdbcExpiringCodeStore store = webApplicationContext.getBean(JdbcExpiringCodeStore.class); + store.setGenerator(generator); + + String firstRegistrationPassword = "firstPass1"; + String laterRegistrationPassword = "laterPass2"; + + // First registration for the address. + mockMvc.perform(post("/create_account.do") + .with(cookieCsrf()) + .param("email", userEmail) + .param("password", firstRegistrationPassword) + .param("password_confirmation", firstRegistrationPassword)) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("accounts/email_sent")); + + // A later registration for the same still-unconfirmed address. + mockMvc.perform(post("/create_account.do") + .with(cookieCsrf()) + .param("email", userEmail) + .param("password", laterRegistrationPassword) + .param("password_confirmation", laterRegistrationPassword)) .andExpect(status().isFound()) .andExpect(redirectedUrl("accounts/email_sent")); + // The owner confirms the account using the most recent link. mockMvc.perform(get("/verify_user") .param("code", "test" + generator.counter.get())) .andExpect(status().isFound()) - .andExpect(redirectedUrl(LOGIN_REDIRECT)) .andReturn(); + // Confirmation must not authorize the password chosen by the earlier registration. + mockMvc.perform(post("/login.do") + .with(cookieCsrf()) + .param("username", userEmail) + .param("password", firstRegistrationPassword)) + .andExpect(unauthenticated()); + } + + @Test + void confirmingAccountSendsOwnerToForcedPasswordSetup() throws Exception { + PredictableGenerator generator = new PredictableGenerator(); + JdbcExpiringCodeStore store = webApplicationContext.getBean(JdbcExpiringCodeStore.class); + store.setGenerator(generator); + + mockMvc.perform(post("/create_account.do") + .with(cookieCsrf()) + .param("email", userEmail) + .param("password", "secr3T") + .param("password_confirmation", "secr3T")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("accounts/email_sent")); + + // Confirming the account sends the owner to the reset-password page to establish a password. + MvcResult verifyResult = mockMvc.perform(get("/verify_user") + .param("code", "test" + generator.counter.get())) + .andExpect(status().isFound()) + .andReturn(); + + String location = verifyResult.getResponse().getRedirectedUrl(); + assertThat(location) + .startsWith("/reset_password?code=") + .contains("force_change=true"); + String resetCode = UriComponentsBuilder.fromUriString(location).build().getQueryParams().getFirst("code"); + + // The page explains that a password change is required due to changes in the system. + MvcResult pageResult = mockMvc.perform(get("/reset_password") + .param("code", resetCode) + .param("force_change", "true")) + .andExpect(status().isOk()) + .andExpect(view().name("reset_password")) + .andExpect(model().attribute("message_code", "force_change")) + .andReturn(); + String formCode = (String) pageResult.getModelAndView().getModel().get("code"); + + mockMvc.perform(post("/reset_password.do") + .with(cookieCsrf()) + .param("code", formCode) + .param("email", userEmail) + .param("password", USER_PASSWORD) + .param("password_confirmation", USER_PASSWORD)) + .andExpect(status().isFound()); + + // Once the owner has set their own password, the account can be used to log in. + mockMvc.perform(post("/login.do") + .with(cookieCsrf()) + .param("username", userEmail) + .param("password", USER_PASSWORD)) + .andExpect(authenticated()); + } + + @Test + void creatingAnAccountWithAnEmptyClientId() throws Exception { + PredictableGenerator generator = new PredictableGenerator(); + JdbcExpiringCodeStore store = webApplicationContext.getBean(JdbcExpiringCodeStore.class); + store.setGenerator(generator); + + mockMvc.perform(post("/create_account.do") + .with(cookieCsrf()) + .param("email", userEmail) + .param("password", "secr3T") + .param("password_confirmation", "secr3T") + .param("client_id", "")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("accounts/email_sent")); + + confirmAccountAndSetPassword("test" + generator.counter.get(), "", userEmail); + MvcResult mvcResult = loginWithAccount("") .andExpect(authenticated()) .andReturn(); @@ -320,11 +437,7 @@ void creatingAnAccountWithNoClientRedirect() throws Exception { assertThat(message.getContentString()).contains("Cloud Foundry"); assertThat(message.getMessage().getHeader("From")).contains("Cloud Foundry "); - mockMvc.perform(get("/verify_user") - .param("code", "test" + generator.counter.get())) - .andExpect(status().isFound()) - .andExpect(redirectedUrl(LOGIN_REDIRECT)) - .andReturn(); + confirmAccountAndSetPassword("test" + generator.counter.get(), "", userEmail); MvcResult mvcResult = loginWithAccount("") .andExpect(authenticated()) @@ -375,12 +488,7 @@ void creatingAnAccountInAnotherZoneWithNoClientRedirect() throws Exception { assertThat(hasLength(link)).isTrue(); assertThat(link).contains(subdomain + ".localhost"); - mockMvc.perform(get("/verify_user") - .param("code", "test" + generator.counter.get()) - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost"))) - .andExpect(status().isFound()) - .andExpect(redirectedUrl(LOGIN_REDIRECT)) - .andReturn(); + confirmAccountAndSetPassword("test" + generator.counter.get(), subdomain, userEmail); MvcResult mvcResult = loginWithAccount(subdomain) .andExpect(redirectedUrl("/")) @@ -425,11 +533,8 @@ void creatingAnAccountInAnotherZoneWithClientRedirect() throws Exception { assertThat(hasLength(link)).isTrue(); assertThat(link).contains(subdomain + ".localhost"); - mockMvc.perform(get("/verify_user") - .param("code", "test" + generator.counter.get()) - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost"))) - .andExpect(redirectedUrl(LOGIN_REDIRECT + "&form_redirect_uri=http://myzoneclient.example.com")) - .andReturn(); + String afterReset = confirmAccountAndSetPassword("test" + generator.counter.get(), subdomain, userEmail); + assertThat(afterReset).isEqualTo("/login?success=password_reset&form_redirect_uri=http://myzoneclient.example.com"); MvcResult mvcResult = loginWithAccount(subdomain) .andExpect(authenticated()) @@ -468,14 +573,29 @@ void redirectToSavedRequest_ifPresent() throws Exception { .param("password_confirmation", "test-password")) .andExpect(redirectedUrl("accounts/email_sent")); - mockMvc.perform(get("/verify_user") + MvcResult verifyResult = mockMvc.perform(get("/verify_user") .session(session) .param("code", "test" + generator.counter.get())) .andExpect(status().isFound()) - .andExpect(redirectedUrl(LOGIN_REDIRECT)) .andReturn(); + String location = verifyResult.getResponse().getRedirectedUrl(); + assertThat(location) + .startsWith("/reset_password?code=") + .contains("force_change=true"); + assertThat(SessionUtils.getSavedRequestSession(MockMvcUtils.getZoneSession(session)).getRedirectUrl()).isNotNull(); + + // Consume the reset code so the predictable code sequence does not collide with later tests. + String resetCode = UriComponentsBuilder.fromUriString(location).build().getQueryParams().getFirst("code"); + mockMvc.perform(post("/reset_password.do") + .with(cookieCsrf()) + .session(session) + .param("code", resetCode) + .param("email", "testuser@test.org") + .param("password", USER_PASSWORD) + .param("password_confirmation", USER_PASSWORD)) + .andExpect(status().isFound()); } @Test @@ -600,11 +720,8 @@ private void createAccount(String expectedRedirectUri, String redirectUri) throw assertThat(message.getContentString()).contains("Cloud Foundry"); assertThat(message.getMessage().getHeader("From")).contains("Cloud Foundry "); - mockMvc.perform(get("/verify_user") - .param("code", "test" + generator.counter.get())) - .andExpect(status().isFound()) - .andExpect(redirectedUrl(LOGIN_REDIRECT + "&form_redirect_uri=" + expectedRedirectUri)) - .andReturn(); + String afterReset = confirmAccountAndSetPassword("test" + generator.counter.get(), "", userEmail); + assertThat(afterReset).isEqualTo("/login?success=password_reset&form_redirect_uri=" + expectedRedirectUri); MvcResult mvcResult = loginWithAccount("") .andExpect(authenticated()) @@ -618,6 +735,44 @@ private void createAccount(String expectedRedirectUri, String redirectUri) throw assertThat(principal.getOrigin()).isEqualTo(OriginKeys.UAA); } + /** + * Account confirmation no longer leaves a usable password: clicking the activation link sends + * the owner to the reset-password page to establish their own password. This helper completes + * that flow, confirming the account via {@code /verify_user} and setting the password to + * {@link #USER_PASSWORD} so the account can then be used to log in. + */ + private String confirmAccountAndSetPassword(String activationCode, String subdomain, String email) throws Exception { + MockHttpServletRequestBuilder verify = get("/verify_user").param("code", activationCode); + if (hasText(subdomain)) { + verify.with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")); + } + MvcResult verifyResult = mockMvc.perform(verify) + .andExpect(status().isFound()) + .andReturn(); + + String location = verifyResult.getResponse().getRedirectedUrl(); + assertThat(location) + .startsWith("/reset_password?code=") + .contains("force_change=true"); + String resetCode = UriComponentsBuilder.fromUriString(location).build().getQueryParams().getFirst("code"); + + MockHttpServletRequestBuilder reset = post("/reset_password.do") + .with(cookieCsrf()) + .param("code", resetCode) + .param("email", email) + .param("password", USER_PASSWORD) + .param("password_confirmation", USER_PASSWORD); + if (hasText(subdomain)) { + reset.with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")); + } + // Returns the post-reset redirect so callers can assert the carried signup redirect. + return mockMvc.perform(reset) + .andExpect(status().isFound()) + .andReturn() + .getResponse() + .getRedirectedUrl(); + } + private ResultActions loginWithAccount(String subdomain) throws Exception { MockHttpServletRequestBuilder req = post("/login.do") diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcZonePathTests.java index e510aedcc09..9ab1efa2069 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcZonePathTests.java @@ -45,6 +45,7 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.support.StandardServletEnvironment; +import org.springframework.web.util.UriComponentsBuilder; import org.cloudfoundry.identity.uaa.extensions.EnabledIfZonePathsEnabled; import java.util.Collections; @@ -61,12 +62,12 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.xpath; import static org.springframework.util.StringUtils.hasLength; +import static org.springframework.util.StringUtils.hasText; @DefaultTestContext @EnabledIfZonePathsEnabled class AccountsControllerMockMvcZonePathTests { - private static final String LOGIN_REDIRECT = "/login?success=verify_success"; private static final String USER_PASSWORD = "secr3T"; private final AlphanumericRandomValueStringGenerator generator = new AlphanumericRandomValueStringGenerator(); private String userEmail; @@ -303,11 +304,7 @@ void creatingAnAccount() throws Exception { ScimUser scimUser = scimUserProvisioning.query("userName eq '" + userEmail + "' and origin eq '" + OriginKeys.UAA + "'", IdentityZoneHolder.get().getId()).getFirst(); assertThat(scimUser.isVerified()).isFalse(); - mockMvc.perform(get("/verify_user") - .param("code", "test" + generator.counter.get())) - .andExpect(status().isFound()) - .andExpect(redirectedUrl(LOGIN_REDIRECT)) - .andReturn(); + confirmAccountAndSetPassword(ZoneResolutionMode.SUBDOMAIN, "", "test" + generator.counter.get(), userEmail); MvcResult mvcResult = loginWithAccount("") .andExpect(authenticated()) @@ -336,11 +333,7 @@ void creatingAnAccountWithAnEmptyClientId() throws Exception { .andExpect(status().isFound()) .andExpect(redirectedUrl("accounts/email_sent")); - mockMvc.perform(get("/verify_user") - .param("code", "test" + generator.counter.get())) - .andExpect(status().isFound()) - .andExpect(redirectedUrl(LOGIN_REDIRECT)) - .andReturn(); + confirmAccountAndSetPassword(ZoneResolutionMode.SUBDOMAIN, "", "test" + generator.counter.get(), userEmail); MvcResult mvcResult = loginWithAccount("") .andExpect(authenticated()) @@ -382,11 +375,7 @@ void creatingAnAccountWithNoClientRedirect() throws Exception { assertThat(message.getContentString()).contains("Cloud Foundry"); assertThat(message.getMessage().getHeader("From")).contains("Cloud Foundry "); - mockMvc.perform(get("/verify_user") - .param("code", "test" + generator.counter.get())) - .andExpect(status().isFound()) - .andExpect(redirectedUrl(LOGIN_REDIRECT)) - .andReturn(); + confirmAccountAndSetPassword(ZoneResolutionMode.SUBDOMAIN, "", "test" + generator.counter.get(), userEmail); MvcResult mvcResult = loginWithAccount("") .andExpect(authenticated()) @@ -441,12 +430,7 @@ void creatingAnAccountInAnotherZoneWithNoClientRedirect(ZoneResolutionMode mode) assertThat(link).contains(subdomain + ".localhost"); } - String expectedVerifyRedirect = mode == ZoneResolutionMode.ZONE_PATH ? "/z/" + subdomain + "/login?success=verify_success" : LOGIN_REDIRECT; - mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/verify_user") - .param("code", "test" + generator.counter.get())) - .andExpect(status().isFound()) - .andExpect(redirectedUrl(expectedVerifyRedirect)) - .andReturn(); + confirmAccountAndSetPassword(mode, subdomain, "test" + generator.counter.get(), userEmail); MvcResult mvcResult = loginWithAccount(subdomain) .andExpect(redirectedUrl("/")) @@ -495,13 +479,11 @@ void creatingAnAccountInAnotherZoneWithClientRedirect(ZoneResolutionMode mode) t assertThat(link).contains(subdomain + ".localhost"); } - String expectedVerifyRedirect = mode == ZoneResolutionMode.ZONE_PATH - ? "/z/" + subdomain + "/login?success=verify_success&form_redirect_uri=http://myzoneclient.example.com" - : LOGIN_REDIRECT + "&form_redirect_uri=http://myzoneclient.example.com"; - mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/verify_user") - .param("code", "test" + generator.counter.get())) - .andExpect(redirectedUrl(expectedVerifyRedirect)) - .andReturn(); + String afterReset = confirmAccountAndSetPassword(mode, subdomain, "test" + generator.counter.get(), userEmail); + String expectedAfterReset = mode == ZoneResolutionMode.ZONE_PATH + ? "/z/" + subdomain + "/login?success=password_reset&form_redirect_uri=http://myzoneclient.example.com" + : "/login?success=password_reset&form_redirect_uri=http://myzoneclient.example.com"; + assertThat(afterReset).isEqualTo(expectedAfterReset); MvcResult mvcResult = loginWithAccount(subdomain) .andExpect(authenticated()) @@ -540,14 +522,29 @@ void redirectToSavedRequest_ifPresent() throws Exception { .param("password_confirmation", "test-password")) .andExpect(redirectedUrl("accounts/email_sent")); - mockMvc.perform(get("/verify_user") + MvcResult verifyResult = mockMvc.perform(get("/verify_user") .session(session) .param("code", "test" + generator.counter.get())) .andExpect(status().isFound()) - .andExpect(redirectedUrl(LOGIN_REDIRECT)) .andReturn(); + String location = verifyResult.getResponse().getRedirectedUrl(); + assertThat(location) + .startsWith("/reset_password?code=") + .contains("force_change=true"); + assertThat(SessionUtils.getSavedRequestSession(MockMvcUtils.getZoneSession(session)).getRedirectUrl()).isNotNull(); + + // Consume the reset code so the predictable code sequence does not collide with later tests. + String resetCode = UriComponentsBuilder.fromUriString(location).build().getQueryParams().getFirst("code"); + mockMvc.perform(post("/reset_password.do") + .with(cookieCsrf()) + .session(session) + .param("code", resetCode) + .param("email", "testuser@test.org") + .param("password", USER_PASSWORD) + .param("password_confirmation", USER_PASSWORD)) + .andExpect(status().isFound()); } @Test @@ -669,11 +666,8 @@ private void createAccount(String expectedRedirectUri, String redirectUri) throw assertThat(message.getContentString()).contains("Cloud Foundry"); assertThat(message.getMessage().getHeader("From")).contains("Cloud Foundry "); - mockMvc.perform(get("/verify_user") - .param("code", "test" + generator.counter.get())) - .andExpect(status().isFound()) - .andExpect(redirectedUrl(LOGIN_REDIRECT + "&form_redirect_uri=" + expectedRedirectUri)) - .andReturn(); + String afterReset = confirmAccountAndSetPassword(ZoneResolutionMode.SUBDOMAIN, "", "test" + generator.counter.get(), userEmail); + assertThat(afterReset).isEqualTo("/login?success=password_reset&form_redirect_uri=" + expectedRedirectUri); MvcResult mvcResult = loginWithAccount("") .andExpect(authenticated()) @@ -687,6 +681,39 @@ private void createAccount(String expectedRedirectUri, String redirectUri) throw assertThat(principal.getOrigin()).isEqualTo(OriginKeys.UAA); } + /** + * Account confirmation no longer leaves a usable password: clicking the activation link sends + * the owner to the reset-password page to establish their own password. This helper completes + * that flow (zone-resolution aware), confirming the account via {@code /verify_user} and setting + * the password to {@link #USER_PASSWORD} so the account can then be used to log in. + */ + private String confirmAccountAndSetPassword(ZoneResolutionMode mode, String subdomain, String activationCode, String email) throws Exception { + MvcResult verifyResult = mockMvc.perform(mode.createRequestBuilder(subdomain != null ? subdomain : "", HttpMethod.GET, "/verify_user") + .param("code", activationCode)) + .andExpect(status().isFound()) + .andReturn(); + + String location = verifyResult.getResponse().getRedirectedUrl(); + String expectedPrefix = (mode == ZoneResolutionMode.ZONE_PATH && hasText(subdomain)) + ? "/z/" + subdomain + "/reset_password?code=" : "/reset_password?code="; + assertThat(location) + .startsWith(expectedPrefix) + .contains("force_change=true"); + String resetCode = UriComponentsBuilder.fromUriString(location).build().getQueryParams().getFirst("code"); + + // Returns the post-reset redirect so callers can assert the carried signup redirect. + return mockMvc.perform(mode.createRequestBuilder(subdomain != null ? subdomain : "", HttpMethod.POST, "/reset_password.do") + .with(cookieCsrf()) + .param("code", resetCode) + .param("email", email) + .param("password", USER_PASSWORD) + .param("password_confirmation", USER_PASSWORD)) + .andExpect(status().isFound()) + .andReturn() + .getResponse() + .getRedirectedUrl(); + } + private ResultActions loginWithAccount(String subdomain) throws Exception { return loginWithAccount(ZoneResolutionMode.SUBDOMAIN, subdomain); } From 03313203e9a3ec03011ebf2330121097892b8dd1 Mon Sep 17 00:00:00 2001 From: Filip Hanik Date: Thu, 29 Oct 2026 11:36:03 -0700 Subject: [PATCH 02/10] fix: redirects after email change, invitation acceptance and password reset - Email change verification only redirects to a requested redirect_uri that matches a registered redirect URI of the client, or to the client's change_email_redirect_url; otherwise the user goes to the profile page (or the login page when signed out). - The URL carried to /login as form_redirect_uri is URL-encoded after email change, invitation acceptance and password reset, so it arrives as one parameter. --- .../uaa/account/ChangeEmailController.java | 4 +- .../uaa/account/EmailChangeEmailService.java | 4 +- .../ResetPasswordAuthenticationFilter.java | 4 +- .../invitations/InvitationsController.java | 2 +- .../account/EmailChangeEmailServiceTest.java | 47 +++++++- ...ResetPasswordAuthenticationFilterTest.java | 24 +++- .../InvitationsControllerTest.java | 41 +++++++ .../InvitationsControllerZonePathTest.java | 43 ++++++++ .../uaa/login/ChangeEmailControllerTest.java | 43 +++++++- .../integration/feature/ResetPasswordIT.java | 80 ++++++++++---- .../login/AccountsControllerMockMvcTests.java | 21 +++- ...ccountsControllerMockMvcZonePathTests.java | 24 +++- .../login/InvitationsServiceMockMvcTests.java | 51 ++++++++- ...nvitationsServiceMockMvcZonePathTests.java | 15 ++- .../identity/uaa/login/LoginMockMvcTests.java | 100 +++++++++++++++++ .../uaa/login/LoginMockMvcZonePathTests.java | 104 ++++++++++++++++++ .../PasswordResetEndpointMockMvcTests.java | 18 ++- 17 files changed, 582 insertions(+), 43 deletions(-) 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..780d0933f52 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 @@ -25,6 +25,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 @@ -125,7 +127,7 @@ 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; + 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/EmailChangeEmailService.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/EmailChangeEmailService.java index d6967aa20e7..9d355761209 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 @@ -127,7 +127,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 } 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..13d03c61062 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 @@ -34,6 +34,8 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.sql.Timestamp; public class ResetPasswordAuthenticationFilter extends OncePerRequestFilter { @@ -82,7 +84,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse 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); + response.sendRedirect(request.getContextPath() + "/login?success=password_reset&form_redirect_uri=" + URLEncoder.encode(redirectUri, StandardCharsets.UTF_8)); } } catch (InvalidPasswordException e) { if (expiringCode != null) { 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..8615d1373c6 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 @@ -287,7 +287,7 @@ public String acceptInvitation(@RequestParam("password") String password, } String res = "redirect:/login?success=invite_accepted"; if (!invitation.getRedirectUri().equals("/home")) { - res += "&" + FORM_REDIRECT_PARAMETER + "=" + invitation.getRedirectUri(); + res += "&" + FORM_REDIRECT_PARAMETER + "=" + URLEncoder.encode(invitation.getRedirectUri(), StandardCharsets.UTF_8); } return res; } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/account/EmailChangeEmailServiceTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/account/EmailChangeEmailServiceTest.java index 86c48fb6a9c..fd280f23a05 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/account/EmailChangeEmailServiceTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/account/EmailChangeEmailServiceTest.java @@ -27,6 +27,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; @@ -303,14 +305,55 @@ void completeActivationWithNoRedirectUri() { assertThat(response).containsEntry("redirect_url", "http://fallback.url/redirect"); } + @Test + void completeVerificationWithoutFallbackGivesNoRedirectWhenClientHasNoRegisteredUris() { + UaaClientDetails clientDetails = new UaaClientDetails("client-id", null, null, "authorization_grant", null, null); + + Map response = setUpCompleteActivation("user@example.com", "app", "http://other.example.com/page", clientDetails); + + assertThat(response).containsEntry("redirect_url", null); + } + + @Test + void completeVerificationWithoutFallbackGivesNoRedirectWhenRequestedUriMatchesNoRegisteredUri() { + UaaClientDetails clientDetails = new UaaClientDetails("client-id", null, null, "authorization_grant", null, "http://app.com/*"); + + Map response = setUpCompleteActivation("user@example.com", "app", "http://other.example.com/page", clientDetails); + + assertThat(response).containsEntry("redirect_url", null); + } + + @ParameterizedTest + @NullSource + void completeVerificationWithoutFallbackGivesNoRedirectWhenNoUriIsRequested(String requestedRedirectUri) { + UaaClientDetails clientDetails = new UaaClientDetails("client-id", null, null, "authorization_grant", null, "http://app.com/*"); + + Map response = setUpCompleteActivation("user@example.com", "app", requestedRedirectUri, clientDetails); + + assertThat(response).containsEntry("redirect_url", null); + } + + @Test + void completeVerificationWithoutFallbackKeepsRequestedUriThatMatchesRegisteredUri() { + UaaClientDetails clientDetails = new UaaClientDetails("client-id", null, null, "authorization_grant", null, "http://app.com/*"); + + Map response = setUpCompleteActivation("user@example.com", "app", "http://app.com/redirect", clientDetails); + + assertThat(response).containsEntry("redirect_url", "http://app.com/redirect"); + } + private Map setUpCompleteActivation(String username, String clientId, String redirectUri) { + UaaClientDetails clientDetails = new UaaClientDetails("client-id", null, null, "authorization_grant", null, "http://app.com/*"); + clientDetails.addAdditionalInformation(CHANGE_EMAIL_REDIRECT_URL, "http://fallback.url/redirect"); + return setUpCompleteActivation(username, clientId, redirectUri, clientDetails); + } + + private Map setUpCompleteActivation(String username, String clientId, String redirectUri, UaaClientDetails clientDetails) { Map codeData = new HashMap<>(); codeData.put("user_id", "user-001"); codeData.put("client_id", clientId); codeData.put("redirect_uri", redirectUri); codeData.put("email", "new@example.com"); - UaaClientDetails clientDetails = new UaaClientDetails("client-id", null, null, "authorization_grant", null, "http://app.com/*"); - clientDetails.addAdditionalInformation(CHANGE_EMAIL_REDIRECT_URL, "http://fallback.url/redirect"); when(mockExpiringCodeStore.retrieveCode("the_secret_code", zoneId)).thenReturn(new ExpiringCode("the_secret_code", new Timestamp(System.currentTimeMillis()), JsonUtils.writeValueAsString(codeData), null)); ScimUser user = new ScimUser("user-001", username, "", ""); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationFilterTest.java index c3c52c2d227..e663bb846ed 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationFilterTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationFilterTest.java @@ -15,6 +15,7 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; import java.sql.Timestamp; import static org.assertj.core.api.Assertions.assertThat; @@ -50,6 +51,9 @@ import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.util.StringUtils; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; class ResetPasswordAuthenticationFilterTest { @@ -111,6 +115,13 @@ void happy_day_password_reset_with_null_redirect() throws Exception { happy_day_password_reset(resetPasswordResponse.getRedirectUri()); } + @Test + void happy_day_password_reset_with_query_in_redirect() throws Exception { + resetPasswordResponse = new ResetPasswordService.ResetPasswordResponse(user, "http://app.example.com/page?first=1&second=2#fragment", null); + when(service.resetPassword(any(ExpiringCode.class), eq(password))).thenReturn(resetPasswordResponse); + happy_day_password_reset(resetPasswordResponse.getRedirectUri()); + } + @Test void happy_day_password_reset_with_home_redirect() throws Exception { resetPasswordResponse = new ResetPasswordService.ResetPasswordResponse(user, "home", null); @@ -118,6 +129,15 @@ void happy_day_password_reset_with_home_redirect() throws Exception { happy_day_password_reset(""); } + private static void assertRedirectsToLoginWithSingleFormRedirectUri(String redirectedUrl, String expectedFormRedirectUri) { + UriComponents redirect = UriComponentsBuilder.fromUriString(redirectedUrl).build(); + assertThat(redirect.getPath()).endsWith("/login"); + assertThat(redirect.getFragment()).isNull(); + assertThat(redirect.getQueryParams()).containsOnlyKeys("success", "form_redirect_uri"); + assertThat(redirect.getQueryParams().getFirst("success")).isEqualTo("password_reset"); + assertThat(UriUtils.decode(redirect.getQueryParams().getFirst("form_redirect_uri"), StandardCharsets.UTF_8)).isEqualTo(expectedFormRedirectUri); + } + public void happy_day_password_reset(String redirectUri) throws Exception { filter.doFilterInternal(request, response, chain); //do our assertion @@ -126,7 +146,9 @@ public void happy_day_password_reset(String redirectUri) throws Exception { if (!StringUtils.hasText(redirectUri) || "home".equals(redirectUri)) { verify(response, times(1)).sendRedirect(request.getContextPath() + "/login?success=password_reset"); } else { - verify(response, times(1)).sendRedirect(request.getContextPath() + "/login?success=password_reset&form_redirect_uri=" + redirectUri); + ArgumentCaptor redirectCaptor = ArgumentCaptor.forClass(String.class); + verify(response, times(1)).sendRedirect(redirectCaptor.capture()); + assertRedirectsToLoginWithSingleFormRedirectUri(redirectCaptor.getValue(), redirectUri); } verify(chain, times(0)).doFilter(any(), any()); } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/invitations/InvitationsControllerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/invitations/InvitationsControllerTest.java index 8cbc501daae..85e56dfaefa 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/invitations/InvitationsControllerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/invitations/InvitationsControllerTest.java @@ -1,5 +1,6 @@ package org.cloudfoundry.identity.uaa.invitations; +import java.nio.charset.StandardCharsets; import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; import org.cloudfoundry.identity.uaa.authentication.manager.DynamicLdapAuthenticationManager; import org.cloudfoundry.identity.uaa.authentication.manager.DynamicZoneAwareAuthenticationManager; @@ -63,6 +64,9 @@ import java.util.Arrays; import java.util.HashMap; import java.util.Map; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.codestore.ExpiringCodeType.INVITATION; @@ -534,6 +538,15 @@ void acceptInvite() throws Exception { verify(invitationsService).acceptInvitation(anyString(), eq("passw0rd")); } + private static void assertRedirectsToLoginWithSingleFormRedirectUri(String redirectedUrl, String expectedFormRedirectUri) { + UriComponents redirect = UriComponentsBuilder.fromUriString(redirectedUrl).build(); + assertThat(redirect.getPath()).endsWith("/login"); + assertThat(redirect.getFragment()).isNull(); + assertThat(redirect.getQueryParams()).containsOnlyKeys("success", "form_redirect_uri"); + assertThat(redirect.getQueryParams().getFirst("success")).isEqualTo("invite_accepted"); + assertThat(UriUtils.decode(redirect.getQueryParams().getFirst("form_redirect_uri"), StandardCharsets.UTF_8)).isEqualTo(expectedFormRedirectUri); + } + private MockHttpServletRequestBuilder startAcceptInviteFlow(String password, String passwordConfirmation) { String zoneId = IdentityZoneHolder.get().getId(); UaaPrincipal uaaPrincipal = new UaaPrincipal("user-id-001", "user@example.com", "user@example.com", OriginKeys.UAA, null, zoneId); @@ -572,6 +585,34 @@ void acceptInviteWithValidClientRedirect() throws Exception { .andExpect(redirectedUrl("/login?success=invite_accepted&form_redirect_uri=valid.redirect.com")); } + @Test + void acceptInviteKeepsRedirectUrlWithQueryAsOneFormRedirectParameter() throws Exception { + String zoneId = IdentityZoneHolder.get().getId(); + UaaPrincipal uaaPrincipal = new UaaPrincipal("user-id-001", "user@example.com", "user@example.com", OriginKeys.UAA, null, zoneId); + ScimUser user = new ScimUser(uaaPrincipal.getId(), uaaPrincipal.getName(), "fname", "lname"); + user.setPrimaryEmail(user.getUserName()); + + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(uaaPrincipal, null, UaaAuthority.USER_AUTHORITIES); + SecurityContextHolder.getContext().setAuthentication(token); + + Map codeData = getInvitationsCode(OriginKeys.UAA); + String codeDataString = JsonUtils.writeValueAsString(codeData); + when(expiringCodeStore.retrieveCode("thecode", zoneId)).thenReturn(new ExpiringCode("thecode", new Timestamp(1), codeDataString, INVITATION.name()), null); + when(expiringCodeStore.generateCode(eq(codeDataString), any(), eq(INVITATION.name()), eq(zoneId))).thenReturn(new ExpiringCode("thenewcode", new Timestamp(1), codeDataString, INVITATION.name())); + when(invitationsService.acceptInvitation(anyString(), eq("password"))).thenReturn(new AcceptedInvitation("http://app.example.com/page?first=1&second=2#fragment", user)); + + MockHttpServletRequestBuilder post = post("/invitations/accept.do") + .param("password", "password") + .param("password_confirmation", "password") + .param("code", "thecode"); + + MvcResult result = mockMvc.perform(post) + .andExpect(status().isFound()) + .andReturn(); + + assertRedirectsToLoginWithSingleFormRedirectUri(result.getResponse().getRedirectedUrl(), "http://app.example.com/page?first=1&second=2#fragment"); + } + @Test void acceptInviteWithInvalidClientRedirect() throws Exception { String zoneId = IdentityZoneHolder.get().getId(); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/invitations/InvitationsControllerZonePathTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/invitations/InvitationsControllerZonePathTest.java index 1db57d5c24b..f94c835a57b 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/invitations/InvitationsControllerZonePathTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/invitations/InvitationsControllerZonePathTest.java @@ -1,5 +1,6 @@ package org.cloudfoundry.identity.uaa.invitations; +import java.nio.charset.StandardCharsets; import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; import org.cloudfoundry.identity.uaa.authentication.manager.DynamicLdapAuthenticationManager; import org.cloudfoundry.identity.uaa.authentication.manager.DynamicZoneAwareAuthenticationManager; @@ -67,6 +68,9 @@ import java.util.Arrays; import java.util.HashMap; import java.util.Map; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.codestore.ExpiringCodeType.INVITATION; @@ -139,6 +143,15 @@ void tearDown() { } /** Uses context path for ZONE_PATH so request matches ZonePathContextRewritingFilter output. */ + private static void assertRedirectsToLoginWithSingleFormRedirectUri(String redirectedUrl, String expectedFormRedirectUri) { + UriComponents redirect = UriComponentsBuilder.fromUriString(redirectedUrl).build(); + assertThat(redirect.getPath()).endsWith("/login"); + assertThat(redirect.getFragment()).isNull(); + assertThat(redirect.getQueryParams()).containsOnlyKeys("success", "form_redirect_uri"); + assertThat(redirect.getQueryParams().getFirst("success")).isEqualTo("invite_accepted"); + assertThat(UriUtils.decode(redirect.getQueryParams().getFirst("form_redirect_uri"), StandardCharsets.UTF_8)).isEqualTo(expectedFormRedirectUri); + } + private static MockHttpServletRequestBuilder requestGet(ZoneRequestPathMode mode, String pathSuffix) { if (mode.redirectPrefix().isEmpty()) { return get(pathSuffix); @@ -630,6 +643,36 @@ void acceptInviteWithValidClientRedirect(ZoneRequestPathMode mode) throws Except .andExpect(redirectedUrl(expectedRedirect)); } + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInviteKeepsRedirectUrlWithQueryAsOneFormRedirectParameter(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + String zoneId = IdentityZoneHolder.get().getId(); + UaaPrincipal uaaPrincipal = new UaaPrincipal("user-id-001", "user@example.com", "user@example.com", OriginKeys.UAA, null, zoneId); + ScimUser user = new ScimUser(uaaPrincipal.getId(), uaaPrincipal.getName(), "fname", "lname"); + user.setPrimaryEmail(user.getUserName()); + + UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(uaaPrincipal, null, UaaAuthority.USER_AUTHORITIES); + SecurityContextHolder.getContext().setAuthentication(token); + + Map codeData = getInvitationsCode(OriginKeys.UAA); + String codeDataString = JsonUtils.writeValueAsString(codeData); + when(expiringCodeStore.retrieveCode("thecode", zoneId)).thenReturn(new ExpiringCode("thecode", new Timestamp(1), codeDataString, INVITATION.name()), null); + when(expiringCodeStore.generateCode(eq(codeDataString), any(), eq(INVITATION.name()), eq(zoneId))).thenReturn(new ExpiringCode("thenewcode", new Timestamp(1), codeDataString, INVITATION.name())); + when(invitationsService.acceptInvitation(anyString(), eq("password"))).thenReturn(new AcceptedInvitation("http://app.example.com/page?first=1&second=2#fragment", user)); + + MockHttpServletRequestBuilder post = requestPost(mode, "/invitations/accept.do") + .param("password", "password") + .param("password_confirmation", "password") + .param("code", "thecode"); + + MvcResult result = mockMvc.perform(post) + .andExpect(status().isFound()) + .andReturn(); + + assertRedirectsToLoginWithSingleFormRedirectUri(result.getResponse().getRedirectedUrl(), "http://app.example.com/page?first=1&second=2#fragment"); + } + @ParameterizedTest @EnumSource(ZoneRequestPathMode.class) void acceptInviteWithInvalidClientRedirect(ZoneRequestPathMode mode) throws Exception { diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/login/ChangeEmailControllerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/login/ChangeEmailControllerTest.java index e0189dff362..3006a34f8a1 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/login/ChangeEmailControllerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/login/ChangeEmailControllerTest.java @@ -30,13 +30,18 @@ import org.springframework.test.context.junit.jupiter.SpringJUnitConfig; import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.servlet.config.annotation.DefaultServletHandlerConfigurer; import org.springframework.web.servlet.config.annotation.EnableWebMvc; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; +import java.nio.charset.StandardCharsets; import java.util.Collections; import java.util.HashMap; import java.util.Map; @@ -241,9 +246,43 @@ void verifyEmailWithRedirectUrl() throws Exception { .contentType(APPLICATION_FORM_URLENCODED) .param("code", "the_secret_code"); - mockMvc.perform(get) + MvcResult result = mockMvc.perform(get) + .andExpect(status().isFound()) + .andReturn(); + + assertRedirectsToLoginWithSingleFormRedirectUri(result, "//example.com/callback"); + } + + @Test + void verifyEmailKeepsRedirectUrlWithQueryAsOneFormRedirectParameter() throws Exception { + UaaUser user = new UaaUser("user-id-001", "new@example.com", "password", "new@example.com", Collections.emptyList(), "name", "name", null, null, OriginKeys.UAA, null, true, IdentityZoneHolder.get().getId(), "user-id-001", null); + when(uaaUserDatabase.retrieveUserById(anyString())).thenReturn(user); + + Map response = new HashMap<>(); + response.put("userId", "user-id-001"); + response.put("username", "new@example.com"); + response.put("email", "new@example.com"); + response.put("redirect_url", "http://app.example.com/page?first=1&second=2#fragment"); + when(changeEmailService.completeVerification("the_secret_code")).thenReturn(response); + + MockHttpServletRequestBuilder get = get("/verify_email") + .contentType(APPLICATION_FORM_URLENCODED) + .param("code", "the_secret_code"); + + MvcResult result = mockMvc.perform(get) .andExpect(status().isFound()) - .andExpect(redirectedUrl("login?success=change_email_success&form_redirect_uri=//example.com/callback")); + .andReturn(); + + assertRedirectsToLoginWithSingleFormRedirectUri(result, "http://app.example.com/page?first=1&second=2#fragment"); + } + + private static void assertRedirectsToLoginWithSingleFormRedirectUri(MvcResult result, String expectedFormRedirectUri) { + UriComponents redirect = UriComponentsBuilder.fromUriString(result.getResponse().getRedirectedUrl()).build(); + assertThat(redirect.getPath()).isEqualTo("login"); + assertThat(redirect.getFragment()).isNull(); + assertThat(redirect.getQueryParams()).containsOnlyKeys("success", "form_redirect_uri"); + assertThat(redirect.getQueryParams().getFirst("success")).isEqualTo("change_email_success"); + assertThat(UriUtils.decode(redirect.getQueryParams().getFirst("form_redirect_uri"), StandardCharsets.UTF_8)).isEqualTo(expectedFormRedirectUri); } @Test diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/ResetPasswordIT.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/ResetPasswordIT.java index a59671fefb5..822fcf22eb6 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/ResetPasswordIT.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/ResetPasswordIT.java @@ -16,6 +16,8 @@ import com.icegreen.greenmail.util.GreenMail; import com.icegreen.greenmail.util.GreenMailUtil; import jakarta.mail.internet.MimeMessage; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.integration.util.IntegrationTestUtils; import org.cloudfoundry.identity.uaa.login.test.UnlessProfileActive; @@ -33,6 +35,9 @@ import java.security.SecureRandom; import java.util.Collections; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; import static org.apache.commons.lang3.StringUtils.contains; import static org.assertj.core.api.Assertions.assertThat; @@ -116,33 +121,23 @@ void resettingAPasswordWithPrimaryEmail() { @Test void resetPassword_with_clientRedirect() throws Exception { - webDriver.get(baseUrl + "/forgot_password?client_id=" + scimClientId + "&redirect_uri=http://testzonedoesnotexist.localhost"); - assertThat(webDriver.findElement(By.tagName("h1")).getText()).isEqualTo("Reset Password"); - - int receivedEmailSize = greenMail.getReceivedMessages().length; - - webDriver.findElement(By.name("username")).sendKeys(username); - webDriver.clickAndWait(By.xpath("//input[@value='Send reset password link']")); - assertThat(webDriver.findElement(By.tagName("h1")).getText()).isEqualTo("Instructions Sent"); + String finalUrl = resetPasswordWithClientRedirect(scimClientId, "http://testzonedoesnotexist.localhost"); - greenMail.waitForIncomingEmail(5000, receivedEmailSize + 1); - assertThat(greenMail.getReceivedMessages()).hasSize(receivedEmailSize + 1); - MimeMessage message = greenMail.getReceivedMessages()[receivedEmailSize]; - assertThat(message.getHeader("To")[0]).isEqualTo(email); - assertThat(GreenMailUtil.getBody(message)).contains("Reset your password"); - - assertThat(webDriver.findElement(By.cssSelector(".instructions-sent")).getText()).isEqualTo("Please check your email for a reset password link."); + assertRedirectsToLoginWithSingleFormRedirectUri(finalUrl, "http://testzonedoesnotexist.localhost"); + } - String link = testClient.extractLink(GreenMailUtil.getBody(message)); - assertThat(contains(link, "@")).isFalse(); - assertThat(contains(link, "%40")).isFalse(); - webDriver.get(link); + @Test + void resetPassword_keepsClientRedirectWithQueryAsOneFormRedirectParameter() throws Exception { + String redirectUri = "http://testzonedoesnotexist.localhost/page?first=1&second=2#fragment"; + String adminAccessToken = testClient.getOAuthAccessToken("admin", "adminsecret", "client_credentials", "clients.read clients.write clients.secret clients.admin"); + String patternClientId = "pattern_" + new SecureRandom().nextInt(); + UaaClientDetails patternClient = new UaaClientDetails(patternClientId, "oauth", "uaa.user", "authorization_code,refresh_token", null, "http://testzonedoesnotexist.localhost/**"); + patternClient.setClientSecret("scimsecret"); + IntegrationTestUtils.createClient(adminAccessToken, baseUrl, patternClient); - webDriver.findElement(By.name("password")).sendKeys("new_password"); - webDriver.findElement(By.name("password_confirmation")).sendKeys("new_password"); - webDriver.clickAndWait(By.xpath("//input[@value='Create new password']")); + String finalUrl = resetPasswordWithClientRedirect(patternClientId, redirectUri); - assertThat(webDriver.getCurrentUrl()).isEqualTo(baseUrl + "/login?success=password_reset&form_redirect_uri=http://testzonedoesnotexist.localhost"); + assertRedirectsToLoginWithSingleFormRedirectUri(finalUrl, redirectUri); } @Test @@ -237,6 +232,45 @@ void resetPassword_displaysErrorMessage_NewPasswordSameAsOld() { assertThat(webDriver.findElement(By.cssSelector(".error-message")).getText()).contains("Your new password cannot be the same as the old password."); } + private static void assertRedirectsToLoginWithSingleFormRedirectUri(String redirectedUrl, String expectedFormRedirectUri) { + UriComponents redirect = UriComponentsBuilder.fromUriString(redirectedUrl).build(); + assertThat(redirect.getPath()).endsWith("/login"); + assertThat(redirect.getFragment()).isNull(); + assertThat(redirect.getQueryParams()).containsOnlyKeys("success", "form_redirect_uri"); + assertThat(redirect.getQueryParams().getFirst("success")).isEqualTo("password_reset"); + assertThat(UriUtils.decode(redirect.getQueryParams().getFirst("form_redirect_uri"), StandardCharsets.UTF_8)).isEqualTo(expectedFormRedirectUri); + } + + private String resetPasswordWithClientRedirect(String clientId, String redirectUri) throws Exception { + webDriver.get(baseUrl + "/forgot_password?client_id=" + clientId + "&redirect_uri=" + URLEncoder.encode(redirectUri, StandardCharsets.UTF_8)); + assertThat(webDriver.findElement(By.tagName("h1")).getText()).isEqualTo("Reset Password"); + + int receivedEmailSize = greenMail.getReceivedMessages().length; + + webDriver.findElement(By.name("username")).sendKeys(username); + webDriver.clickAndWait(By.xpath("//input[@value='Send reset password link']")); + assertThat(webDriver.findElement(By.tagName("h1")).getText()).isEqualTo("Instructions Sent"); + + greenMail.waitForIncomingEmail(5000, receivedEmailSize + 1); + assertThat(greenMail.getReceivedMessages()).hasSize(receivedEmailSize + 1); + MimeMessage message = greenMail.getReceivedMessages()[receivedEmailSize]; + assertThat(message.getHeader("To")[0]).isEqualTo(email); + assertThat(GreenMailUtil.getBody(message)).contains("Reset your password"); + + assertThat(webDriver.findElement(By.cssSelector(".instructions-sent")).getText()).isEqualTo("Please check your email for a reset password link."); + + String link = testClient.extractLink(GreenMailUtil.getBody(message)); + assertThat(contains(link, "@")).isFalse(); + assertThat(contains(link, "%40")).isFalse(); + webDriver.get(link); + + webDriver.findElement(By.name("password")).sendKeys("new_password"); + webDriver.findElement(By.name("password_confirmation")).sendKeys("new_password"); + webDriver.clickAndWait(By.xpath("//input[@value='Create new password']")); + + return webDriver.getCurrentUrl(); + } + private void beginPasswordReset(String username) { webDriver.get(baseUrl + "/login"); assertThat(webDriver.getTitle()).isEqualTo("Cloud Foundry"); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcTests.java index 92fdf613db7..865c55bbcc9 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcTests.java @@ -1,5 +1,6 @@ package org.cloudfoundry.identity.uaa.login; +import java.nio.charset.StandardCharsets; import org.apache.commons.lang3.RandomStringUtils; import org.cloudfoundry.identity.uaa.DefaultTestContext; import org.cloudfoundry.identity.uaa.account.EmailAccountCreationService; @@ -42,9 +43,11 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.support.StandardServletEnvironment; +import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; import java.util.Collections; +import org.springframework.web.util.UriUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.CookieCsrfPostProcessor.cookieCsrf; @@ -414,6 +417,11 @@ void creatingAnAccountWithClientRedirect() throws Exception { createAccount("http://redirect.uri/client", "http://redirect.uri/client"); } + @Test + void creatingAnAccountKeepsClientRedirectWithQueryAsOneFormRedirectParameter() throws Exception { + createAccount("http://redirect.uri/page?first=1&second=2#fragment", "http://redirect.uri/page?first=1&second=2#fragment"); + } + @Test void creatingAnAccountWithFallbackClientRedirect() throws Exception { createAccount("http://redirect.uri/fallback", null); @@ -534,7 +542,7 @@ void creatingAnAccountInAnotherZoneWithClientRedirect() throws Exception { assertThat(link).contains(subdomain + ".localhost"); String afterReset = confirmAccountAndSetPassword("test" + generator.counter.get(), subdomain, userEmail); - assertThat(afterReset).isEqualTo("/login?success=password_reset&form_redirect_uri=http://myzoneclient.example.com"); + assertRedirectsToLoginWithSingleFormRedirectUri(afterReset, "http://myzoneclient.example.com"); MvcResult mvcResult = loginWithAccount(subdomain) .andExpect(authenticated()) @@ -686,6 +694,15 @@ void consentIfConfiguredDisplaysMeaningfulErrorWhenConsentNotProvided() throws E assertThat(result.getResponse().getContentAsString()).contains("Please agree before continuing."); } + private static void assertRedirectsToLoginWithSingleFormRedirectUri(String redirectedUrl, String expectedFormRedirectUri) { + UriComponents redirect = UriComponentsBuilder.fromUriString(redirectedUrl).build(); + assertThat(redirect.getPath()).endsWith("/login"); + assertThat(redirect.getFragment()).isNull(); + assertThat(redirect.getQueryParams()).containsOnlyKeys("success", "form_redirect_uri"); + assertThat(redirect.getQueryParams().getFirst("success")).isEqualTo("password_reset"); + assertThat(UriUtils.decode(redirect.getQueryParams().getFirst("form_redirect_uri"), StandardCharsets.UTF_8)).isEqualTo(expectedFormRedirectUri); + } + private UaaClientDetails createTestClient() throws Exception { UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("test-client-" + RandomStringUtils.randomAlphanumeric(200)); @@ -721,7 +738,7 @@ private void createAccount(String expectedRedirectUri, String redirectUri) throw assertThat(message.getMessage().getHeader("From")).contains("Cloud Foundry "); String afterReset = confirmAccountAndSetPassword("test" + generator.counter.get(), "", userEmail); - assertThat(afterReset).isEqualTo("/login?success=password_reset&form_redirect_uri=" + expectedRedirectUri); + assertRedirectsToLoginWithSingleFormRedirectUri(afterReset, expectedRedirectUri); MvcResult mvcResult = loginWithAccount("") .andExpect(authenticated()) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcZonePathTests.java index 9ab1efa2069..0bedb800a66 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/AccountsControllerMockMvcZonePathTests.java @@ -1,5 +1,6 @@ package org.cloudfoundry.identity.uaa.login; +import java.nio.charset.StandardCharsets; import org.apache.commons.lang3.RandomStringUtils; import org.cloudfoundry.identity.uaa.DefaultTestContext; import org.cloudfoundry.identity.uaa.account.EmailAccountCreationService; @@ -45,10 +46,12 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.context.WebApplicationContext; import org.springframework.web.context.support.StandardServletEnvironment; +import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; import org.cloudfoundry.identity.uaa.extensions.EnabledIfZonePathsEnabled; import java.util.Collections; +import org.springframework.web.util.UriUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.CookieCsrfPostProcessor.cookieCsrf; @@ -352,6 +355,11 @@ void creatingAnAccountWithClientRedirect() throws Exception { createAccount("http://redirect.uri/client", "http://redirect.uri/client"); } + @Test + void creatingAnAccountKeepsClientRedirectWithQueryAsOneFormRedirectParameter() throws Exception { + createAccount("http://redirect.uri/page?first=1&second=2#fragment", "http://redirect.uri/page?first=1&second=2#fragment"); + } + @Test void creatingAnAccountWithFallbackClientRedirect() throws Exception { createAccount("http://redirect.uri/fallback", null); @@ -480,10 +488,7 @@ void creatingAnAccountInAnotherZoneWithClientRedirect(ZoneResolutionMode mode) t } String afterReset = confirmAccountAndSetPassword(mode, subdomain, "test" + generator.counter.get(), userEmail); - String expectedAfterReset = mode == ZoneResolutionMode.ZONE_PATH - ? "/z/" + subdomain + "/login?success=password_reset&form_redirect_uri=http://myzoneclient.example.com" - : "/login?success=password_reset&form_redirect_uri=http://myzoneclient.example.com"; - assertThat(afterReset).isEqualTo(expectedAfterReset); + assertRedirectsToLoginWithSingleFormRedirectUri(afterReset, "http://myzoneclient.example.com"); MvcResult mvcResult = loginWithAccount(subdomain) .andExpect(authenticated()) @@ -632,6 +637,15 @@ void consentIfConfiguredDisplaysMeaningfulErrorWhenConsentNotProvided(ZoneResolu assertThat(result.getResponse().getContentAsString()).contains("Please agree before continuing."); } + private static void assertRedirectsToLoginWithSingleFormRedirectUri(String redirectedUrl, String expectedFormRedirectUri) { + UriComponents redirect = UriComponentsBuilder.fromUriString(redirectedUrl).build(); + assertThat(redirect.getPath()).endsWith("/login"); + assertThat(redirect.getFragment()).isNull(); + assertThat(redirect.getQueryParams()).containsOnlyKeys("success", "form_redirect_uri"); + assertThat(redirect.getQueryParams().getFirst("success")).isEqualTo("password_reset"); + assertThat(UriUtils.decode(redirect.getQueryParams().getFirst("form_redirect_uri"), StandardCharsets.UTF_8)).isEqualTo(expectedFormRedirectUri); + } + private UaaClientDetails createTestClient() throws Exception { UaaClientDetails clientDetails = new UaaClientDetails(); clientDetails.setClientId("test-client-" + RandomStringUtils.randomAlphanumeric(200)); @@ -667,7 +681,7 @@ private void createAccount(String expectedRedirectUri, String redirectUri) throw assertThat(message.getMessage().getHeader("From")).contains("Cloud Foundry "); String afterReset = confirmAccountAndSetPassword(ZoneResolutionMode.SUBDOMAIN, "", "test" + generator.counter.get(), userEmail); - assertThat(afterReset).isEqualTo("/login?success=password_reset&form_redirect_uri=" + expectedRedirectUri); + assertRedirectsToLoginWithSingleFormRedirectUri(afterReset, expectedRedirectUri); MvcResult mvcResult = loginWithAccount("") .andExpect(authenticated()) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/InvitationsServiceMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/InvitationsServiceMockMvcTests.java index b79a8b1782c..9794c77da1c 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/InvitationsServiceMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/InvitationsServiceMockMvcTests.java @@ -14,6 +14,7 @@ package org.cloudfoundry.identity.uaa.login; +import java.nio.charset.StandardCharsets; import org.cloudfoundry.identity.uaa.DefaultTestContext; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.constants.OriginKeys; @@ -48,6 +49,9 @@ import java.net.URL; import java.util.Arrays; import java.util.Collections; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.CookieCsrfPostProcessor.cookieCsrf; @@ -350,8 +354,8 @@ void acceptInvitationSetsYourPassword() throws Exception { post ) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/login?success=invite_accepted&form_redirect_uri=" + REDIRECT_URI)) .andReturn(); + assertRedirectsToLoginWithSingleFormRedirectUri(result.getResponse().getRedirectedUrl(), REDIRECT_URI); assertThat(queryUserForField(jdbcTemplate, email, "verified", Boolean.class)).as("User should be verified after password reset").isTrue(); @@ -365,6 +369,42 @@ void acceptInvitationSetsYourPassword() throws Exception { .andExpect(redirectedUrl("/login")); } + @Test + void acceptInvitationKeepsRedirectUrlWithQueryAsOneFormRedirectParameter() throws Exception { + String adminToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mockMvc, "admin", "adminsecret", "clients.admin clients.read clients.write clients.secret scim.read scim.write", null); + String patternClientId = generator.generate().toLowerCase(); + String patternClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(this.mockMvc, adminToken, patternClientId, patternClientSecret, Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), "scim.read,scim.invite", Collections.singleton(REDIRECT_URI + "/**"), IdentityZone.getUaa()); + String patternInviteToken = MockMvcUtils.getScimInviteUserToken(mockMvc, patternClientId, patternClientSecret, null, "admin", "adminsecret"); + String redirectUri = REDIRECT_URI + "/page?first=1&second=2#fragment"; + + String email = generator.generate().toLowerCase() + "@test.org"; + URL inviteLink = MockMvcUtils.inviteUser(webApplicationContext, mockMvc, email, patternInviteToken, null, patternClientId, OriginKeys.UAA, redirectUri); + + String code = extractInvitationCode(inviteLink.toString()); + MvcResult result = mockMvc.perform(get("/invitations/accept") + .param("code", code) + .accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andReturn(); + + code = jdbcTemplate.queryForObject("SELECT code FROM expiring_code_store", String.class); + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(false); + MockHttpServletRequestBuilder post = post("/invitations/accept.do") + .param("password", "s3cret") + .param("password_confirmation", "s3cret") + .param("code", code) + .with(cookieCsrf()); + if (session != null) { + post = post.session(session); + } + result = mockMvc.perform(post) + .andExpect(status().isFound()) + .andReturn(); + + assertRedirectsToLoginWithSingleFormRedirectUri(result.getResponse().getRedirectedUrl(), redirectUri); + } + @Test void inviteLdapUsersVerifiesAndRedirects() throws Exception { ZoneScimInviteData zone = createZoneForInvites(mockMvc, webApplicationContext, clientId); @@ -425,6 +465,15 @@ void inviteSamlUserWillRedirectUponAccept() throws Exception { assertThat(queryUserForField(jdbcTemplate, email, "verified", Boolean.class)).as("Saml user should not yet be verified after clicking on the accept link").isFalse(); } + private static void assertRedirectsToLoginWithSingleFormRedirectUri(String redirectedUrl, String expectedFormRedirectUri) { + UriComponents redirect = UriComponentsBuilder.fromUriString(redirectedUrl).build(); + assertThat(redirect.getPath()).endsWith("/login"); + assertThat(redirect.getFragment()).isNull(); + assertThat(redirect.getQueryParams()).containsOnlyKeys("success", "form_redirect_uri"); + assertThat(redirect.getQueryParams().getFirst("success")).isEqualTo("invite_accepted"); + assertThat(UriUtils.decode(redirect.getQueryParams().getFirst("form_redirect_uri"), StandardCharsets.UTF_8)).isEqualTo(expectedFormRedirectUri); + } + private static T queryUserForField(JdbcTemplate jdbcTemplate, String email, String field, Class type) { return jdbcTemplate.queryForObject("SELECT " + field + " FROM users WHERE email=?", type, email); } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/InvitationsServiceMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/InvitationsServiceMockMvcZonePathTests.java index 2ecbf9f86d9..c5ccc72ffba 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/InvitationsServiceMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/InvitationsServiceMockMvcZonePathTests.java @@ -14,6 +14,7 @@ package org.cloudfoundry.identity.uaa.login; +import java.nio.charset.StandardCharsets; import org.cloudfoundry.identity.uaa.DefaultTestContext; import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.constants.OriginKeys; @@ -60,6 +61,9 @@ import org.cloudfoundry.identity.uaa.util.JsonUtils; import java.net.URL; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; import static org.cloudfoundry.identity.uaa.oauth.common.util.OAuth2Utils.CLIENT_ID; import static org.springframework.http.MediaType.APPLICATION_JSON; @@ -628,8 +632,8 @@ void acceptInvitationSetsYourPassword() throws Exception { post ) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/login?success=invite_accepted&form_redirect_uri=" + REDIRECT_URI)) .andReturn(); + assertRedirectsToLoginWithSingleFormRedirectUri(result.getResponse().getRedirectedUrl(), REDIRECT_URI); assertThat(queryUserForField(jdbcTemplate, email, "verified", Boolean.class)).as("User should be verified after password reset").isTrue(); @@ -762,6 +766,15 @@ void inviteSamlUserWillRedirectUponAccept() throws Exception { assertThat(queryUserForField(jdbcTemplate, email, "verified", Boolean.class)).as("Saml user should not yet be verified after clicking on the accept link").isFalse(); } + private static void assertRedirectsToLoginWithSingleFormRedirectUri(String redirectedUrl, String expectedFormRedirectUri) { + UriComponents redirect = UriComponentsBuilder.fromUriString(redirectedUrl).build(); + assertThat(redirect.getPath()).endsWith("/login"); + assertThat(redirect.getFragment()).isNull(); + assertThat(redirect.getQueryParams()).containsOnlyKeys("success", "form_redirect_uri"); + assertThat(redirect.getQueryParams().getFirst("success")).isEqualTo("invite_accepted"); + assertThat(UriUtils.decode(redirect.getQueryParams().getFirst("form_redirect_uri"), StandardCharsets.UTF_8)).isEqualTo(expectedFormRedirectUri); + } + private static T queryUserForField(JdbcTemplate jdbcTemplate, String email, String field, Class type) { return jdbcTemplate.queryForObject("SELECT " + field + " FROM users WHERE email=?", type, email); } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcTests.java index 2f39d6ceca8..fb44627b094 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcTests.java @@ -10,6 +10,7 @@ import org.cloudfoundry.identity.uaa.codestore.ExpiringCodeStore; import org.cloudfoundry.identity.uaa.codestore.JdbcExpiringCodeStore; import org.cloudfoundry.identity.uaa.impl.config.IdentityZoneConfigurationBootstrap; +import org.cloudfoundry.identity.uaa.message.util.FakeJavaMailSender; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; import org.cloudfoundry.identity.uaa.oauth.common.util.RandomValueStringGenerator; @@ -73,7 +74,9 @@ import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; import org.springframework.util.StringUtils; import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; import java.io.File; import java.net.URI; @@ -2032,6 +2035,94 @@ void changeEmailSubmitWithCorrectCsrf() throws Exception { } + @Test + void changeEmailVerificationGoesToProfileWhenClientHasNoRedirectUrisAndRequestedUriIsNotRegistered() throws Exception { + assumeFalse(isLimitedMode(limitedModeUaaFilter.getFilter()), "Test only runs in non limited mode."); + String username = generator.generate() + "@testdomain.com"; + createUser(scimUserProvisioning, username, IdentityZone.getUaaZoneId()); + SecurityContext userContext = getUaaSecurityContext(username, webApplicationContext, IdentityZoneHolder.getCurrentZoneId()); + + String clientId = generator.generate(); + UaaClientDetails client = new UaaClientDetails(clientId, "", "", "client_credentials", "uaa.none"); + client.setClientSecret(clientId); + MockMvcUtils.createClient(webApplicationContext, client, getUaa()); + + FakeJavaMailSender sender = webApplicationContext.getBean(FakeJavaMailSender.class); + sender.clearMessage(); + + MockHttpSession session = (MockHttpSession) mockMvc.perform(get("/change_email") + .accept(TEXT_HTML) + .with(securityContext(userContext))) + .andExpect(status().isOk()) + .andReturn().getRequest().getSession(); + + mockMvc.perform(post("/change_email.do") + .accept(TEXT_HTML) + .session(session) + .with(securityContext(userContext)) + .with(cookieCsrf()) + .param("newEmail", generator.generate() + "@testdomain.com") + .param("client_id", clientId) + .param("redirect_uri", "http://other.example.com/page")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("email_sent?code=email_change")); + + Matcher matcher = Pattern.compile("verify_email\\?code=([A-Za-z0-9_-]+)").matcher(sender.getSentMessages().getFirst().getContentString()); + assertThat(matcher.find()).isTrue(); + + mockMvc.perform(get("/verify_email") + .accept(TEXT_HTML) + .session(session) + .with(securityContext(userContext)) + .param("code", matcher.group(1))) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("profile?success_message_code=email_change.success")); + } + + @Test + void changeEmailVerificationKeepsRedirectUrlAsOneFormRedirectParameterWhenNotSignedIn() throws Exception { + assumeFalse(isLimitedMode(limitedModeUaaFilter.getFilter()), "Test only runs in non limited mode."); + String username = generator.generate() + "@testdomain.com"; + createUser(scimUserProvisioning, username, IdentityZone.getUaaZoneId()); + SecurityContext userContext = getUaaSecurityContext(username, webApplicationContext, IdentityZoneHolder.getCurrentZoneId()); + + String clientId = generator.generate(); + UaaClientDetails client = new UaaClientDetails(clientId, "", "", "client_credentials", "uaa.none", "http://app.example.com/**"); + client.setClientSecret(clientId); + MockMvcUtils.createClient(webApplicationContext, client, getUaa()); + + FakeJavaMailSender sender = webApplicationContext.getBean(FakeJavaMailSender.class); + sender.clearMessage(); + + MockHttpSession session = (MockHttpSession) mockMvc.perform(get("/change_email") + .accept(TEXT_HTML) + .with(securityContext(userContext))) + .andExpect(status().isOk()) + .andReturn().getRequest().getSession(); + + mockMvc.perform(post("/change_email.do") + .accept(TEXT_HTML) + .session(session) + .with(securityContext(userContext)) + .with(cookieCsrf()) + .param("newEmail", generator.generate() + "@testdomain.com") + .param("client_id", clientId) + .param("redirect_uri", "http://app.example.com/page?first=1&second=2#fragment")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("email_sent?code=email_change")); + + Matcher matcher = Pattern.compile("verify_email\\?code=([A-Za-z0-9_-]+)").matcher(sender.getSentMessages().getFirst().getContentString()); + assertThat(matcher.find()).isTrue(); + + MvcResult result = mockMvc.perform(get("/verify_email") + .accept(TEXT_HTML) + .param("code", matcher.group(1))) + .andExpect(status().isFound()) + .andReturn(); + + assertRedirectsToLoginWithSingleFormRedirectUri(result.getResponse().getRedirectedUrl(), "http://app.example.com/page?first=1&second=2#fragment"); + } + @Test void changeEmailDoNotLoggedIn() throws Exception { assumeFalse(isLimitedMode(limitedModeUaaFilter.getFilter()), "Test only runs in non limited mode."); @@ -3061,6 +3152,15 @@ public Map getParameterMap() { }; } + private static void assertRedirectsToLoginWithSingleFormRedirectUri(String redirectedUrl, String expectedFormRedirectUri) { + UriComponents redirect = UriComponentsBuilder.fromUriString(redirectedUrl).build(); + assertThat(redirect.getPath()).endsWith("login"); + assertThat(redirect.getFragment()).isNull(); + assertThat(redirect.getQueryParams()).containsOnlyKeys("success", "form_redirect_uri"); + assertThat(redirect.getQueryParams().getFirst("success")).isEqualTo("change_email_success"); + assertThat(UriUtils.decode(redirect.getQueryParams().getFirst("form_redirect_uri"), StandardCharsets.UTF_8)).isEqualTo(expectedFormRedirectUri); + } + private static ScimUser createUser(ScimUserProvisioning scimUserProvisioning, AlphanumericRandomValueStringGenerator generator, String zoneId) { String username = generator.generate() + "@testdomain.com"; return createUser(scimUserProvisioning, username, zoneId); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcZonePathTests.java index a84fe9c23dc..0d7249d64e6 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/LoginMockMvcZonePathTests.java @@ -10,6 +10,7 @@ import org.cloudfoundry.identity.uaa.codestore.ExpiringCodeStore; import org.cloudfoundry.identity.uaa.codestore.JdbcExpiringCodeStore; import org.cloudfoundry.identity.uaa.impl.config.IdentityZoneConfigurationBootstrap; +import org.cloudfoundry.identity.uaa.message.util.FakeJavaMailSender; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; import org.cloudfoundry.identity.uaa.oauth.client.ClientConstants; import org.cloudfoundry.identity.uaa.oauth.common.util.RandomValueStringGenerator; @@ -77,7 +78,9 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.util.StringUtils; import org.springframework.web.context.WebApplicationContext; +import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; import org.cloudfoundry.identity.uaa.extensions.EnabledIfZonePathsEnabled; import java.io.File; @@ -359,6 +362,98 @@ void access_login_page_while_logged_in(ZoneResolutionMode mode) throws Exception .andExpect(redirectedUrl(expectedRedirect)); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void changeEmailVerificationGoesToProfileWhenClientHasNoRedirectUrisAndRequestedUriIsNotRegistered(ZoneResolutionMode mode) throws Exception { + assumeFalse(isLimitedMode(limitedModeUaaFilter.getFilter()), "Zone creation returns 503 in limited mode."); + String subdomain = new AlphanumericRandomValueStringGenerator(24).generate().toLowerCase(); + IdentityZone zone = MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, false, IdentityZoneHolder.getCurrentZoneId()); + ScimUser zoneUser = createUser(scimUserProvisioning, generator, zone.getId()); + SecurityContext userContext = getUaaSecurityContext(zoneUser.getUserName(), webApplicationContext, zone.getId()); + + String clientId = generator.generate(); + UaaClientDetails client = new UaaClientDetails(clientId, "", "", "client_credentials", "uaa.none"); + client.setClientSecret(clientId); + MockMvcUtils.createClient(webApplicationContext, client, zone); + + FakeJavaMailSender sender = webApplicationContext.getBean(FakeJavaMailSender.class); + sender.clearMessage(); + + MockHttpSession session = (MockHttpSession) mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/change_email") + .accept(TEXT_HTML) + .with(securityContext(userContext))) + .andExpect(status().isOk()) + .andReturn().getRequest().getSession(); + + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/change_email.do") + .accept(TEXT_HTML) + .session(session) + .with(securityContext(userContext)) + .with(cookieCsrf()) + .param("newEmail", generator.generate() + "@testdomain.com") + .param("client_id", clientId) + .param("redirect_uri", "http://other.example.com/page")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("email_sent?code=email_change")); + + Matcher matcher = Pattern.compile("verify_email\\?code=([A-Za-z0-9_-]+)").matcher(sender.getSentMessages().getFirst().getContentString()); + assertThat(matcher.find()).isTrue(); + + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/verify_email") + .accept(TEXT_HTML) + .session(session) + .with(securityContext(userContext)) + .param("code", matcher.group(1))) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("profile?success_message_code=email_change.success")); + } + + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void changeEmailVerificationKeepsRedirectUrlAsOneFormRedirectParameterWhenNotSignedIn(ZoneResolutionMode mode) throws Exception { + assumeFalse(isLimitedMode(limitedModeUaaFilter.getFilter()), "Zone creation returns 503 in limited mode."); + String subdomain = new AlphanumericRandomValueStringGenerator(24).generate().toLowerCase(); + IdentityZone zone = MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, false, IdentityZoneHolder.getCurrentZoneId()); + ScimUser zoneUser = createUser(scimUserProvisioning, generator, zone.getId()); + SecurityContext userContext = getUaaSecurityContext(zoneUser.getUserName(), webApplicationContext, zone.getId()); + + String clientId = generator.generate(); + UaaClientDetails client = new UaaClientDetails(clientId, "", "", "client_credentials", "uaa.none", "http://app.example.com/**"); + client.setClientSecret(clientId); + MockMvcUtils.createClient(webApplicationContext, client, zone); + + FakeJavaMailSender sender = webApplicationContext.getBean(FakeJavaMailSender.class); + sender.clearMessage(); + + MockHttpSession session = (MockHttpSession) mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/change_email") + .accept(TEXT_HTML) + .with(securityContext(userContext))) + .andExpect(status().isOk()) + .andReturn().getRequest().getSession(); + + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/change_email.do") + .accept(TEXT_HTML) + .session(session) + .with(securityContext(userContext)) + .with(cookieCsrf()) + .param("newEmail", generator.generate() + "@testdomain.com") + .param("client_id", clientId) + .param("redirect_uri", "http://app.example.com/page?first=1&second=2#fragment")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("email_sent?code=email_change")); + + Matcher matcher = Pattern.compile("verify_email\\?code=([A-Za-z0-9_-]+)").matcher(sender.getSentMessages().getFirst().getContentString()); + assertThat(matcher.find()).isTrue(); + + MvcResult result = mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/verify_email") + .accept(TEXT_HTML) + .param("code", matcher.group(1))) + .andExpect(status().isFound()) + .andReturn(); + + assertRedirectsToLoginWithSingleFormRedirectUri(result.getResponse().getRedirectedUrl(), "http://app.example.com/page?first=1&second=2#fragment"); + } + @ParameterizedTest @EnumSource(ZoneResolutionMode.class) void invalid_accept_media_type(ZoneResolutionMode mode) throws Exception { @@ -3670,6 +3765,15 @@ public Map getParameterMap() { }; } + private static void assertRedirectsToLoginWithSingleFormRedirectUri(String redirectedUrl, String expectedFormRedirectUri) { + UriComponents redirect = UriComponentsBuilder.fromUriString(redirectedUrl).build(); + assertThat(redirect.getPath()).endsWith("login"); + assertThat(redirect.getFragment()).isNull(); + assertThat(redirect.getQueryParams()).containsOnlyKeys("success", "form_redirect_uri"); + assertThat(redirect.getQueryParams().getFirst("success")).isEqualTo("change_email_success"); + assertThat(UriUtils.decode(redirect.getQueryParams().getFirst("form_redirect_uri"), StandardCharsets.UTF_8)).isEqualTo(expectedFormRedirectUri); + } + private static ScimUser createUser(ScimUserProvisioning scimUserProvisioning, AlphanumericRandomValueStringGenerator generator, String zoneId) { String username = generator.generate() + "@testdomain.com"; return createUser(scimUserProvisioning, username, zoneId); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/PasswordResetEndpointMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/PasswordResetEndpointMockMvcTests.java index 8faba60ee7e..1ff7ba8ea95 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/PasswordResetEndpointMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/PasswordResetEndpointMockMvcTests.java @@ -1,5 +1,9 @@ package org.cloudfoundry.identity.uaa.scim.endpoints; +import java.nio.charset.StandardCharsets; +import org.springframework.web.util.UriComponents; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; import tools.jackson.core.type.TypeReference; import org.cloudfoundry.identity.uaa.DefaultTestContext; import org.cloudfoundry.identity.uaa.codestore.ExpiringCode; @@ -186,9 +190,10 @@ void changePasswordDoWithClientidAndRedirecturi() throws Exception { .param("password_confirmation", "newpass") .with(cookieCsrf()); - mockMvc.perform(post) + MvcResult resetResult = mockMvc.perform(post) .andExpect(status().is3xxRedirection()) - .andExpect(redirectedUrl(webApplicationContext.getServletContext().getContextPath() + "/login?success=password_reset&form_redirect_uri=http://localhost:8080/app/")); + .andReturn(); + assertRedirectsToLoginWithSingleFormRedirectUri(resetResult.getResponse().getRedirectedUrl(), "http://localhost:8080/app/"); post = post("/login.do") .param("username", scimUser.getUserName()) @@ -285,6 +290,15 @@ void zoneAdminCanResetsAndChangePassword() throws Exception { .andExpect(jsonPath("$.code").isNotEmpty()); } + private static void assertRedirectsToLoginWithSingleFormRedirectUri(String redirectedUrl, String expectedFormRedirectUri) { + UriComponents redirect = UriComponentsBuilder.fromUriString(redirectedUrl).build(); + assertThat(redirect.getPath()).endsWith("/login"); + assertThat(redirect.getFragment()).isNull(); + assertThat(redirect.getQueryParams()).containsOnlyKeys("success", "form_redirect_uri"); + assertThat(redirect.getQueryParams().getFirst("success")).isEqualTo("password_reset"); + assertThat(UriUtils.decode(redirect.getQueryParams().getFirst("form_redirect_uri"), StandardCharsets.UTF_8)).isEqualTo(expectedFormRedirectUri); + } + private static String getExpiringCode(MockMvc mockMvc, String clientId, String redirectUri, String loginToken, ScimUser scimUser) throws Exception { MockHttpServletRequestBuilder post = post("/password_resets") .header("Authorization", "Bearer " + loginToken) From d5ace057ff9fd256cf40b2982f0c4aed5d0bf17c Mon Sep 17 00:00:00 2001 From: Filip Hanik Date: Thu, 29 Oct 2026 11:36:03 -0700 Subject: [PATCH 03/10] fix: expiring code intents and reset code lifetime - POST /Codes requires a non-blank intent and stores it as given; a missing or blank intent is answered with 400. Documented in UAA-APIs.rst and the API docs. - Passcode login requires the intent the passcode endpoint stores ("PASSCODE "). - Opening a password reset link, or submitting a rejected password, no longer extends the lifetime of the reset code; opening the link again shows the form. --- docs/UAA-APIs.rst | 49 ++++++++++++ .../ResetPasswordAuthenticationFilter.java | 3 +- .../uaa/account/ResetPasswordController.java | 6 +- .../PasscodeAuthenticationFilter.java | 3 +- .../uaa/codestore/CodeStoreEndpoints.java | 13 +++- .../uaa/codestore/JdbcExpiringCodeStore.java | 4 + ...ResetPasswordAuthenticationFilterTest.java | 30 ++++++++ .../PasscodeAuthenticationFilterTest.java | 76 ++++++++++++++++++- .../codestore/CodeStoreEndpointsTests.java | 62 +++++++++++---- .../login/ResetPasswordControllerTest.java | 19 ++++- .../source/index.html.md.erb | 29 +++++++ .../integration/feature/InvitationsIT.java | 2 +- .../integration/feature/ResetPasswordIT.java | 6 ++ .../ResetPasswordControllerMockMvcTests.java | 38 ++++++++++ ...asswordControllerMockMvcZonePathTests.java | 36 +++++++++ .../mock/codestore/CodeStoreEndpointDocs.java | 60 +++++++++++++++ .../CodeStoreEndpointsMockMvcTests.java | 59 +++++++++++--- ...odeStoreEndpointsMockMvcZonePathTests.java | 61 +++++++++++---- 18 files changed, 504 insertions(+), 52 deletions(-) create mode 100644 uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/codestore/CodeStoreEndpointDocs.java diff --git a/docs/UAA-APIs.rst b/docs/UAA-APIs.rst index 4835dfd30e8..3ea3d54608e 100644 --- a/docs/UAA-APIs.rst +++ b/docs/UAA-APIs.rst @@ -2136,6 +2136,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": 1792688400000, + "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":1792688400000,"intent":"MY_APPLICATION_CODE"}' + + Verify User Links: ``GET /Users/{id}/verify-link`` -------------------------------------------------- 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 13d03c61062..7c5e0073771 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 @@ -36,7 +36,6 @@ import java.io.IOException; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; -import java.sql.Timestamp; public class ResetPasswordAuthenticationFilter extends OncePerRequestFilter { private final ResetPasswordService service; @@ -102,7 +101,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } 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()); + ExpiringCode newCode = expiringCodeStore.generateCode(expiringCode.getData(), expiringCode.getExpiresAt(), expiringCode.getIntent(), IdentityZoneHolder.get().getId()); request.setAttribute("code", newCode.getCode()); } 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 3c98b278aaa..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; @@ -179,15 +178,14 @@ public String resetPasswordPage(Model model, @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) { 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/codestore/CodeStoreEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/codestore/CodeStoreEndpoints.java index f9b47ac1524..eb3da1b349c 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; @@ -33,8 +40,12 @@ public class CodeStoreEndpoints { @ResponseStatus(HttpStatus.CREATED) @ResponseBody public ExpiringCode generateCode(@RequestBody ExpiringCode expiringCode) { + String intent = 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/test/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationFilterTest.java index e663bb846ed..95059dbdb84 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationFilterTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationFilterTest.java @@ -181,6 +181,36 @@ void error_during_password_reset_invalid_password_exception() throws Exception { verify(service, times(1)).resetPassword(any(ExpiringCode.class), eq(password)); } + @Test + void rejected_password_does_not_extend_the_code_lifetime() throws Exception { + when(service.resetPassword(any(ExpiringCode.class), anyString())).thenThrow(new InvalidPasswordException("failed", HttpStatus.BAD_REQUEST)); + + assertThat(codeLifetimeAfterFailedSubmit(false)).isNotNull(); + } + + @Test + void mismatched_confirmation_does_not_extend_the_code_lifetime() throws Exception { + assertThat(codeLifetimeAfterFailedSubmit(true)).isNotNull(); + } + + private ExpiringCode codeLifetimeAfterFailedSubmit(boolean mismatchedConfirmation) throws Exception { + var store = new InMemoryExpiringCodeStore(new TimeServiceImpl()); + Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + 60_000); + var code = store.generateCode("{}", expiresAt, "forgot_password_for_id:id", IdentityZoneHolder.get().getId()).getCode(); + var submit = MockMvcRequestBuilders.post("/reset_password.do") + .param("code", code) + .param("password", password) + .param("password_confirmation", mismatchedConfirmation ? "different" : password) + .param("email", email) + .buildRequest(new MockServletContext()); + + new ResetPasswordAuthenticationFilter(service, entryPoint, store).doFilterInternal(submit, response, chain); + + ExpiringCode renewed = store.peekCode((String) submit.getAttribute("code"), IdentityZoneHolder.get().getId()); + assertThat(renewed.getExpiresAt()).isBeforeOrEqualTo(expiresAt); + return renewed; + } + @Test void invalid_code_password_reset() throws Exception { request.setParameter("code", "invalid"); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/PasscodeAuthenticationFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/PasscodeAuthenticationFilterTest.java index b92b7471adf..c79a2ecd1a2 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/PasscodeAuthenticationFilterTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/PasscodeAuthenticationFilterTest.java @@ -4,14 +4,32 @@ import org.cloudfoundry.identity.uaa.authentication.PasscodeAuthenticationFilter.ExpiringCodeAuthenticationManager; import org.cloudfoundry.identity.uaa.codestore.ExpiringCodeStore; import org.cloudfoundry.identity.uaa.codestore.InMemoryExpiringCodeStore; +import org.cloudfoundry.identity.uaa.passcode.PasscodeInformation; +import org.cloudfoundry.identity.uaa.user.UaaUser; +import org.cloudfoundry.identity.uaa.user.UaaUserDatabase; +import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.cloudfoundry.identity.uaa.util.MockTimeService; import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.security.authentication.InsufficientAuthenticationException; +import org.springframework.security.core.Authentication; + +import java.sql.Timestamp; +import java.util.List; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; class PasscodeAuthenticationFilterTest { @@ -44,4 +62,60 @@ void setup() { ExpiringCodeStore expiringCodeStore = new InMemoryExpiringCodeStore(new MockTimeService()); manager = new ExpiringCodeAuthenticationManager(null, null, logger, expiringCodeStore, null); } -} \ No newline at end of file + + @Nested + class PasscodeIntent { + + private static final String USER_ID = "user-id-1"; + + private ExpiringCodeStore expiringCodeStore; + private UaaUserDatabase userDatabase; + private ExpiringCodeAuthenticationManager passcodeManager; + + @BeforeEach + void setUp() { + expiringCodeStore = new InMemoryExpiringCodeStore(new MockTimeService()); + userDatabase = mock(UaaUserDatabase.class); + UaaUser user = mock(UaaUser.class); + doReturn(List.of()).when(user).getAuthorities(); + when(userDatabase.retrieveUserById(USER_ID)).thenReturn(user); + passcodeManager = new ExpiringCodeAuthenticationManager( + userDatabase, null, LoggerFactory.getLogger(PasscodeAuthenticationFilterTest.class), expiringCodeStore, null); + } + + private String storePasscode(String intent) { + PasscodeInformation information = new PasscodeInformation(USER_ID, "marissa", "unused", "uaa", List.of()); + return expiringCodeStore.generateCode( + JsonUtils.writeValueAsString(information), + new Timestamp(System.currentTimeMillis() + 60_000), + intent, + IdentityZoneHolder.get().getId()).getCode(); + } + + private ExpiringCodeAuthentication authenticationFor(String passcode) { + return new ExpiringCodeAuthentication( + new PasscodeAuthenticationFilter.PasscodeHttpServletRequest(new MockHttpServletRequest("POST", "/oauth/token")), + passcode); + } + + @Test + void passcodeWithItsOwnIntentSignsIn() { + String passcode = storePasscode("PASSCODE " + USER_ID); + + Authentication result = passcodeManager.authenticate(authenticationFor(passcode)); + + assertThat(result.getPrincipal()).hasFieldOrPropertyWithValue("id", USER_ID); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", "EMAIL", "AUTOLOGIN", "PASSCODE", "PASSCODE other-user-id", "passcode user-id-1", "my-application-code"}) + void codeWithAnyOtherIntentIsRejected(String intent) { + String passcode = storePasscode(intent); + + assertThatThrownBy(() -> passcodeManager.authenticate(authenticationFor(passcode))) + .isInstanceOf(InsufficientAuthenticationException.class) + .hasMessage("Invalid passcode"); + } + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/codestore/CodeStoreEndpointsTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/codestore/CodeStoreEndpointsTests.java index cf80667eb0c..7d046592d49 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/codestore/CodeStoreEndpointsTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/codestore/CodeStoreEndpointsTests.java @@ -8,6 +8,9 @@ import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.InOrder; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -20,8 +23,10 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatNoException; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.inOrder; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.spy; import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; @@ -72,7 +77,7 @@ private String createDummyIdentityZone(@Autowired JdbcTemplate jdbcTemplate) { @Test void generateCode() { Timestamp expiresAt = new Timestamp(currentTime.get() + 60000); - ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, EMPTY_JSON, null); + ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, EMPTY_JSON, "EMAIL"); ExpiringCode result = codeStoreEndpoints.generateCode(expiringCode); @@ -85,45 +90,72 @@ void generateCode() { assertThat(result.getData()).isEqualTo(EMPTY_JSON); - verify(spiedExpiringCodeStore).generateCode(EMPTY_JSON, expiresAt, null, currentIdentityZoneId); + verify(spiedExpiringCodeStore).generateCode(EMPTY_JSON, expiresAt, "EMAIL", currentIdentityZoneId); + } + + @ParameterizedTest + @ValueSource(strings = {"EMAIL", "INVITATION", "REGISTRATION", "email", "my-application-code"}) + void generateCodeStoresGivenIntent(String intent) { + Timestamp expiresAt = new Timestamp(currentTime.get() + 60000); + ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, EMPTY_JSON, intent); + + ExpiringCode result = codeStoreEndpoints.generateCode(expiringCode); + + assertThat(result.getIntent()).isEqualTo(intent); + verify(spiedExpiringCodeStore).generateCode(EMPTY_JSON, expiresAt, intent, currentIdentityZoneId); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " "}) + void generateCodeWithoutIntent(String intent) { + Timestamp expiresAt = new Timestamp(currentTime.get() + 60000); + ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, EMPTY_JSON, intent); + + assertThatThrownBy(() -> codeStoreEndpoints.generateCode(expiringCode)) + .isInstanceOf(CodeStoreException.class) + .hasMessage("intent is required. Known intents: EMAIL, INVITATION, REGISTRATION; custom values are accepted.") + .extracting("status") + .isEqualTo(HttpStatus.BAD_REQUEST); + verify(spiedExpiringCodeStore, never()).generateCode(any(), any(), any(), any()); } @Test void generateCodeWithNullData() { Timestamp expiresAt = new Timestamp(currentTime.get() + 60000); - ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, null, null); + ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, null, "EMAIL"); assertThatThrownBy(() -> codeStoreEndpoints.generateCode(expiringCode)) .isInstanceOf(CodeStoreException.class) .hasMessage("data and expiresAt are required.") .extracting("status") .isEqualTo(HttpStatus.BAD_REQUEST); - verify(spiedExpiringCodeStore).generateCode(null, expiresAt, null, currentIdentityZoneId); + verify(spiedExpiringCodeStore).generateCode(null, expiresAt, "EMAIL", currentIdentityZoneId); } @Test void generateCodeWithNullExpiresAt() { - ExpiringCode expiringCode = new ExpiringCode(null, null, EMPTY_JSON, null); + ExpiringCode expiringCode = new ExpiringCode(null, null, EMPTY_JSON, "EMAIL"); assertThatThrownBy(() -> codeStoreEndpoints.generateCode(expiringCode)) .isInstanceOf(CodeStoreException.class) .hasMessage("data and expiresAt are required.") .extracting("status") .isEqualTo(HttpStatus.BAD_REQUEST); - verify(spiedExpiringCodeStore).generateCode(EMPTY_JSON, null, null, currentIdentityZoneId); + verify(spiedExpiringCodeStore).generateCode(EMPTY_JSON, null, "EMAIL", currentIdentityZoneId); } @Test void generateCodeWithExpiresAtInThePast() { Timestamp expiresAt = new Timestamp(currentTime.get() - 60000); - ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, EMPTY_JSON, null); + ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, EMPTY_JSON, "EMAIL"); assertThatThrownBy(() -> codeStoreEndpoints.generateCode(expiringCode)) .isInstanceOf(CodeStoreException.class) .hasMessage("expiresAt must be in the future.") .extracting("status") .isEqualTo(HttpStatus.BAD_REQUEST); - verify(spiedExpiringCodeStore).generateCode(EMPTY_JSON, expiresAt, null, currentIdentityZoneId); + verify(spiedExpiringCodeStore).generateCode(EMPTY_JSON, expiresAt, "EMAIL", currentIdentityZoneId); } @Test @@ -133,7 +165,7 @@ void generateCodeWithDuplicateCode() { spiedExpiringCodeStore.setGenerator(generator); Timestamp expiresAt = new Timestamp(currentTime.get() + 60000); - ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, EMPTY_JSON, null); + ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, EMPTY_JSON, "EMAIL"); assertThatNoException().isThrownBy(() -> codeStoreEndpoints.generateCode(expiringCode)); @@ -143,13 +175,13 @@ void generateCodeWithDuplicateCode() { .extracting("status") .isEqualTo(HttpStatus.INTERNAL_SERVER_ERROR); verify(spiedExpiringCodeStore, times(2)) - .generateCode(EMPTY_JSON, expiresAt, null, currentIdentityZoneId); + .generateCode(EMPTY_JSON, expiresAt, "EMAIL", currentIdentityZoneId); } @Test void retrieveCode() { Timestamp expiresAt = new Timestamp(currentTime.get() + 60000); - ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, EMPTY_JSON, null); + ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, EMPTY_JSON, "EMAIL"); ExpiringCode generatedCode = codeStoreEndpoints.generateCode(expiringCode); String code = generatedCode.getCode(); @@ -163,7 +195,7 @@ void retrieveCode() { .isEqualTo(HttpStatus.NOT_FOUND); InOrder inOrder = inOrder(spiedExpiringCodeStore); - inOrder.verify(spiedExpiringCodeStore).generateCode(EMPTY_JSON, expiresAt, null, currentIdentityZoneId); + inOrder.verify(spiedExpiringCodeStore).generateCode(EMPTY_JSON, expiresAt, "EMAIL", currentIdentityZoneId); inOrder.verify(spiedExpiringCodeStore).retrieveCode(code, currentIdentityZoneId); } @@ -193,7 +225,7 @@ void storeLargeData() { Arrays.fill(oneMb, 'a'); String data = new String(oneMb); Timestamp expiresAt = new Timestamp(currentTime.get() + 60000); - ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, data, null); + ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, data, "EMAIL"); ExpiringCode generatedCode = codeStoreEndpoints.generateCode(expiringCode); @@ -202,7 +234,7 @@ void storeLargeData() { assertThat(actualCode).isEqualTo(generatedCode); InOrder inOrder = inOrder(spiedExpiringCodeStore); - inOrder.verify(spiedExpiringCodeStore).generateCode(data, expiresAt, null, currentIdentityZoneId); + inOrder.verify(spiedExpiringCodeStore).generateCode(data, expiresAt, "EMAIL", currentIdentityZoneId); inOrder.verify(spiedExpiringCodeStore).retrieveCode(code, currentIdentityZoneId); } @@ -210,7 +242,7 @@ void storeLargeData() { void retrieveCodeWithExpiredCode() { int expiresIn = 1000; Timestamp expiresAt = new Timestamp(currentTime.get() + expiresIn); - ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, EMPTY_JSON, null); + ExpiringCode expiringCode = new ExpiringCode(null, expiresAt, EMPTY_JSON, "EMAIL"); ExpiringCode generatedCode = codeStoreEndpoints.generateCode(expiringCode); currentTime.addAndGet(expiresIn + 1); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/login/ResetPasswordControllerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/login/ResetPasswordControllerTest.java index 72e63ad0263..3b6dec44ec6 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/login/ResetPasswordControllerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/login/ResetPasswordControllerTest.java @@ -329,8 +329,23 @@ void resetPasswordPageDuplicate() throws Exception { .andExpect(status().isOk()) .andExpect(view().name("reset_password")); mockMvc.perform(get("/reset_password").param("email", "user@example.com").param("code", code.getCode())) - .andExpect(status().isUnprocessableEntity()) - .andExpect(view().name("forgot_password")); + .andExpect(status().isOk()) + .andExpect(view().name("reset_password")); + } + + @Test + void resetPasswordPageDoesNotExtendTheCodeLifetime() throws Exception { + Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + 60_000); + ExpiringCode code = codeStore.generateCode("{\"user_id\" : \"some-user-id\"}", expiresAt, FORGOT_PASSWORD_INTENT_PREFIX + "some-user-id", IdentityZoneHolder.get().getId()); + + MvcResult result = mockMvc.perform(get("/reset_password").param("code", code.getCode())) + .andExpect(status().isOk()) + .andReturn(); + + String formCode = (String) result.getModelAndView().getModel().get("code"); + ExpiringCode codeInTheForm = codeStore.peekCode(formCode, IdentityZoneHolder.get().getId()); + assertThat(codeInTheForm).isNotNull(); + assertThat(codeInTheForm.getExpiresAt()).isBeforeOrEqualTo(expiresAt); } @Test diff --git a/uaa/slateCustomizations/source/index.html.md.erb b/uaa/slateCustomizations/source/index.html.md.erb index 9332b0d65e4..226792cd8ce 100644 --- a/uaa/slateCustomizations/source/index.html.md.erb +++ b/uaa/slateCustomizations/source/index.html.md.erb @@ -2986,6 +2986,35 @@ _Request Parameters_ <%= render('LoginInfoEndpointDocs/perform_auto_login/query-parameters.md') %> +# Expiring Codes + +## Create a Code + +`POST /Codes` stores a one time code with an expiry and an intent, and returns the generated code. A code can be read back +once with `GET /Codes/{code}`; reading it removes it. Codes are stored per identity zone. + +The `intent` is required and must not be blank; a request without one is rejected with `400 Bad Request` and the message +`intent is required. Known intents: EMAIL, INVITATION, REGISTRATION; custom values are accepted.` +The UAA flows only accept a code whose intent matches the flow: `EMAIL` for email change verification, `INVITATION` for +invitation acceptance and `REGISTRATION` for account activation. Any other value is stored as given and is meant for codes +that your own application creates and redeems. + +<%= render('CodeStoreEndpointDocs/document_create_code/curl-request.md') %> +<%= render('CodeStoreEndpointDocs/document_create_code/http-request.md') %> +<%= render('CodeStoreEndpointDocs/document_create_code/http-response.md') %> + +_Request Headers_ + +<%= render('CodeStoreEndpointDocs/document_create_code/request-headers.md') %> + +_Request Body_ + +<%= render('CodeStoreEndpointDocs/document_create_code/request-fields.md') %> + +_Response Body_ + +<%= render('CodeStoreEndpointDocs/document_create_code/response-fields.md') %> + # External Login Server The UAA provides endpoints that facilitate the use of an external login server. A server that handles the UI diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/InvitationsIT.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/InvitationsIT.java index a497084e162..7bfd956791c 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/InvitationsIT.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/InvitationsIT.java @@ -117,7 +117,7 @@ public static String createInvitation(String baseUrl, String username, String us invitationHeaders.add("Authorization", "Bearer " + loginToken); Timestamp expiry = new Timestamp(System.currentTimeMillis() + TimeUnit.MILLISECONDS.convert(System.currentTimeMillis() + 24 * 3600, TimeUnit.MILLISECONDS)); - ExpiringCode expiringCode = new ExpiringCode(null, expiry, "{\"origin\":\"" + origin + "\", \"client_id\":\"app\", \"redirect_uri\":\"" + redirectUri + "\", \"user_id\":\"" + userId + "\", \"email\":\"" + userEmail + "\"}", null); + ExpiringCode expiringCode = new ExpiringCode(null, expiry, "{\"origin\":\"" + origin + "\", \"client_id\":\"app\", \"redirect_uri\":\"" + redirectUri + "\", \"user_id\":\"" + userId + "\", \"email\":\"" + userEmail + "\"}", "INVITATION"); HttpEntity expiringCodeRequest = new HttpEntity<>(expiringCode, invitationHeaders); ResponseEntity expiringCodeResponse = uaaTemplate.exchange(baseUrl + "/Codes", POST, expiringCodeRequest, ExpiringCode.class); expiringCode = expiringCodeResponse.getBody(); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/ResetPasswordIT.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/ResetPasswordIT.java index 822fcf22eb6..f422778e27f 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/ResetPasswordIT.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/integration/feature/ResetPasswordIT.java @@ -202,6 +202,12 @@ void codesCanOnlyBeUsedOnce() { beginPasswordReset(username); String link = getPasswordResetLink(email); webDriver.get(link); + + webDriver.findElement(By.name("password")).sendKeys("new_password"); + webDriver.findElement(By.name("password_confirmation")).sendKeys("new_password"); + webDriver.clickAndWait(By.xpath("//input[@value='Create new password']")); + assertThat(webDriver.findElement(By.cssSelector(".alert-success")).getText()).contains("Password reset successful"); + webDriver.get(link); assertThat(webDriver.findElement(By.cssSelector(".error-message")).getText()).contains("Sorry, your reset password link is no longer valid. You can request another one below."); } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/ResetPasswordControllerMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/ResetPasswordControllerMockMvcTests.java index 9aac6697162..94ef74e642a 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/ResetPasswordControllerMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/ResetPasswordControllerMockMvcTests.java @@ -32,6 +32,7 @@ import org.springframework.security.web.savedrequest.DefaultSavedRequest; import org.springframework.security.web.savedrequest.SavedRequest; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.context.WebApplicationContext; @@ -54,6 +55,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.request; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; @DefaultTestContext public class ResetPasswordControllerMockMvcTests { @@ -404,6 +406,42 @@ void resetPassword_ReturnsUnprocessableEntity_NewPasswordNotAccordingToPolicy() webApplicationContext.getBean(JdbcIdentityProviderProvisioning.class).update(uaaProvider, uaaProvider.getIdentityZoneId()); } + @Test + void openingTheResetLinkTwiceStillWorksAndDoesNotExtendTheCodeLifetime() throws Exception { + ScimUser user = getScimUser(); + PasswordChange change = new PasswordChange(user.getId(), user.getUserName(), user.getPasswordLastModified(), "", ""); + Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + 60_000); + ExpiringCode code = codeStore.generateCode(JsonUtils.writeValueAsString(change), expiresAt, FORGOT_PASSWORD_INTENT_PREFIX + user.getId(), IdentityZoneHolder.get().getId()); + + mockMvc.perform(get("/reset_password").param("code", code.getCode())) + .andExpect(status().isOk()); + MvcResult second = mockMvc.perform(get("/reset_password").param("code", code.getCode())) + .andExpect(status().isOk()) + .andExpect(view().name("reset_password")) + .andReturn(); + + String formCode = (String) second.getModelAndView().getModel().get("code"); + assertThat(codeStore.peekCode(formCode, IdentityZoneHolder.get().getId()).getExpiresAt()).isBeforeOrEqualTo(expiresAt); + mockMvc.perform(createChangePasswordRequest(user, formCode, true, "secret1", "secret1")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?success=password_reset")); + } + + @Test + void aRejectedPasswordDoesNotExtendTheCodeLifetime() throws Exception { + ScimUser user = getScimUser(); + PasswordChange change = new PasswordChange(user.getId(), user.getUserName(), user.getPasswordLastModified(), "", ""); + Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + 60_000); + ExpiringCode code = codeStore.generateCode(JsonUtils.writeValueAsString(change), expiresAt, FORGOT_PASSWORD_INTENT_PREFIX + user.getId(), IdentityZoneHolder.get().getId()); + + MvcResult result = mockMvc.perform(createChangePasswordRequest(user, code, true, "secret1", "different")) + .andExpect(status().isUnprocessableEntity()) + .andReturn(); + + String renewedCode = (String) result.getRequest().getAttribute("code"); + assertThat(codeStore.peekCode(renewedCode, IdentityZoneHolder.get().getId()).getExpiresAt()).isBeforeOrEqualTo(expiresAt); + } + private MockHttpServletRequestBuilder createChangePasswordRequest(ScimUser user, ExpiringCode code, boolean useCSRF) throws Exception { return createChangePasswordRequest(user, code, useCSRF, "newpassw0rDl", "newpassw0rDl"); } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/ResetPasswordControllerMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/ResetPasswordControllerMockMvcZonePathTests.java index 3a7c42c245f..de91eff3739 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/ResetPasswordControllerMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/ResetPasswordControllerMockMvcZonePathTests.java @@ -418,6 +418,42 @@ void resetPassword_ReturnsUnprocessableEntity_NewPasswordNotAccordingToPolicy() webApplicationContext.getBean(JdbcIdentityProviderProvisioning.class).update(uaaProvider, uaaProvider.getIdentityZoneId()); } + @Test + void openingTheResetLinkTwiceStillWorksAndDoesNotExtendTheCodeLifetime() throws Exception { + ScimUser user = getScimUser(); + PasswordChange change = new PasswordChange(user.getId(), user.getUserName(), user.getPasswordLastModified(), "", ""); + Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + 60_000); + ExpiringCode code = codeStore.generateCode(JsonUtils.writeValueAsString(change), expiresAt, FORGOT_PASSWORD_INTENT_PREFIX + user.getId(), IdentityZoneHolder.get().getId()); + + mockMvc.perform(get("/reset_password").param("code", code.getCode())) + .andExpect(status().isOk()); + MvcResult second = mockMvc.perform(get("/reset_password").param("code", code.getCode())) + .andExpect(status().isOk()) + .andExpect(view().name("reset_password")) + .andReturn(); + + String formCode = (String) second.getModelAndView().getModel().get("code"); + assertThat(codeStore.peekCode(formCode, IdentityZoneHolder.get().getId()).getExpiresAt()).isBeforeOrEqualTo(expiresAt); + mockMvc.perform(createChangePasswordRequest(user, formCode, true, "secret1", "secret1")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?success=password_reset")); + } + + @Test + void aRejectedPasswordDoesNotExtendTheCodeLifetime() throws Exception { + ScimUser user = getScimUser(); + PasswordChange change = new PasswordChange(user.getId(), user.getUserName(), user.getPasswordLastModified(), "", ""); + Timestamp expiresAt = new Timestamp(System.currentTimeMillis() + 60_000); + ExpiringCode code = codeStore.generateCode(JsonUtils.writeValueAsString(change), expiresAt, FORGOT_PASSWORD_INTENT_PREFIX + user.getId(), IdentityZoneHolder.get().getId()); + + MvcResult result = mockMvc.perform(createChangePasswordRequest(user, code, true, "secret1", "different")) + .andExpect(status().isUnprocessableEntity()) + .andReturn(); + + String renewedCode = (String) result.getRequest().getAttribute("code"); + assertThat(codeStore.peekCode(renewedCode, IdentityZoneHolder.get().getId()).getExpiresAt()).isBeforeOrEqualTo(expiresAt); + } + private MockHttpServletRequestBuilder createChangePasswordRequest(ScimUser user, ExpiringCode code, boolean useCSRF) throws Exception { return createChangePasswordRequest(user, code, useCSRF, "newpassw0rDl", "newpassw0rDl"); } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/codestore/CodeStoreEndpointDocs.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/codestore/CodeStoreEndpointDocs.java new file mode 100644 index 00000000000..56cb1fecee6 --- /dev/null +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/codestore/CodeStoreEndpointDocs.java @@ -0,0 +1,60 @@ +package org.cloudfoundry.identity.uaa.mock.codestore; + +import org.cloudfoundry.identity.uaa.mock.EndpointDocs; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneSwitchingFilter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.restdocs.snippet.Snippet; + +import static org.cloudfoundry.identity.uaa.test.SnippetUtils.fieldWithPath; +import static org.cloudfoundry.identity.uaa.test.SnippetUtils.headerWithName; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.restdocs.headers.HeaderDocumentation.requestHeaders; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.preprocessResponse; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.JsonFieldType.NUMBER; +import static org.springframework.restdocs.payload.JsonFieldType.STRING; +import static org.springframework.restdocs.payload.PayloadDocumentation.requestFields; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; + +class CodeStoreEndpointDocs extends EndpointDocs { + + private String loginToken; + + @BeforeEach + void setUpToken() throws Exception { + loginToken = MockMvcUtils.getClientOAuthAccessToken(mockMvc, "login", "loginsecret", "oauth.login"); + } + + @Test + void document_create_code() throws Exception { + Snippet requestHeaders = requestHeaders( + headerWithName("Authorization").required().description("Bearer token with the scope `oauth.login` present, or a UAA admin token."), + headerWithName(IdentityZoneSwitchingFilter.HEADER).optional(null).description("If using a `zones..admin` scope/token, indicates what zone this request goes to by supplying a zone_id."), + headerWithName(IdentityZoneSwitchingFilter.SUBDOMAIN_HEADER).optional(null).description("If using a `zones..admin` scope/token, indicates what zone this request goes to by supplying a subdomain.") + ); + + Snippet requestFields = requestFields( + fieldWithPath("data").required().type(STRING).description("The data stored with the code, returned when the code is redeemed. For the flows below this is a JSON document written as a string."), + fieldWithPath("expiresAt").required().type(NUMBER).description("Expiry time of the code in milliseconds since the epoch. It must be in the future."), + fieldWithPath("intent").required().type(STRING).description("What the code may be used for. It is required and must not be blank. The values `EMAIL` (email change verification), `INVITATION` (invitation acceptance) and `REGISTRATION` (account activation) are used by the UAA flows of the same name; the match is case sensitive. Any other value is stored as given, for codes that your own application creates and redeems.") + ); + + Snippet responseFields = responseFields( + fieldWithPath("code").type(STRING).description("The generated one time code."), + fieldWithPath("expiresAt").type(NUMBER).description("Expiry time of the code in milliseconds since the epoch."), + fieldWithPath("data").type(STRING).description("The data stored with the code."), + fieldWithPath("intent").type(STRING).description("The intent stored with the code.") + ); + + mockMvc.perform(post("/Codes") + .header("Authorization", "Bearer " + loginToken) + .contentType(APPLICATION_JSON) + .accept(APPLICATION_JSON) + .content("{\"data\":\"{\\\"user_id\\\":\\\"a-user-id\\\"}\",\"expiresAt\":" + (System.currentTimeMillis() + 600_000) + ",\"intent\":\"MY_APPLICATION_CODE\"}")) + .andDo(document("{ClassName}/{methodName}", preprocessResponse(prettyPrint()), requestHeaders, requestFields, responseFields)); + } +} diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/codestore/CodeStoreEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/codestore/CodeStoreEndpointsMockMvcTests.java index 4a5dfc124d9..53fc87628d6 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/codestore/CodeStoreEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/codestore/CodeStoreEndpointsMockMvcTests.java @@ -10,6 +10,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -64,7 +65,7 @@ void tearDown() { @ValueSource(strings = {"/Codes", "/Codes/"}) void generateCode(String url) throws Exception { Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) @@ -81,11 +82,45 @@ void generateCode(String url) throws Exception { } + @ParameterizedTest + @ValueSource(strings = {"EMAIL", "INVITATION", "REGISTRATION", "email", "my-application-code"}) + void generateCodeStoresGivenIntent(String intent) throws Exception { + Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); + ExpiringCode code = new ExpiringCode(null, ts, "{}", intent); + + mockMvc.perform(post("/Codes") + .header("Authorization", "Bearer " + loginToken) + .contentType(APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(code))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.intent").value(intent)); + + assertThat(jdbcTemplate.queryForObject("select intent from expiring_code_store", String.class)).isEqualTo(intent); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " "}) + void generateCodeWithoutIntent(String intent) throws Exception { + Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); + ExpiringCode code = new ExpiringCode(null, ts, "{}", intent); + + mockMvc.perform(post("/Codes") + .header("Authorization", "Bearer " + loginToken) + .contentType(APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(code))) + .andExpect(status().isBadRequest()); + + assertThat(jdbcTemplate.queryForObject("select count(*) from expiring_code_store", Integer.class)).isZero(); + } + @ParameterizedTest @ValueSource(strings = {"/Codes", "/Codes/"}) void generateCodeWithInvalidScope(String url) throws Exception { Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String loginToken = testClient.getClientCredentialsOAuthAccessToken("admin", "adminsecret", "scim.read"); String requestBody = JsonUtils.writeValueAsString(code); @@ -103,7 +138,7 @@ void generateCodeWithInvalidScope(String url) throws Exception { @ValueSource(strings = {"/Codes", "/Codes/"}) void generateCodeAnonymous(String url) throws Exception { Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) @@ -119,7 +154,7 @@ void generateCodeAnonymous(String url) throws Exception { @ValueSource(strings = {"/Codes", "/Codes/"}) void generateCodeWithNullData(String url) throws Exception { Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); - ExpiringCode code = new ExpiringCode(null, ts, null, null); + ExpiringCode code = new ExpiringCode(null, ts, null, "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -135,7 +170,7 @@ void generateCodeWithNullData(String url) throws Exception { @ParameterizedTest @ValueSource(strings = {"/Codes", "/Codes/"}) void generateCodeWithNullExpiresAt(String url) throws Exception { - ExpiringCode code = new ExpiringCode(null, null, "{}", null); + ExpiringCode code = new ExpiringCode(null, null, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -152,7 +187,7 @@ void generateCodeWithNullExpiresAt(String url) throws Exception { @ValueSource(strings = {"/Codes", "/Codes/"}) void generateCodeWithExpiresAtInThePast(String url) throws Exception { Timestamp ts = new Timestamp(System.currentTimeMillis() - 60000); - ExpiringCode code = new ExpiringCode(null, ts, null, null); + ExpiringCode code = new ExpiringCode(null, ts, null, "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -169,7 +204,7 @@ void generateCodeWithExpiresAtInThePast(String url) throws Exception { @ValueSource(strings = {"/Codes", "/Codes/"}) void retrieveCode(String url) throws Exception { Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -200,7 +235,7 @@ void retrieveCode(String url) throws Exception { @ValueSource(strings = {"/Codes", "/Codes/"}) void retrieveCodeThatIsExpired(String url) throws Exception { Timestamp ts = new Timestamp(Long.MAX_VALUE); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -227,7 +262,7 @@ void retrieveCodeThatIsExpired(String url) throws Exception { @ValueSource(strings = {"/Codes", "/Codes/"}) void codeThatIsExpiredIsDeletedOnCreateOfNewCode(String url) throws Exception { Timestamp ts = new Timestamp(Long.MAX_VALUE); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -244,7 +279,7 @@ void codeThatIsExpiredIsDeletedOnCreateOfNewCode(String url) throws Exception { expireAllCodes(); ts = new Timestamp(Long.MAX_VALUE); - code = new ExpiringCode(null, ts, "{}", null); + code = new ExpiringCode(null, ts, "{}", "EMAIL"); requestBody = JsonUtils.writeValueAsString(code); post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -268,7 +303,7 @@ class WithCustomExpirationInterval { void setUp() throws Exception { // TODO: Why is this here? Timestamp ts = new Timestamp(Long.MAX_VALUE); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post("/Codes") .header("Authorization", "Bearer " + loginToken) @@ -296,7 +331,7 @@ void tearDown() { void verifyExpirationIntervalWorks(String url) throws Exception { jdbcExpiringCodeStore.setExpirationInterval(10000000); Timestamp ts = new Timestamp(System.currentTimeMillis() + 1000); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/codestore/CodeStoreEndpointsMockMvcZonePathTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/codestore/CodeStoreEndpointsMockMvcZonePathTests.java index fa5197bc5f8..45f54f99def 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/codestore/CodeStoreEndpointsMockMvcZonePathTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/codestore/CodeStoreEndpointsMockMvcZonePathTests.java @@ -15,6 +15,7 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.NullSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; @@ -77,7 +78,7 @@ void tearDown() { @ValueSource(strings = {"/Codes", "/Codes/"}) void generateCode(String url) throws Exception { Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) @@ -94,11 +95,45 @@ void generateCode(String url) throws Exception { } + @ParameterizedTest + @ValueSource(strings = {"EMAIL", "INVITATION", "REGISTRATION", "email", "my-application-code"}) + void generateCodeStoresGivenIntent(String intent) throws Exception { + Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); + ExpiringCode code = new ExpiringCode(null, ts, "{}", intent); + + mockMvc.perform(post("/Codes") + .header("Authorization", "Bearer " + loginToken) + .contentType(APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(code))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.intent").value(intent)); + + assertThat(jdbcTemplate.queryForObject("select intent from expiring_code_store", String.class)).isEqualTo(intent); + } + + @ParameterizedTest + @NullSource + @ValueSource(strings = {"", " "}) + void generateCodeWithoutIntent(String intent) throws Exception { + Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); + ExpiringCode code = new ExpiringCode(null, ts, "{}", intent); + + mockMvc.perform(post("/Codes") + .header("Authorization", "Bearer " + loginToken) + .contentType(APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(code))) + .andExpect(status().isBadRequest()); + + assertThat(jdbcTemplate.queryForObject("select count(*) from expiring_code_store", Integer.class)).isZero(); + } + @ParameterizedTest @ValueSource(strings = {"/Codes", "/Codes/"}) void generateCodeWithInvalidScope(String url) throws Exception { Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String loginToken = testClient.getClientCredentialsOAuthAccessToken("admin", "adminsecret", "scim.read"); String requestBody = JsonUtils.writeValueAsString(code); @@ -116,7 +151,7 @@ void generateCodeWithInvalidScope(String url) throws Exception { @ValueSource(strings = {"/Codes", "/Codes/"}) void generateCodeAnonymous(String url) throws Exception { Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) @@ -132,7 +167,7 @@ void generateCodeAnonymous(String url) throws Exception { @ValueSource(strings = {"/Codes", "/Codes/"}) void generateCodeWithNullData(String url) throws Exception { Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); - ExpiringCode code = new ExpiringCode(null, ts, null, null); + ExpiringCode code = new ExpiringCode(null, ts, null, "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -148,7 +183,7 @@ void generateCodeWithNullData(String url) throws Exception { @ParameterizedTest @ValueSource(strings = {"/Codes", "/Codes/"}) void generateCodeWithNullExpiresAt(String url) throws Exception { - ExpiringCode code = new ExpiringCode(null, null, "{}", null); + ExpiringCode code = new ExpiringCode(null, null, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -165,7 +200,7 @@ void generateCodeWithNullExpiresAt(String url) throws Exception { @ValueSource(strings = {"/Codes", "/Codes/"}) void generateCodeWithExpiresAtInThePast(String url) throws Exception { Timestamp ts = new Timestamp(System.currentTimeMillis() - 60000); - ExpiringCode code = new ExpiringCode(null, ts, null, null); + ExpiringCode code = new ExpiringCode(null, ts, null, "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -182,7 +217,7 @@ void generateCodeWithExpiresAtInThePast(String url) throws Exception { @ValueSource(strings = {"/Codes", "/Codes/"}) void retrieveCode(String url) throws Exception { Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -213,7 +248,7 @@ void retrieveCode(String url) throws Exception { @ValueSource(strings = {"/Codes", "/Codes/"}) void retrieveCodeThatIsExpired(String url) throws Exception { Timestamp ts = new Timestamp(Long.MAX_VALUE); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -240,7 +275,7 @@ void retrieveCodeThatIsExpired(String url) throws Exception { @ValueSource(strings = {"/Codes", "/Codes/"}) void codeThatIsExpiredIsDeletedOnCreateOfNewCode(String url) throws Exception { Timestamp ts = new Timestamp(Long.MAX_VALUE); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -257,7 +292,7 @@ void codeThatIsExpiredIsDeletedOnCreateOfNewCode(String url) throws Exception { expireAllCodes(); ts = new Timestamp(Long.MAX_VALUE); - code = new ExpiringCode(null, ts, "{}", null); + code = new ExpiringCode(null, ts, "{}", "EMAIL"); requestBody = JsonUtils.writeValueAsString(code); post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -294,7 +329,7 @@ class WithCustomExpirationInterval { void setUp() throws Exception { // TODO: Why is this here? Timestamp ts = new Timestamp(Long.MAX_VALUE); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post("/Codes") .header("Authorization", "Bearer " + loginToken) @@ -322,7 +357,7 @@ void tearDown() { void verifyExpirationIntervalWorks(String url) throws Exception { jdbcExpiringCodeStore.setExpirationInterval(10000000); Timestamp ts = new Timestamp(System.currentTimeMillis() + 1000); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); MockHttpServletRequestBuilder post = post(url) .header("Authorization", "Bearer " + loginToken) @@ -351,7 +386,7 @@ void codes_endpoint_responds_for_zone_path(ZoneResolutionMode mode) throws Excep MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, loginClient, IdentityZoneHolder.getCurrentZoneId()); Timestamp ts = new Timestamp(System.currentTimeMillis() + 60000); - ExpiringCode code = new ExpiringCode(null, ts, "{}", null); + ExpiringCode code = new ExpiringCode(null, ts, "{}", "EMAIL"); String requestBody = JsonUtils.writeValueAsString(code); mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/Codes") From 9fb964055eaab4f69c5f188fe477c9dcb8068e0f Mon Sep 17 00:00:00 2001 From: Filip Hanik Date: Thu, 29 Oct 2026 11:36:03 -0700 Subject: [PATCH 04/10] fix: signup and email change for an existing address, and verification links from the user API - POST /create_account.do and POST /change_email.do answer the same way for an address that already has an account as for a new address. The owner of the address receives an email (account_exists.html) instead of an activation or verification link. The signup confirmation page names the address the email was sent to. - The duplicate-email handling in ChangeEmailController that can no longer be reached and the email_change.username_exists message are removed. - A link from GET /Users/{id}/verify-link only verifies the user: the password set by the caller keeps working, and the user is redirected to /login?success=verify_success. - The password reset code on AccountCreationResponse is not serialized. - Docs: login.selfServiceLinksEnabled in UAA-Configuration-Reference.md. --- docs/UAA-Configuration-Reference.md | 9 +++ .../uaa/account/AccountCreationService.java | 3 +- .../uaa/account/AccountsController.java | 17 ++++- .../uaa/account/ChangeEmailController.java | 11 +-- .../account/EmailAccountCreationService.java | 32 +++++++- .../uaa/account/EmailChangeEmailService.java | 15 +++- .../uaa/scim/endpoints/ScimUserEndpoints.java | 2 +- .../identity/uaa/scim/util/ScimUtils.java | 22 ++++++ .../templates/mail/account_exists.html | 22 ++++++ .../templates/web/accounts/email_sent.html | 5 +- .../account/AccountCreationResponseTest.java | 21 +++++ .../AccountsControllerViewZonePathTests.java | 20 +++++ .../account/EmailChangeEmailServiceTest.java | 7 +- .../uaa/login/AccountsControllerTest.java | 50 ++++++++++-- .../uaa/login/ChangeEmailControllerTest.java | 13 ++-- .../EmailAccountCreationServiceTests.java | 42 +++++++++- server/src/test/resources/messages.properties | 1 + uaa/src/main/resources/messages.properties | 2 +- .../integration/feature/CreateAccountIT.java | 4 +- .../login/AccountsControllerMockMvcTests.java | 73 ++++++++++++++++++ ...ccountsControllerMockMvcZonePathTests.java | 76 +++++++++++++++++++ .../ScimUserEndpointsMockMvcTests.java | 27 ++++++- ...ScimUserEndpointsMockMvcZonePathTests.java | 27 ++++++- 23 files changed, 459 insertions(+), 42 deletions(-) create mode 100644 server/src/main/resources/templates/mail/account_exists.html create mode 100644 server/src/test/java/org/cloudfoundry/identity/uaa/account/AccountCreationResponseTest.java diff --git a/docs/UAA-Configuration-Reference.md b/docs/UAA-Configuration-Reference.md index ad3017b1fd9..ed645247842 100644 --- a/docs/UAA-Configuration-Reference.md +++ b/docs/UAA-Configuration-Reference.md @@ -1900,6 +1900,15 @@ When `true`, "Create Account" and "Forgot Password" links are displayed on the l > was verified can never be used to authenticate. Any client or signup redirect configured for the > registration (an explicit `redirect_uri` or the client's `signup_redirect_url`) is applied after the > owner has set their password, provided it matches one of the client's registered redirect URIs. +> +> **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) 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 3292a91c11a..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,7 +20,7 @@ class AccountCreationResponse { private String email; @JsonProperty("redirect_location") private String redirectLocation; - @JsonProperty("password_reset_code") + @JsonIgnore private String passwordResetCode; public AccountCreationResponse(String userId, String username, String email, String redirectLocation) { 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 aab576a47a9..262e7f92a11 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 @@ -20,11 +20,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,7 +68,8 @@ 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) { BrandingInformation zoneBranding = IdentityZoneHolder.get().getConfig().getBranding(); if (zoneBranding != null && zoneBranding.getConsent() != null && !doesUserConsent) { @@ -95,6 +99,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,6 +129,16 @@ public String verifyUser(Model model, return "accounts/link_prompt"; } + 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())) { + res += "&form_redirect_uri=" + URLEncoder.encode(redirectLocation, StandardCharsets.UTF_8); + } + 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. 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 780d0933f52..cbb967152da 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 @@ -74,16 +74,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"; } 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 3b3603ec3bb..657870fdac3 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 @@ -23,7 +23,6 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Qualifier; -import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.util.StringUtils; import org.springframework.web.client.HttpClientErrorException; @@ -88,7 +87,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()); } @@ -128,6 +127,16 @@ public AccountCreationResponse completeActivation(String code) { 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 (Boolean.parseBoolean(data.get(ScimUtils.USER_PROVISIONED))) { + // 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. + 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. Discard the registration password so it can never authenticate, and require the @@ -139,8 +148,6 @@ public AccountCreationResponse completeActivation(String code) { scimUserProvisioning.updatePasswordChangeRequired(user.getId(), true, zoneId); user = scimUserProvisioning.verifyUser(user.getId(), -1, zoneId); - String clientId = data.get("client_id"); - String redirectUri = data.get("redirect_uri") != null ? data.get("redirect_uri") : ""; // 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); @@ -211,6 +218,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 9d355761209..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; } } @@ -152,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/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/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/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 +