diff --git a/.gitignore b/.gitignore index a66ac6f646b..5836f2f94c8 100644 --- a/.gitignore +++ b/.gitignore @@ -52,3 +52,4 @@ scripts/certificates/tmp/** # temporary tomcat war file directories scripts/boot/tomcat/** +.vscode diff --git a/build.gradle b/build.gradle index 3ae4d33a935..c9e9b2a6a79 100644 --- a/build.gradle +++ b/build.gradle @@ -88,11 +88,13 @@ subprojects { testImplementation(libraries.junit5JupiterApi) testImplementation(libraries.junit5JupiterParams) testImplementation(libraries.junit5JupiterEngine) + testImplementation(libraries.junit5PlatformCommons) + testImplementation(libraries.junit5PlatformEngine) testImplementation(libraries.unboundIdLdapSdk) testRuntimeOnly(libraries.jacocoAgent) - // Ensure test runtime dependencies are included - testRuntimeOnly('org.junit.platform:junit-platform-launcher') + // Ensure test runtime dependencies are included (aligned with Jupiter 5.13 / Platform 1.13) + testRuntimeOnly(libraries.junit5PlatformLauncher) compileOnly(libraries.lombok) annotationProcessor(libraries.lombok) diff --git a/dependencies.gradle b/dependencies.gradle index a06ce6c6365..1bc5a060777 100644 --- a/dependencies.gradle +++ b/dependencies.gradle @@ -13,6 +13,8 @@ versions.guavaVersion = "33.4.8-jre" versions.seleniumVersion = "4.38.0" versions.braveVersion = "6.3.0" versions.opensaml = "4.0.1" +versions.junit5Jupiter = "5.14.2" +versions.junit5Platform = "1.14.2" // Versions we're overriding from the Spring Boot Bom (Dependabot does not issue PRs to bump these versions, so we need to manually bump them) ext["selenium.version"] = "${versions.seleniumVersion}" // Selenium for integration tests only @@ -52,9 +54,12 @@ libraries.jodaTime = "joda-time:joda-time:2.14.0" libraries.jsonAssert = "org.skyscreamer:jsonassert" libraries.jsonPath = "com.jayway.jsonpath:json-path" libraries.jsonPathAssert = "com.jayway.jsonpath:json-path-assert" -libraries.junit5JupiterApi = "org.junit.jupiter:junit-jupiter-api" -libraries.junit5JupiterEngine = "org.junit.jupiter:junit-jupiter-engine" -libraries.junit5JupiterParams = "org.junit.jupiter:junit-jupiter-params" +libraries.junit5JupiterApi = "org.junit.jupiter:junit-jupiter-api:${versions.junit5Jupiter}" +libraries.junit5JupiterEngine = "org.junit.jupiter:junit-jupiter-engine:${versions.junit5Jupiter}" +libraries.junit5JupiterParams = "org.junit.jupiter:junit-jupiter-params:${versions.junit5Jupiter}" +libraries.junit5PlatformCommons = "org.junit.platform:junit-platform-commons:${versions.junit5Platform}" +libraries.junit5PlatformEngine = "org.junit.platform:junit-platform-engine:${versions.junit5Platform}" +libraries.junit5PlatformLauncher = "org.junit.platform:junit-platform-launcher:${versions.junit5Platform}" libraries.log4jCore = "org.apache.logging.log4j:log4j-core" libraries.lombok = "org.projectlombok:lombok" libraries.mariaJdbcDriver = "org.mariadb.jdbc:mariadb-java-client" diff --git a/docs/path-based-zones-endpoints-without-z-support.md b/docs/path-based-zones-endpoints-without-z-support.md new file mode 100644 index 00000000000..24a3d763265 --- /dev/null +++ b/docs/path-based-zones-endpoints-without-z-support.md @@ -0,0 +1,198 @@ +# Endpoints Without `/z/{subdomain}/` Support (Discovery) + +This document lists endpoints that do **not** yet have a dual path mapping for `/z/{subdomain}/...`. Security config may already allow `/z/*/path` in some cases; the **controller** (or filter) still only maps the non-zone path. Tests that hit these paths are listed so you can extend them with zone-path permutations or add new tests when adding `/z/` support. + +**Legend:** +- **Controller has /z/?** – Controller (or endpoint class) has a second path variant like `/z/{subdomain}/...`. +- **Security has /z/*/?** – At least one security filter chain or requestMatcher includes a `/z/*/...` (or `/z/{subdomain}/...`) pattern for this path. +- **Tests** – Test classes or test methods that perform requests to these paths (get/post/put/delete to the path). These are the tests that may need zone-path parameterization or new cases when you add `/z/` support. + +--- + +## Table of Contents + +1. ✅ [Reset / forgot password (UI)](#1-reset--forgot-password-ui) +2. ✅ [Change password (UI)](#2-change-password-ui) +3. ✅ [Change email / verify email (UI)](#3-change-email--verify-email-ui) +4. ✅ [Force password change (UI)](#4-force-password-change-ui) +5. ✅ [Logged out (UI)](#5-logged-out-ui) +6. ✅ [Home and error pages (UI)](#6-home-and-error-pages-ui) +7. ✅ [Session (UI)](#7-session-ui) +8. ✅ [Invitations (UI + API)](#8-invitations-ui--api) +9. ❌ [Profile (UI)](#9-profile-ui) +10. ❌ [Passcode (API / UI)](#10-passcode-api--ui) +11. ❌ [OAuth / token / client admin (API)](#11-oauth--token--client-admin-api-not-yet-covered-by-z) +12. ❌ [Authenticate (API)](#12-authenticate-api) +13. ❌ [Disable User Management and Rate Limiter](#12-authenticate-api) +14. ❌ [Zone Switching - Path Aware Zone Sessions](#13-zone-switching---path-aware-zone-sessions) +15. ❌ [HTML Content - Pages and Emails](#summary-high-level) +16. ❌ [Summary (high-level)](#summary-high-level) +17. ✅ [Pull Request](https://github.com/cloudfoundry/uaa/pull/3730) +18. ✅ [Feature Branch](https://github.com/fhanik/uaa/tree/feature/path-based-zones) +--- + +## 1. Reset / forgot password (UI) + +| Endpoint(s) | Controller / Class | Controller has /z/? | Security has /z/*/? | Tests that touch these endpoints | +|-------------|--------------------|---------------------|----------------------|-----------------------------------| +| `/forgot_password` | ResetPasswordController | No | Yes (LoginSecurityConfiguration login form chain) | ResetPasswordControllerMockMvcTests, ResetPasswordControllerTest, LoginMockMvcTests (forgot_password.do, links) | +| `/forgot_password.do` | ResetPasswordController | No | Yes | Same as above | +| `/email_sent` | ResetPasswordController | No | Yes (noSecurityEndpoints has `/z/*/email_sent`) | ResetPasswordControllerTest, AccountsControllerMockMvcTests (accounts/email_sent) | +| `/reset_password` (HEAD, GET with `code`) | ResetPasswordController | No | Yes (login form chain) | ResetPasswordControllerMockMvcTests, ResetPasswordControllerTest, ResetPasswordAuthenticationEntryPointTests (forward) | +| `/reset_password.do` | ResetPasswordController | No | Yes (login form + ResetPasswordAuthenticationFilter) | ResetPasswordControllerMockMvcTests, ResetPasswordControllerTest, ResetPasswordAuthenticationFilterTest | + +**Note:** Security already has `/z/*/forgot_password`, `/z/*/reset_password**`, etc. in LoginSecurityConfiguration. The **controller** still only declares the single path (e.g. `@GetMapping("/forgot_password")`). Adding `/z/{subdomain}/...` to the controller mappings is the remaining work. + +--- + +## 2. Change password (UI) + +| Endpoint(s) | Controller / Class | Controller has /z/? | Security has /z/*/? | Tests that touch these endpoints | +|-------------|--------------------|---------------------|----------------------|-----------------------------------| +| `/change_password` | ChangePasswordController | No | No (chain is `/password_*` only) | LoginMockMvcTests (get/change_password, post/change_password.do) | +| `/change_password.do` | ChangePasswordController | No | No | Same as above | + +**Note:** LoginSecurityConfiguration has a separate chain for `/password_*` with no `/z/*/` variant. Both controller and security need updates for zone path. + +--- + +## 3. Change email / verify email (UI) + +| Endpoint(s) | Controller / Class | Controller has /z/? | Security has /z/*/? | Tests that touch these endpoints | +|-------------|--------------------|---------------------|----------------------|-----------------------------------| +| `/change_email` | ChangeEmailController | No | No (chain is `/email_*` only) | LoginMockMvcTests, ChangeEmailControllerTest | +| `/change_email.do` | ChangeEmailController | No | No | Same as above | +| `/verify_email` | ChangeEmailController | No | No | ChangeEmailControllerTest | + +**Note:** LoginSecurityConfiguration has a separate chain for `/email_*` with no `/z/*/` variant. Both controller and security need updates. + +--- + +## 4. Force password change (UI) + +| Endpoint(s) | Controller / Class | Controller has /z/? | Security has /z/*/? | Tests that touch these endpoints | +|-------------|--------------------|---------------------|----------------------|-----------------------------------| +| `/force_password_change`, `/force_password_change/` | ForcePasswordChangeController | No | Yes (login form chain) | ForcePasswordChangeControllerTest, ForcePasswordChangeControllerMockMvcTest, UaaAuthenticationFailureHandlerTests (redirect + applyRequestPath) | +| `/force_password_change_completed` | No controller mapping (redirect target; PasswordChangeUiRequiredFilter uses path) | N/A | No (not in noSecurityEndpoints with /z/) | ForcePasswordChangeControllerMockMvcTest (get), PasswordChangeUiRequiredFilterTest (setPathInfo) | + +**Note:** Security already has `/z/*/force_password_change/**`. Controller has no `/z/` variant. `force_password_change_completed` is a redirect target and filter path; no explicit `@GetMapping` found—may be served as view or by default. If it gets a controller, it will need `/z/` support too. + +--- + +## 5. Logged out (UI) + +| Endpoint(s) | Controller / Class | Controller has /z/? | Security has /z/*/? | Tests that touch these endpoints | +|-------------|--------------------|---------------------|----------------------|-----------------------------------| +| `/logged_out` | LoggedOutEndpoint | No | No (noSecurityEndpoints has `/logged_out` but no `/z/*/logged_out`) | Indirect (logout flows redirect here) | + +**Note:** SpringServletXmlSecurityConfiguration noSecurityEndpoints includes `/logged_out` only; no `/z/*/logged_out`. Controller has single path. + +--- + +## 6. Home and error pages (UI) + +| Endpoint(s) | Controller / Class | Controller has /z/? | Security has /z/*/? | Tests that touch these endpoints | +|-------------|--------------------|---------------------|----------------------|-----------------------------------| +| `/`, `/home` | HomeController | No | No | LoginMockMvcTests (get("/")), HomeControllerViewTests (get("/home")), IdentityZoneEndpointsMockMvcTests (homeRedirect link) | +| `/error500` | HomeController | No | No (noSecurityEndpoints has `/error**`) | — | +| `/saml_error` | HomeController | No | No (noSecurityEndpoints has `/saml_error`) | — | +| `/oauth_error` | HomeController | No | No | — | +| `/rejected` | HomeController | No | No (noSecurityEndpoints has `/rejected`) | — | + +**Note:** noSecurityEndpoints does not add `/z/*/` for these. Controller has no `/z/` variants. + +--- + +## 7. Session (UI) + +| Endpoint(s) | Controller / Class | Controller has /z/? | Security has /z/*/? | Tests that touch these endpoints | +|-------------|--------------------|---------------------|----------------------|-----------------------------------| +| `/session` | SessionController | No | No (noSecurityEndpoints has `/session` but no `/z/*/session`) | SessionControllerIntegrationTests | +| `/session_management` | SessionController | No | No | SessionControllerIntegrationTests | + +--- + +## 8. Invitations (UI + API) + +| Endpoint(s) | Controller / Class | Controller has /z/? | Security has /z/*/? | Tests that touch these endpoints | +|-------------|--------------------|---------------------|----------------------|-----------------------------------| +| `/invitations/accept` | InvitationsController | No | No (LoginSecurityConfiguration has /invitations/accept without /z/) | InvitationsEndpointMockMvcTests, InvitationsControllerTest, InvitationsServiceMockMvcTests, AbstractLdapMockMvcTest | +| `/invitations/accept.do` | InvitationsController | No | No | Same as above | +| `/invitations/accept_enterprise.do` | InvitationsController | No | No | InvitationsControllerTest, AbstractLdapMockMvcTest | +| `/invitations/sent`, `/invitations/new`, `/invitations/new.do` | InvitationsController | No | No | InvitationsControllerTest (if any hit these) | +| `/invite_users`, `/invite_users/` | InvitationsEndpoint (API) | No | No (LoginSecurityConfiguration /invite_users/** has no /z/) | InvitationsEndpointMockMvcTests | + +--- + +## 9. Profile (UI) + +| Endpoint(s) | Controller / Class | Controller has /z/? | Security has /z/*/? | Tests that touch these endpoints | +|-------------|--------------------|---------------------|----------------------|-----------------------------------| +| `/profile`, `/profile/` | ProfileController | No | No (login form chain has no /z/*/ for profile) | ProfileControllerMockMvcTests, LoginMockMvcTests (redirect:profile), InvitationsServiceMockMvcTests | + +--- + +## 10. Passcode (API / UI) + +| Endpoint(s) | Controller / Class | Controller has /z/? | Security has /z/*/? | Tests that touch these endpoints | +|-------------|--------------------|---------------------|----------------------|-----------------------------------| +| `/passcode` | PasscodeEndpoint | No | No (OauthEndpointSecurityConfiguration passcode matcher has no /z/) | PasscodeMockMvcTests, TokenMvcMockTests (get("/passcode")), AbstractLdapMockMvcTest, LoginInfoEndpointTests (prompt text) | + +--- + +## 11. OAuth / token / client admin (API – not yet covered by /z/) + +| Endpoint(s) | Controller / Class | Controller has /z/? | Security has /z/*/? | Tests that touch these endpoints | +|-------------|--------------------|---------------------|----------------------|-----------------------------------| +| `/oauth/confirm_access` | AccessController | No | No | — | +| `/oauth/error` | AccessController | No | No | — | +| `/oauth/token/revoke/user/{userId}` etc. | TokenRevocationEndpoint | No | No (OauthEndpointSecurityConfiguration /oauth/token/revoke/** has no /z/) | — | +| `/check_token` | CheckTokenEndpoint | No | No | — | +| `/introspect` | IntrospectEndpoint | No | No | — | +| `/oauth/clients/**` | ClientAdminEndpoints, ClientMetadataAdminEndpoints | No | No (ClientAdminSecurityConfiguration has no /z/) | — | +| `/identity-providers/**` | IdentityProviderEndpoints | No | No (IdentityZoneSecurityConfiguration has no /z/) | — | +| `/identity-zones/**` | — | No | No | IdentityZoneEndpointsMockMvcTests (already parameterized for zone path in tests) | +| `/Codes/**` | CodeStoreEndpoints | No | No | — | +| `/email_verifications`, `/email_changes` | ChangeEmailEndpoints (SCIM) | No | No | — | +| `/RateLimitingStatus/**` | RateLimitStatusController | No | No | — | +| `/saml/metadata`, `/saml/metadata/` | SamlMetadataEndpoint | No | No (secFilterOpenSamlEndPoints has no /z/) | — | + +--- + +## 12. Authenticate (API) + +| Endpoint(s) | Controller / Class | Controller has /z/? | Security has /z/*/? | Tests that touch these endpoints | +|-------------|--------------------|---------------------|----------------------|-----------------------------------| +| `/authenticate`, `/authenticate/` | RemoteAuthenticationEndpoint | No | No (LoginSecurityConfiguration authenticate chain has no /z/) | LoginMockMvcTests (post("/authenticate")) | + +--- + +## 13. Disable User Management and Rate Limiter + +Should filter all the same URLs when the zone is path based. + +--- + +## 14. Zone Switching - Path Aware Zone Sessions + +Once steps 1-12 are completed, the system will work for a single session. +Switching zones by changing the /z/ zone path, will cause the SessionResetFilter +to kick in and redirect the user to the default zone login page. + +There is a decision to be made at this point, do we support multiple zone sessions when using paths? +If so, there will be a session implementation, very much like the one IdentityZoneResolving/Switching filters +that allows the same server side session hold attributes for multiple zones at the same time + +--- + +## 15. HTML Content - Pages and Emails + +See [logged_out.html](server/src/main/resources/templates/web/logged_out.html) for how to handle HTML +Self Explanatory + +## Summary (high-level) + +- **UI endpoints most likely to need `/z/` next:** reset_password, forgot_password, change_password, change_email, verify_email, force_password_change (and _completed), logged_out, home, session, invitations (accept flow), profile, passcode. Security already has `/z/*/` for several of these (forgot_password, reset_password, force_password_change, create_account, login, etc.); the **controller** mappings are what’s missing. +- **Security chains that don’t yet have `/z/*/`:** `/password_*`, `/email_*`, noSecurityEndpoints for `/session`, `/session_management`, `/logged_out`, `/`, `/home`, `/error**`, `/saml_error`, `/oauth_error`, `/rejected`; invitations and invite_users; profile; passcode; OAuth confirm_access/error; token revoke; check_token; introspect; client admin; identity-providers; identity-zones; Codes; RateLimitingStatus; SAML metadata; authenticate. +- **Tests:** The “Tests that touch these endpoints” column lists the test classes/methods that perform requests to the given path. When you add `/z/{subdomain}/` support for an endpoint, parameterize those tests with `ZoneResolutionMode` (or equivalent) or add dedicated zone-path tests so both default and `/z/` paths are covered. + diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java index 9d18c8af3c1..e4a4c52a4a0 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlFiltersConfiguration.java @@ -189,7 +189,7 @@ FilterRegistrationBean sessionResetFilter( SessionResetFilter filter = new SessionResetFilter( new DefaultRedirectStrategy(), identityZoneManager, - "/login", + "/login", //TODO not zone path aware. userDatabase ); FilterRegistrationBean bean = new FilterRegistrationBean<>(filter); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlSecurityConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlSecurityConfiguration.java index a0362b2147c..91fa4c84332 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlSecurityConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/SpringServletXmlSecurityConfiguration.java @@ -47,27 +47,28 @@ @EnableWebSecurity public class SpringServletXmlSecurityConfiguration { + // Ant-style patterns: use /z/*/path (not /z/{subdomain}/path) so AntPathRequestMatcher matches zone paths private final String[] noSecurityEndpoints = { "/error**", "/error/**", "/rejected", "/resources/**", "/square-logo.png", - "/info", + "/info", "/z/*/info", "/password/**", "/saml/web/**", "/vendor/**", - "/email_sent", - "/accounts/email_sent", - "/invalid_request", + "/email_sent", "/z/*/email_sent", + "/accounts/email_sent", "/z/*/accounts/email_sent", + "/invalid_request", "/z/*/invalid_request", "/saml_error", "/favicon.ico", "/oauth_error", - "/session", - "/session_management", - "/oauth/token/.well-known/openid-configuration", - "/.well-known/openid-configuration", - "/logged_out" + "/session", "/z/*/session", + "/session_management", "/z/*/session_management", + "/oauth/token/.well-known/openid-configuration", "/z/*/oauth/token/.well-known/openid-configuration", + "/.well-known/openid-configuration", "/z/*/.well-known/openid-configuration", + "/logged_out", "/z/*/logged_out" }; private final String[] secFilterOpenSamlEndPoints = { @@ -189,6 +190,7 @@ SecurityFilterChainPostProcessor securityFilterChainPostProcessor( additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.after(Saml2LogoutRequestFilter.class), saml2LogoutResponseFilter.getFilter()); additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.before(AnonymousAuthenticationFilter.class), userManagementSecurityFilter.getFilter()); additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.after(DisableUserManagementSecurityFilter.class), userManagementFilter.getFilter()); + //TODO - should this be directly after the filter that sets the SecurityContext? additionalFilters.put(SecurityFilterChainPostProcessor.FilterPosition.position(102), sessionResetFilter.getFilter()); bean.setAdditionalFilters(additionalFilters); 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 6957f26234c..b11b999d9da 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 @@ -39,7 +39,7 @@ public AccountsController( this.identityProviderProvisioning = identityProviderProvisioning; } - @GetMapping("/create_account") + @GetMapping({"/create_account", "/z/{subdomain}/create_account"}) public String activationEmail(Model model, @RequestParam(value = "client_id", required = false) String clientId, @RequestParam(value = "redirect_uri", required = false) String redirectUri, @@ -54,7 +54,7 @@ public String activationEmail(Model model, return "accounts/new_activation_email"; } - @PostMapping("/create_account.do") + @PostMapping({"/create_account.do", "/z/{subdomain}/create_account.do"}) public String sendActivationEmail(Model model, HttpServletResponse response, @RequestParam(value = "client_id", required = false) String clientId, @RequestParam(value = "redirect_uri", required = false) String redirectUri, @@ -94,18 +94,18 @@ public String sendActivationEmail(Model model, HttpServletResponse response, return "redirect:accounts/email_sent"; } - @GetMapping("/accounts/email_sent") + @GetMapping({"/accounts/email_sent", "/z/{subdomain}/accounts/email_sent"}) public String emailSent() { return "accounts/email_sent"; } - @RequestMapping(value = "/verify_user", method = RequestMethod.HEAD) + @RequestMapping(value = {"/verify_user", "/z/{subdomain}/verify_user"}, method = RequestMethod.HEAD) public String verifyUser() { // Some mail providers initially send a HEAD request to check the validity of the link before redirecting users. return "redirect:/login"; } - @GetMapping("/verify_user") + @GetMapping({"/verify_user", "/z/{subdomain}/verify_user"}) public String verifyUser(Model model, @RequestParam String code, HttpServletResponse response, HttpSession session) { 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 6be1cf79163..5dc61ec627f 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 @@ -40,7 +40,7 @@ public ChangeEmailController( this.uaaUserDatabase = uaaUserDatabase; } - @GetMapping("/change_email") + @GetMapping({"/change_email", "/z/{subdomain}/change_email"}) public String changeEmailPage(Model model, @RequestParam(value = "client_id", required = false) String clientId, @RequestParam(value = "redirect_uri", required = false) String redirectUri) { SecurityContext securityContext = SecurityContextHolder.getContext(); @@ -50,7 +50,7 @@ public String changeEmailPage(Model model, @RequestParam(value = "client_id", re return "change_email"; } - @PostMapping("/change_email.do") + @PostMapping({"/change_email.do", "/z/{subdomain}/change_email.do"}) public String changeEmail(Model model, @Valid @ModelAttribute ValidEmail newEmail, BindingResult result, @RequestParam(required = false, value = "client_id") String clientId, @RequestParam(required = false, value = "redirect_uri") String redirectUri, @@ -86,7 +86,7 @@ public String changeEmail(Model model, @Valid @ModelAttribute ValidEmail newEmai return "redirect:email_sent?code=email_change"; } - @GetMapping("/verify_email") + @GetMapping({"/verify_email", "/z/{subdomain}/verify_email"}) public String verifyEmail(Model model, @RequestParam String code, RedirectAttributes redirectAttributes, HttpServletResponse httpServletResponse, HttpServletRequest request) { Map response; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/ChangePasswordController.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/ChangePasswordController.java index 87427ad2b12..cb88e35a0f9 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/ChangePasswordController.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/ChangePasswordController.java @@ -25,12 +25,12 @@ public ChangePasswordController(final ChangePasswordService changePasswordServic this.changePasswordService = changePasswordService; } - @GetMapping("/change_password") + @GetMapping({"/change_password", "/z/{subdomain}/change_password"}) public String changePasswordPage() { return "change_password"; } - @PostMapping("/change_password.do") + @PostMapping({"/change_password.do", "/z/{subdomain}/change_password.do"}) public String changePassword( Model model, @RequestParam("current_password") String currentPassword, diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java index 0e83b6045b6..2aed8d1865f 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/OpenIdConnectEndpoints.java @@ -28,7 +28,9 @@ public OpenIdConnectEndpoints( @GetMapping(value = { "/.well-known/openid-configuration", - "/oauth/token/.well-known/openid-configuration" + "/oauth/token/.well-known/openid-configuration", + "/z/{subdomain}/.well-known/openid-configuration", + "/z/{subdomain}/oauth/token/.well-known/openid-configuration" }) public ResponseEntity getOpenIdConfiguration(HttpServletRequest request) throws URISyntaxException { OpenIdConfiguration conf = new OpenIdConfiguration(getServerContextPath(request), getTokenEndpoint()); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationEntryPoint.java b/server/src/main/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationEntryPoint.java index 01439ce0833..8a6e8c04648 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationEntryPoint.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/account/ResetPasswordAuthenticationEntryPoint.java @@ -29,8 +29,24 @@ import java.util.HashMap; import java.util.Map; +import static org.springframework.util.StringUtils.hasText; public class ResetPasswordAuthenticationEntryPoint implements AuthenticationEntryPoint { + + /** + * When the request was to a zone path (e.g. /z/{subdomain}/reset_password.do), forward to the same zone path + * so the user stays in zone context. Otherwise return "" for default path. + */ + private String getForwardPathPrefix(HttpServletRequest request) { + String path = request.getRequestURI(); + if (hasText(path) && path.startsWith("/z/")) { + int secondSlash = path.indexOf('/', 3); + if (secondSlash > 0) { + return path.substring(0, secondSlash + 1); + } + } + return "/"; + } @Override public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException { Throwable cause = authException.getCause(); @@ -67,18 +83,20 @@ public String[] getParameterValues(String name) { } }; + String forwardPathPrefix = getForwardPathPrefix(request); + if (cause instanceof PasswordConfirmationException passwordConfirmationException) { request.setAttribute("message_code", passwordConfirmationException.getMessageCode()); - request.getRequestDispatcher("/reset_password").forward(wrapper, response); + request.getRequestDispatcher(forwardPathPrefix + "reset_password").forward(wrapper, response); return; } else { if (cause instanceof InvalidPasswordException exception) { request.setAttribute("message", exception.getMessagesAsOneString()); - request.getRequestDispatcher("/reset_password").forward(wrapper, response); + request.getRequestDispatcher(forwardPathPrefix + "reset_password").forward(wrapper, response); } else { request.setAttribute("message_code", "bad_code"); - request.getRequestDispatcher("/forgot_password").forward(wrapper, response); + request.getRequestDispatcher(forwardPathPrefix + "forgot_password").forward(wrapper, response); } } } 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 33ea75f3db9..a1c1e190356 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 @@ -20,13 +20,12 @@ import org.cloudfoundry.identity.uaa.error.UaaException; import org.cloudfoundry.identity.uaa.scim.exception.InvalidPasswordException; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; -import org.springframework.stereotype.Component; import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; @@ -42,7 +41,10 @@ public class ResetPasswordAuthenticationFilter extends OncePerRequestFilter { private final AuthenticationEntryPoint entryPoint; private final ExpiringCodeStore expiringCodeStore; public static final String RESET_PASSWORD_URL = "/reset_password.do"; - private static final RequestMatcher matcher = new AntPathRequestMatcher(RESET_PASSWORD_URL, "POST"); + private static final RequestMatcher matcher = new OrRequestMatcher( + new AntPathRequestMatcher(RESET_PASSWORD_URL, "POST"), + new AntPathRequestMatcher("/z/*" + RESET_PASSWORD_URL, "POST") + ); public ResetPasswordAuthenticationFilter( ResetPasswordService service, 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 6325801ec5c..469dae96db2 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 @@ -71,7 +71,7 @@ public ResetPasswordController( this.externalLoginUrl = externalLoginUrl; } - @GetMapping("/forgot_password") + @GetMapping({"/forgot_password", "/z/{subdomain}/forgot_password"}) public String forgotPasswordPage(Model model, @RequestParam(required = false, value = "client_id") String clientId, @RequestParam(required = false, value = "redirect_uri") String redirectUri, @@ -84,7 +84,7 @@ public String forgotPasswordPage(Model model, return "forgot_password"; } - @PostMapping("/forgot_password.do") + @PostMapping({"/forgot_password.do", "/z/{subdomain}/forgot_password.do"}) public String forgotPassword(Model model, @RequestParam("username") String username, @RequestParam(value = "client_id", defaultValue = "") String clientId, @RequestParam(value = "redirect_uri", defaultValue = "") String redirectUri, HttpServletResponse response) { if (!identityZoneManager.getCurrentIdentityZone().getConfig().getLinks().getSelfService().isSelfServiceLinksEnabled()) { @@ -160,19 +160,19 @@ private String getServiceName() { } } - @GetMapping("/email_sent") + @GetMapping({"/email_sent", "/z/{subdomain}/email_sent"}) public String emailSentPage(@ModelAttribute("code") String code, HttpServletResponse response) { response.addHeader("Content-Security-Policy", "frame-ancestors 'none'"); return "email_sent"; } - @RequestMapping(value = "/reset_password", method = RequestMethod.HEAD) + @RequestMapping(value = {"/reset_password", "/z/{subdomain}/reset_password"}, method = RequestMethod.HEAD) public void resetPassword() { // Some mail providers initially send a HEAD request to check the validity of the link before redirecting users. } - @GetMapping(value = "/reset_password", params = {"code"}) + @GetMapping(value = {"/reset_password", "/z/{subdomain}/reset_password"}, params = {"code"}) public String resetPasswordPage(Model model, HttpServletResponse response, @RequestParam("code") String code) { @@ -217,7 +217,7 @@ private ExpiringCode checkIfUserExists(ExpiringCode code) { return code; } - @PostMapping("/reset_password.do") + @PostMapping({"/reset_password.do", "/z/{subdomain}/reset_password.do"}) public void resetPassword(Model model, @RequestParam("code") String code, @RequestParam("email") String email, diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/PasswordChangeUiRequiredFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/PasswordChangeUiRequiredFilter.java index 14d9c720b4e..2491a7a06bf 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/PasswordChangeUiRequiredFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/authentication/PasswordChangeUiRequiredFilter.java @@ -1,12 +1,15 @@ package org.cloudfoundry.identity.uaa.authentication; import org.cloudfoundry.identity.uaa.util.SessionUtils; +import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import org.cloudfoundry.identity.uaa.web.UaaSavedRequestCache; import org.springframework.lang.NonNull; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.savedrequest.SavedRequest; import org.springframework.security.web.util.matcher.AntPathRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; +import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; @@ -21,8 +24,8 @@ public class PasswordChangeUiRequiredFilter extends OncePerRequestFilter { private static final String MATCH_PATH = "/force_password_change"; private static final String COMPLETED_PATH = "/force_password_change_completed"; - private final AntPathRequestMatcher matchPath; - private final AntPathRequestMatcher completedPath; + private final RequestMatcher matchPath; + private final RequestMatcher completedPath; private final UaaSavedRequestCache cache; public PasswordChangeUiRequiredFilter() { @@ -32,8 +35,16 @@ public PasswordChangeUiRequiredFilter() { public PasswordChangeUiRequiredFilter(UaaSavedRequestCache cache) { this.cache = cache; - this.matchPath = new AntPathRequestMatcher(MATCH_PATH); - this.completedPath = new AntPathRequestMatcher(COMPLETED_PATH); + this.matchPath = new OrRequestMatcher( + new AntPathRequestMatcher(MATCH_PATH), + new AntPathRequestMatcher(MATCH_PATH + "/"), + new AntPathRequestMatcher("/z/*"+MATCH_PATH), + new AntPathRequestMatcher("/z/*" + MATCH_PATH+ "/") + ); + this.completedPath = new OrRequestMatcher( + new AntPathRequestMatcher(COMPLETED_PATH), + new AntPathRequestMatcher("/z/*"+COMPLETED_PATH) + ); } @Override @@ -47,16 +58,16 @@ protected void doFilterInternal( if (savedRequest != null) { sendRedirect(savedRequest.getRedirectUrl(), request, response); } else { - sendRedirect("/", request, response); + sendRedirect(redirectPathWithZonePrefix(request, "/"), request, response); } } else if (needsPasswordReset(request) && !matchPath.matches(request)) { logger.debug("Password change is required for user."); if (cache.getRequest(request, response) == null) { cache.saveRequest(request, response); } - sendRedirect(MATCH_PATH, request, response); + sendRedirect(redirectPathWithZonePrefix(request, MATCH_PATH), request, response); } else if (matchPath.matches(request) && isAuthenticated() && !needsPasswordReset(request)) { - sendRedirect("/", request, response); + sendRedirect(redirectPathWithZonePrefix(request, "/"), request, response); } else { //pass through filterChain.doFilter(request, response); @@ -76,6 +87,11 @@ private boolean isCompleted(HttpServletRequest request) { return false; } + private static String redirectPathWithZonePrefix(HttpServletRequest request, String defaultPath) { + String prefix = UaaUrlUtils.getZonePathPrefix(request); + return prefix.isEmpty() ? defaultPath : prefix + defaultPath; + } + protected void sendRedirect(String redirectUrl, HttpServletRequest request, HttpServletResponse response) throws IOException { String location = (redirectUrl.startsWith("/") ? request.getContextPath() : "") + redirectUrl; logger.debug("Redirecting request to " + location); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/home/HomeController.java b/server/src/main/java/org/cloudfoundry/identity/uaa/home/HomeController.java index 4fda5c69d88..9b2d7eb57cd 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/home/HomeController.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/home/HomeController.java @@ -7,6 +7,7 @@ import org.cloudfoundry.identity.uaa.client.JdbcClientMetadataProvisioning; import org.cloudfoundry.identity.uaa.util.SessionUtils; import org.cloudfoundry.identity.uaa.util.UaaStringUtils; +import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; @@ -66,8 +67,8 @@ private void populateBuildAndLinkInfo(Model model) { model.addAllAttributes(new HashMap<>()); } - @RequestMapping(value = {"/", "/home"}) - public String home(Model model, Principal principal) { + @RequestMapping(value = {"/", "/home", "/z/{subdomain}/", "/z/{subdomain}/home"}) + public String home(Model model, Principal principal, HttpServletRequest request) { IdentityZone identityZone = getIdentityZone(); IdentityZoneConfiguration config = identityZone.getConfig(); String homePage = @@ -79,6 +80,7 @@ public String home(Model model, Principal principal) { return "redirect:" + homePage; } + model.addAttribute("pathPrefix", UaaUrlUtils.getZonePathPrefix(request)); model.addAttribute("principal", principal); List tiles = new ArrayList<>(); @@ -117,8 +119,9 @@ private boolean shouldShowClient(ClientMetadata clientMetadata) { return clientMetadata.isShowOnHomePage() && clientMetadata.getAppLaunchUrl() != null; } - @RequestMapping("/error500") + @RequestMapping(value = {"/error500", "/z/{subdomain}/error500"}) public String error500(Model model, HttpServletRequest request, HttpServletResponse response) { + model.addAttribute("pathPrefix", UaaUrlUtils.getZonePathPrefix(request)); Throwable genericException = (Throwable) request.getAttribute(RequestDispatcher.ERROR_EXCEPTION); logger.error("Internal error", genericException); @@ -135,7 +138,7 @@ public String error500(Model model, HttpServletRequest request, HttpServletRespo } @SuppressWarnings("java:S3752") - @RequestMapping(path = "/error429", method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.DELETE, RequestMethod.PUT, RequestMethod.PATCH}) + @RequestMapping(path = {"/error429", "/z/{subdomain}/error429"}, method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.DELETE, RequestMethod.PUT, RequestMethod.PATCH}) @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) @ResponseBody public JsonError error429Json(HttpServletRequest request) { @@ -147,20 +150,23 @@ public JsonError error429Json(HttpServletRequest request) { } @SuppressWarnings("java:S3752") - @RequestMapping(path = "/error429", produces = MediaType.TEXT_HTML_VALUE, method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.DELETE, RequestMethod.PUT, RequestMethod.PATCH}) + @RequestMapping(path = {"/error429", "/z/{subdomain}/error429"}, produces = MediaType.TEXT_HTML_VALUE, method = {RequestMethod.GET, RequestMethod.POST, RequestMethod.DELETE, RequestMethod.PUT, RequestMethod.PATCH}) public String error429(Model model, HttpServletRequest request) { + model.addAttribute("pathPrefix", UaaUrlUtils.getZonePathPrefix(request)); model.addAttribute(RATE_LIMIT_ERROR_ATTRIBUTE, request.getAttribute(RATE_LIMIT_ERROR_ATTRIBUTE)); return "error429"; } - @RequestMapping({"/error", "/error**"}) - public String errorGeneric(Model model) { + @RequestMapping({"/error", "/error**", "/z/{subdomain}/error", "/z/{subdomain}/error**"}) + public String errorGeneric(Model model, HttpServletRequest request) { + model.addAttribute("pathPrefix", UaaUrlUtils.getZonePathPrefix(request)); populateBuildAndLinkInfo(model); return ERROR; } - @RequestMapping("/saml_error") + @RequestMapping({"/saml_error", "/z/{subdomain}/saml_error"}) public String error401(Model model, HttpServletRequest request) { + model.addAttribute("pathPrefix", UaaUrlUtils.getZonePathPrefix(request)); AuthenticationException exception = SessionUtils.getAuthenticationException(request.getSession()); if (nonNull(exception)) { model.addAttribute("saml_error", exception.getMessage()); @@ -168,8 +174,9 @@ public String error401(Model model, HttpServletRequest request) { return EXTERNAL_AUTH_ERROR; } - @RequestMapping("/oauth_error") + @RequestMapping({"/oauth_error", "/z/{subdomain}/oauth_error"}) public String error_oauth(Model model, HttpServletRequest request) { + model.addAttribute("pathPrefix", UaaUrlUtils.getZonePathPrefix(request)); String oauthError = "oauth_error"; String exception = (String) request.getSession().getAttribute(oauthError); @@ -180,13 +187,16 @@ public String error_oauth(Model model, HttpServletRequest request) { return EXTERNAL_AUTH_ERROR; } - @RequestMapping("/rejected") + @RequestMapping({"/rejected", "/z/{subdomain}/rejected"}) @ResponseStatus(HttpStatus.BAD_REQUEST) - public String handleRequestRejected(Model model, - @RequestAttribute(RequestDispatcher.ERROR_EXCEPTION) RequestRejectedException ex, - @RequestAttribute(RequestDispatcher.ERROR_REQUEST_URI) String uri) { - - logger.error("Request with encoded URI [{}] rejected. {}", URLEncoder.encode(uri, StandardCharsets.UTF_8), ex.getMessage()); + public String handleRequestRejected(Model model, HttpServletRequest request, + @RequestAttribute(value = RequestDispatcher.ERROR_EXCEPTION, required = false) RequestRejectedException ex, + @RequestAttribute(value = RequestDispatcher.ERROR_REQUEST_URI, required = false) String uri) { + + model.addAttribute("pathPrefix", UaaUrlUtils.getZonePathPrefix(request)); + String uriForLog = uri != null ? uri : request.getRequestURI(); + RequestRejectedException exForLog = ex != null ? ex : new RequestRejectedException("Request rejected"); + logger.error("Request with encoded URI [{}] rejected. {}", URLEncoder.encode(uriForLog, StandardCharsets.UTF_8), exForLog.getMessage()); model.addAttribute("oauth_error", "The request was rejected because it contained a potentially malicious character."); return EXTERNAL_AUTH_ERROR; 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 153d45f65cb..1f27f6ffec8 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 @@ -29,6 +29,7 @@ import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.util.ObjectUtils; import org.cloudfoundry.identity.uaa.util.UaaHttpRequestUtils; +import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import org.cloudfoundry.identity.uaa.zone.BrandingInformation; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManager; import org.springframework.beans.factory.annotation.Qualifier; @@ -74,7 +75,7 @@ @Slf4j @Controller -@RequestMapping("/invitations") +@RequestMapping(value = {"/invitations", "/z/{subdomain}/invitations"}) public class InvitationsController { private static final String EMAIL = "email"; @@ -119,6 +120,7 @@ public String acceptInvitePage(@RequestParam String code, Model model, HttpServl ExpiringCode expiringCode = expiringCodeStore.peekCode(code, identityZoneManager.getCurrentIdentityZoneId()); if ((null == expiringCode) || (null != expiringCode.getIntent() && !INVITATION.name().equals(expiringCode.getIntent()))) { + model.addAttribute("pathPrefix", UaaUrlUtils.getZonePathPrefix(request)); return handleUnprocessableEntity(model, response, "error_message_code", "code_expired", "invitations/accept_invite"); } @@ -161,6 +163,7 @@ public String acceptInvitePage(@RequestParam String code, Model model, HttpServl AnonymousAuthenticationToken token = new AnonymousAuthenticationToken("scim.invite", uaaPrincipal, Collections.singletonList(UaaAuthority.UAA_INVITED)); SecurityContextHolder.getContext().setAuthentication(token); + model.addAttribute("pathPrefix", UaaUrlUtils.getZonePathPrefix(request)); model.addAttribute("provider", provider.getType()); model.addAttribute("code", code); model.addAttribute(EMAIL, codeData.get(EMAIL)); @@ -170,6 +173,7 @@ public String acceptInvitePage(@RequestParam String code, Model model, HttpServl return "invitations/accept_invite"; } catch (EmptyResultDataAccessException noProviderFound) { log.debug("No available invitation providers for email:%s, id:%s".formatted(codeData.get(EMAIL), codeData.get("user_id"))); + model.addAttribute("pathPrefix", UaaUrlUtils.getZonePathPrefix(request)); return handleUnprocessableEntity(model, response, "error_message_code", "no_suitable_idp", "invitations/accept_invite"); } } @@ -244,8 +248,11 @@ public String acceptInvitation(@RequestParam("password") String password, @RequestParam("code") String code, @RequestParam(value = "does_user_consent", required = false) boolean doesUserConsent, Model model, + HttpServletRequest request, HttpServletResponse response) { + String pathPrefix = UaaUrlUtils.getZonePathPrefix(request); + PasswordConfirmationValidation validation = new PasswordConfirmationValidation(password, passwordConfirmation); UaaPrincipal principal = (UaaPrincipal) SecurityContextHolder.getContext().getAuthentication().getPrincipal(); @@ -255,6 +262,7 @@ public String acceptInvitation(@RequestParam("password") String password, if (expiringCode == null || expiringCode.getData() == null) { log.debug("Failing invitation. Code not found."); SecurityContextHolder.clearContext(); + model.addAttribute("pathPrefix", pathPrefix); return handleUnprocessableEntity(model, response, "error_message_code", "code_expired", "invitations/accept_invite"); } Map data = JsonUtils.readValue(expiringCode.getData(), new TypeReference<>() { @@ -262,36 +270,38 @@ public String acceptInvitation(@RequestParam("password") String password, if (principal == null || data.get("user_id") == null || !data.get("user_id").equals(principal.getId())) { log.debug("Failing invitation. Code and user ID mismatch."); SecurityContextHolder.clearContext(); + model.addAttribute("pathPrefix", pathPrefix); return handleUnprocessableEntity(model, response, "error_message_code", "code_expired", "invitations/accept_invite"); } final String newCode = expiringCodeStore.generateCode(expiringCode.getData(), new Timestamp(System.currentTimeMillis() + (10 * 60 * 1000)), expiringCode.getIntent(), identityZoneManager.getCurrentIdentityZoneId()).getCode(); BrandingInformation zoneBranding = identityZoneManager.getCurrentIdentityZone().getConfig().getBranding(); if (zoneBranding != null && zoneBranding.getConsent() != null && !doesUserConsent) { - return processErrorReload(newCode, model, response, "error_message_code", "missing_consent"); + return processErrorReload(newCode, model, response, "error_message_code", "missing_consent", pathPrefix); } if (!validation.valid()) { - return processErrorReload(newCode, model, response, "error_message_code", validation.getMessageCode()); + return processErrorReload(newCode, model, response, "error_message_code", validation.getMessageCode(), pathPrefix); } try { passwordValidator.validate(password); } catch (InvalidPasswordException e) { - return processErrorReload(newCode, model, response, "error_message", e.getMessagesAsOneString()); + return processErrorReload(newCode, model, response, "error_message", e.getMessagesAsOneString(), pathPrefix); } AcceptedInvitation invitation; try { invitation = invitationsService.acceptInvitation(newCode, password); } catch (HttpClientErrorException e) { + model.addAttribute("pathPrefix", pathPrefix); return handleUnprocessableEntity(model, response, "error_message_code", "code_expired", "invitations/accept_invite"); } - String res = "redirect:/login?success=invite_accepted"; + String res = pathPrefix + "/login?success=invite_accepted"; if (!invitation.getRedirectUri().equals("/home")) { res += "&" + FORM_REDIRECT_PARAMETER + "=" + invitation.getRedirectUri(); } - return res; + return "redirect:" + res; } - private String processErrorReload(String code, Model model, HttpServletResponse response, String errorCode, String error) { + private String processErrorReload(String code, Model model, HttpServletResponse response, String errorCode, String error, String pathPrefix) { ExpiringCode expiringCode = expiringCodeStore.retrieveCode(code, identityZoneManager.getCurrentIdentityZoneId()); Map codeData = JsonUtils.readValue(expiringCode.getData(), new TypeReference<>() { }); @@ -300,9 +310,11 @@ private String processErrorReload(String code, Model model, HttpServletResponse model.addAttribute(errorCode, error); model.addAttribute("code", newCode); - return "redirect:accept"; + String redirectTarget = (pathPrefix != null && !pathPrefix.isEmpty()) ? pathPrefix + "/invitations/accept" : "accept"; + return "redirect:" + redirectTarget; } catch (EmptyResultDataAccessException noProviderFound) { log.debug("No available invitation providers for email:%s, id:%s".formatted(codeData.get(EMAIL), codeData.get("user_id"))); + model.addAttribute("pathPrefix", pathPrefix); return handleUnprocessableEntity(model, response, "error_message_code", "no_suitable_idp", "invitations/accept_invite"); } } @@ -312,7 +324,8 @@ public String acceptLdapInvitation(@RequestParam("enterprise_username") String u @RequestParam("enterprise_password") String password, @RequestParam("enterprise_email") String email, @RequestParam String code, - Model model, HttpServletResponse response) { + Model model, HttpServletRequest request, HttpServletResponse response) { + String pathPrefix = UaaUrlUtils.getZonePathPrefix(request); ExpiringCode expiringCode = expiringCodeStore.retrieveCode(code, identityZoneManager.getCurrentIdentityZoneId()); if (expiringCode == null) { @@ -330,9 +343,11 @@ public String acceptLdapInvitation(@RequestParam("enterprise_username") String u authenticationManager = zoneAwareAuthenticationManager.getLdapAuthenticationManager(identityZoneManager.getCurrentIdentityZone(), ldapProvider).getLdapManagerActual(); } catch (EmptyResultDataAccessException e) { //ldap provider was not available + model.addAttribute("pathPrefix", pathPrefix); return handleUnprocessableEntity(model, response, "error_message_code", "no_suitable_idp", "invitations/accept_invite"); } catch (Exception x) { log.error("Unable to retrieve LDAP config.", x); + model.addAttribute("pathPrefix", pathPrefix); return handleUnprocessableEntity(model, response, "error_message_code", "no_suitable_idp", "invitations/accept_invite"); } Authentication authentication; @@ -345,6 +360,7 @@ public String acceptLdapInvitation(@RequestParam("enterprise_username") String u model.addAttribute(EMAIL, data.get(EMAIL)); model.addAttribute("provider", OriginKeys.LDAP); model.addAttribute("code", expiringCodeStore.generateCode(expiringCode.getData(), new Timestamp(System.currentTimeMillis() + (10 * 60 * 1000)), null, identityZoneManager.getCurrentIdentityZoneId()).getCode()); + model.addAttribute("pathPrefix", pathPrefix); return handleUnprocessableEntity(model, response, "error_message", "invite.email_mismatch", "invitations/accept_invite"); } @@ -354,16 +370,19 @@ public String acceptLdapInvitation(@RequestParam("enterprise_username") String u userProvisioning.update(user.getId(), user, identityZoneManager.getCurrentIdentityZoneId()); zoneAwareAuthenticationManager.getLdapAuthenticationManager(identityZoneManager.getCurrentIdentityZone(), ldapProvider).authenticate(token); AcceptedInvitation accept = invitationsService.acceptInvitation(newCode, ""); - return "redirect:" + "/login?success=invite_accepted&form_redirect_uri=" + URLEncoder.encode(accept.getRedirectUri(), StandardCharsets.UTF_8); + return "redirect:" + pathPrefix + "/login?success=invite_accepted&form_redirect_uri=" + URLEncoder.encode(accept.getRedirectUri(), StandardCharsets.UTF_8); } else { + model.addAttribute("pathPrefix", pathPrefix); return handleUnprocessableEntity(model, response, "error_message", "not authenticated", "invitations/accept_invite"); } } catch (AuthenticationException x) { + model.addAttribute("pathPrefix", pathPrefix); return handleUnprocessableEntity(model, response, "error_message", x.getMessage(), "invitations/accept_invite"); } catch (Exception x) { log.error("Unable to authenticate against LDAP", x); model.addAttribute("ldap", true); model.addAttribute(EMAIL, email); + model.addAttribute("pathPrefix", pathPrefix); return handleUnprocessableEntity(model, response, "error_message", "bad_credentials", "invitations/accept_invite"); } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpoint.java b/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpoint.java index 941058c96b8..8d92755d13e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpoint.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpoint.java @@ -67,7 +67,7 @@ public InvitationsEndpoint(final ScimUserProvisioning scimUserProvisioning, this.expiringCodeStore = expiringCodeStore; } - @PostMapping(value = {"/invite_users", "/invite_users/"}, consumes = "application/json") + @PostMapping(value = {"/invite_users", "/invite_users/", "/z/{subdomain}/invite_users", "/z/{subdomain}/invite_users/"}, consumes = "application/json") public ResponseEntity inviteUsers(@RequestBody InvitationsRequest invitations, @RequestParam(value = "client_id", required = false) String clientId, @RequestParam(value = "redirect_uri") String redirectUri) { @@ -85,6 +85,7 @@ public ResponseEntity inviteUsers(@RequestBody InvitationsR List activeProviders = identityProviderProvisioning.retrieveActive(IdentityZoneHolder.get().getId()); HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.getRequestAttributes()).getRequest(); + String pathPrefix = UaaUrlUtils.getZonePathPrefix(request); String subdomainHeader = request.getHeader(SUBDOMAIN_HEADER); String zoneIdHeader = request.getHeader(HEADER); @@ -94,13 +95,16 @@ public ResponseEntity inviteUsers(@RequestBody InvitationsR client = multitenantClientServices.loadClientByClientId(clientId, IdentityZoneHolder.get().getId()); } + String acceptPath = (pathPrefix != null && !pathPrefix.isEmpty()) ? pathPrefix + "/invitations/accept" : "/invitations/accept"; + boolean useSubdomainHost = !IdentityZoneHolder.isUaa() && (pathPrefix == null || pathPrefix.isEmpty()); + for (String email : invitations.getEmails()) { try { if (email != null && validateEmail(email)) { List providers = filter(activeProviders, client, email); if (providers.size() == 1) { ScimUser user = findOrCreateUser(email, providers.getFirst().getOriginKey()); - String accountsUrl = UaaUrlUtils.getUaaUrl("/invitations/accept", !IdentityZoneHolder.isUaa(), IdentityZoneHolder.get()); + String accountsUrl = UaaUrlUtils.getUaaUrl(acceptPath, useSubdomainHost, IdentityZoneHolder.get()); Map data = new HashMap<>(); data.put(USER_ID, user.getId()); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeController.java b/server/src/main/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeController.java index 975ba488652..2d1d7e7405a 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeController.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeController.java @@ -18,6 +18,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestParam; +import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -32,14 +33,14 @@ public class ForcePasswordChangeController { private final ResetPasswordService resetPasswordService; private final IdentityZoneManager identityZoneManager; - @GetMapping({"/force_password_change", "/force_password_change/"}) + @GetMapping({"/force_password_change", "/force_password_change/", "/z/{subdomain}/force_password_change", "/z/{subdomain}/force_password_change/"}) public String forcePasswordChangePage(Model model) { String email = ((UaaAuthentication) SecurityContextHolder.getContext().getAuthentication()).getPrincipal().getEmail(); model.addAttribute("email", email); return "force_password_change"; } - @PostMapping({"/force_password_change", "/force_password_change/"}) + @PostMapping({"/force_password_change", "/force_password_change/", "/z/{subdomain}/force_password_change", "/z/{subdomain}/force_password_change/"}) public String handleForcePasswordChange(Model model, @RequestParam String password, @RequestParam("password_confirmation") String passwordConfirmation, @@ -65,7 +66,8 @@ public String handleForcePasswordChange(Model model, SessionUtils.setPasswordChangeRequired(httpSession, false); authentication.setAuthenticatedTime(System.currentTimeMillis()); SessionUtils.setSecurityContext(request.getSession(), SecurityContextHolder.getContext()); - return "redirect:/force_password_change_completed"; + String pathPrefix = UaaUrlUtils.getZonePathPrefix(request); + return pathPrefix.isEmpty() ? "redirect:/force_password_change_completed" : "redirect:" + pathPrefix + "/force_password_change_completed"; } private String handleUnprocessableEntity(Model model, HttpServletResponse response, String email, String message) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/login/LoginInfoEndpoint.java b/server/src/main/java/org/cloudfoundry/identity/uaa/login/LoginInfoEndpoint.java index 04cc91cceed..16d22c07f58 100755 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/login/LoginInfoEndpoint.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/login/LoginInfoEndpoint.java @@ -211,17 +211,17 @@ private static Map concatenateMaps(M return allIdentityProviders; } - @RequestMapping(value = {"/login"}, headers = "Accept=application/json") + @RequestMapping(value = {"/login", "/z/{subdomain}/login"}, headers = "Accept=application/json") public String infoForLoginJson(Model model, Principal principal, HttpServletRequest request) { return login(model, principal, emptyList(), true, request); } - @RequestMapping(value = {"/info"}, headers = "Accept=application/json") + @RequestMapping(value = {"/info", "/z/{subdomain}/info"}, headers = "Accept=application/json") public String infoForJson(Model model, Principal principal, HttpServletRequest request) { return login(model, principal, emptyList(), true, request); } - @RequestMapping(value = {"/login"}, headers = "Accept=text/html, */*") + @RequestMapping(value = {"/login", "/z/{subdomain}/login"}, headers = "Accept=text/html, */*") public String loginForHtml(Model model, Principal principal, HttpServletRequest request, @@ -246,7 +246,7 @@ public String loginForHtml(Model model, return login(model, principal, List.of(PASSCODE), false, request); } - @RequestMapping(value = {"/invalid_request"}) + @RequestMapping(value = {"/invalid_request", "/z/{subdomain}/invalid_request"}) public String invalidRequest() { return "invalid_request"; } @@ -625,7 +625,7 @@ private Map.Entry evaluateLoginHint( return null; } - @RequestMapping(value = {"/delete_saved_account"}) + @RequestMapping(value = {"/delete_saved_account", "/z/{subdomain}/delete_saved_account"}) public String deleteSavedAccount(HttpServletRequest request, HttpServletResponse response, String userId) { Cookie cookie = UaaUrlUtils.createSavedCookie(userId, null); cookie.setMaxAge(0); @@ -821,7 +821,7 @@ private String extractUrlFromString(String s) { return null; } - @PostMapping(value = "/origin-chooser") + @PostMapping(value = {"/origin-chooser", "/z/{subdomain}/origin-chooser"}) public String loginUsingOrigin(@RequestParam(required = false, name = LOGIN_HINT_ATTRIBUTE) String loginHint) { if (!StringUtils.hasText(loginHint)) { return "redirect:/login?discoveryPerformed=true"; @@ -830,7 +830,7 @@ public String loginUsingOrigin(@RequestParam(required = false, name = LOGIN_HINT return "redirect:/login?discoveryPerformed=true&login_hint=" + URLEncoder.encode(uaaLoginHint.toString(), UTF_8); } - @PostMapping(value = "/login/idp_discovery") + @PostMapping(value = {"/login/idp_discovery", "/z/{subdomain}/login/idp_discovery"}) public String discoverIdentityProvider(@RequestParam String email, @RequestParam(required = false) String skipDiscovery, @RequestParam(required = false, name = LOGIN_HINT_ATTRIBUTE) String loginHint, @RequestParam(required = false, name = USERNAME_PARAMETER) String username, Model model, HttpSession session, HttpServletRequest request) { ClientDetails clientDetails = null; if (hasSavedOauthAuthorizeRequest(session)) { @@ -879,7 +879,7 @@ private String goToPasswordPage(String email, Model model) { return "idp_discovery/password"; } - @PostMapping(value = "/autologin") + @PostMapping(value = {"/autologin", "/z/{subdomain}/autologin"}) @ResponseBody public AutologinResponse generateAutologinCode(@RequestBody AutologinRequest request, @RequestHeader(value = "Authorization", required = false) String auth) { @@ -921,7 +921,7 @@ public AutologinResponse generateAutologinCode(@RequestBody AutologinRequest req return new AutologinResponse(expiringCode.getCode()); } - @GetMapping(value = "/autologin") + @GetMapping(value = {"/autologin", "/z/{subdomain}/autologin"}) public String performAutologin(HttpSession session) { String redirectLocation = "home"; SavedRequest savedRequest = SessionUtils.getSavedRequestSession(session); @@ -932,12 +932,12 @@ public String performAutologin(HttpSession session) { return REDIRECT + redirectLocation; } - @GetMapping(value = "/login_implicit") + @GetMapping(value = {"/login_implicit", "/z/{subdomain}/login_implicit"}) public String captureImplicitValuesUsingJavascript() { return "login_implicit"; } - @GetMapping(value = "/login/callback/{origin}") + @GetMapping(value = {"/login/callback/{origin}", "/z/{subdomain}/login/callback/{origin}"}) public String handleExternalOAuthCallback(final HttpSession session, @PathVariable String origin) { String redirectLocation = "/home"; SavedRequest savedRequest = SessionUtils.getSavedRequestSession(session); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/login/LoginSecurityConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/login/LoginSecurityConfiguration.java index b9153ab4b9f..a10c2981c3e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/login/LoginSecurityConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/login/LoginSecurityConfiguration.java @@ -70,6 +70,7 @@ import java.util.Map; import java.util.Set; +import static org.cloudfoundry.identity.uaa.account.ResetPasswordAuthenticationFilter.RESET_PASSWORD_URL; import static org.cloudfoundry.identity.uaa.web.AuthorizationManagersUtils.anyOf; @Configuration @@ -402,12 +403,12 @@ UaaFilterChain invitation( ) throws Exception { var originalChain = http .securityMatcher( - "/invitations/**" + "/invitations/**", "/z/*/invitations/**" ) .authorizeHttpRequests(auth -> { - auth.requestMatchers(HttpMethod.GET, "/invitations/accept").access(anyOf().anonymous().fullyAuthenticated()); - auth.requestMatchers(HttpMethod.POST, "/invitations/accept.do").hasAuthority("uaa.invited"); - auth.requestMatchers(HttpMethod.POST, "/invitations/accept_enterprise.do").hasAuthority("uaa.invited"); + auth.requestMatchers(HttpMethod.GET, "/invitations/accept", "/z/*/invitations/accept").access(anyOf().anonymous().fullyAuthenticated()); + auth.requestMatchers(HttpMethod.POST, "/invitations/accept.do", "/z/*/invitations/accept.do").hasAuthority("uaa.invited"); + auth.requestMatchers(HttpMethod.POST, "/invitations/accept_enterprise.do", "/z/*/invitations/accept_enterprise.do").hasAuthority("uaa.invited"); auth.anyRequest().denyAll(); }) @@ -435,7 +436,7 @@ UaaFilterChain inviteUser( @Qualifier("resourceAgnosticAuthenticationFilter") FilterRegistrationBean oauth2ResourceFilter ) throws Exception { var originalChain = http - .securityMatcher("/invite_users/**") + .securityMatcher("/invite_users/**", "/z/*/invite_users/**") .authorizeHttpRequests(auth -> { auth.requestMatchers(HttpMethod.POST, "/**").access( anyOf().isUaaAdmin() @@ -469,16 +470,16 @@ UaaFilterChain loginPublicOperations( ) throws Exception { var originalChain = http .securityMatcher( - "/delete_saved_account", - "/verify_user", - "/verify_email", - "/forgot_password", - "/forgot_password.do", - ResetPasswordAuthenticationFilter.RESET_PASSWORD_URL + "/delete_saved_account", "/z/*/delete_saved_account", + "/verify_user", "/z/*/verify_user", + "/verify_email", "/z/*/verify_email", + "/forgot_password", "/z/*/forgot_password", + "/forgot_password.do", "/z/*/forgot_password.do", + RESET_PASSWORD_URL, "/z/*" + RESET_PASSWORD_URL ) .authorizeHttpRequests(auth -> auth.anyRequest().permitAll()) .csrf(csrf -> { - csrf.ignoringRequestMatchers("/forgot_password.do"); + csrf.ignoringRequestMatchers("/forgot_password.do", "/z/*/forgot_password.do"); csrf.csrfTokenRepository(csrfTokenRepository); csrf.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()); }) @@ -523,26 +524,32 @@ UaaFilterChain uiSecurity( }) .authenticationManager(authenticationManager) .authorizeHttpRequests(auth -> { - auth.requestMatchers("/force_password_change/**").fullyAuthenticated(); - auth.requestMatchers("/reset_password**").anonymous(); - auth.requestMatchers("/create_account*").anonymous(); - auth.requestMatchers("/login/idp_discovery").anonymous(); - auth.requestMatchers("/login/idp_discovery/**").anonymous(); - auth.requestMatchers("/saml/metadata/**").anonymous(); - auth.requestMatchers("/origin-chooser").anonymous(); - auth.requestMatchers("/login**").access(anyOf().anonymous().fullyAuthenticated()); + auth.requestMatchers("/force_password_change/**", "/z/*/force_password_change/**").fullyAuthenticated(); + auth.requestMatchers("/reset_password**", "/z/*/reset_password**").anonymous(); + auth.requestMatchers("/create_account*", "/z/*/create_account*").anonymous(); + auth.requestMatchers("/accounts/email_sent", "/z/*/accounts/email_sent").anonymous(); + auth.requestMatchers("/login/idp_discovery", "/z/*/login/idp_discovery").anonymous(); + auth.requestMatchers("/login/idp_discovery/**", "/z/*/login/idp_discovery/**").anonymous(); + auth.requestMatchers("/saml/metadata/**", "/z/*/saml/metadata/**").anonymous(); + auth.requestMatchers("/origin-chooser", "/z/*/origin-chooser").anonymous(); + auth.requestMatchers("/login**", "/z/*/login**").access(anyOf().anonymous().fullyAuthenticated()); + // Allow OPTIONS for CORS preflight to logout.do (including zone path) + auth.requestMatchers(HttpMethod.OPTIONS, "/logout.do", "/z/*/logout.do").permitAll(); auth.requestMatchers("/**").fullyAuthenticated(); }) .formLogin(login -> { login.loginPage("/login"); login.usernameParameter("username"); login.passwordParameter("password"); + // Support both /login.do and /z/{subdomain}/login.do for zone path-based authentication login.loginProcessingUrl("/login.do"); login.defaultSuccessUrl("/"); // TODO is this exactly the same? login.successHandler(loginSuccessHandler); login.failureHandler(loginFailureHandler); login.authenticationDetailsSource(new UaaAuthenticationDetailsSource()); }) + // Add a second filter for zone path login processing + .addFilterBefore(zonePathLoginFilter(authenticationManager, loginSuccessHandler, loginFailureHandler), UsernamePasswordAuthenticationFilter.class) .addFilterBefore(new HttpsHeaderFilter(), DisableEncodeUrlFilter.class) // TODO: Opt in to SecurityContextHolder filter instead of SecurityContextPersistenceFilter // See: https://docs.spring.io/spring-security/reference/5.8/migration/servlet/session-management.html @@ -599,4 +606,21 @@ public OAuth2AuthenticationProcessingFilter oauth2ResourceFilter(@Nullable Strin return oauth2ResourceFilter; } + /** + * Creates a UsernamePasswordAuthenticationFilter for zone path-based login. + * This handles POST requests to /z/{subdomain}/login.do + */ + private UsernamePasswordAuthenticationFilter zonePathLoginFilter( + AuthenticationManager authenticationManager, + AccountSavingAuthenticationSuccessHandler successHandler, + UaaAuthenticationFailureHandler failureHandler + ) { + UsernamePasswordAuthenticationFilter filter = new UsernamePasswordAuthenticationFilter(authenticationManager); + filter.setRequiresAuthenticationRequestMatcher(new AntPathRequestMatcher("/z/*/login.do", "POST")); + filter.setAuthenticationSuccessHandler(successHandler); + filter.setAuthenticationFailureHandler(failureHandler); + filter.setAuthenticationDetailsSource(new UaaAuthenticationDetailsSource()); + return filter; + } + } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/login/SessionController.java b/server/src/main/java/org/cloudfoundry/identity/uaa/login/SessionController.java index 7d5cfed03ee..f0c18c20fa4 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/login/SessionController.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/login/SessionController.java @@ -21,8 +21,9 @@ @Controller public class SessionController { - @RequestMapping("/session") - public String session(Model model, @RequestParam String clientId, @RequestParam String messageOrigin) { + @RequestMapping({"/session", "/z/{subdomain}/session"}) + public String session(Model model, + @RequestParam String clientId, @RequestParam String messageOrigin) { // We need to maintain this version of the session page to continue compatibility with the // original version of uaa-singular. model.addAttribute("clientId", clientId); @@ -30,8 +31,9 @@ public String session(Model model, @RequestParam String clientId, @RequestParam return "session"; } - @RequestMapping("/session_management") - public String sessionManagement(Model model, @RequestParam String clientId, @RequestParam String messageOrigin) { + @RequestMapping({"/session_management", "/z/{subdomain}/session_management"}) + public String sessionManagement(Model model, + @RequestParam String clientId, @RequestParam String messageOrigin) { model.addAttribute("clientId", clientId); model.addAttribute("messageOrigin", messageOrigin); return "session_management"; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/login/UaaAuthenticationFailureHandler.java b/server/src/main/java/org/cloudfoundry/identity/uaa/login/UaaAuthenticationFailureHandler.java index a4e49114f1d..dfa1d481d88 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/login/UaaAuthenticationFailureHandler.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/login/UaaAuthenticationFailureHandler.java @@ -18,12 +18,15 @@ import org.cloudfoundry.identity.uaa.authentication.AuthenticationPolicyRejectionException; import org.cloudfoundry.identity.uaa.authentication.PasswordChangeRequiredException; import org.cloudfoundry.identity.uaa.util.SessionUtils; +import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.Authentication; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.security.web.authentication.ExceptionMappingAuthenticationFailureHandler; import org.springframework.security.web.authentication.logout.LogoutHandler; +import org.springframework.security.web.RedirectStrategy; +import org.springframework.security.web.DefaultRedirectStrategy; import org.springframework.stereotype.Component; import jakarta.servlet.ServletException; @@ -54,17 +57,39 @@ private static ExceptionMappingAuthenticationFailureHandler defaultDelegateFailu ) ); handler.setDefaultFailureUrl("/login?error=login_failure"); + handler.setRedirectStrategy(new ZoneAwareRedirectStrategy()); return handler; } + /** + * Prepend zone path prefix (e.g. /z/{subdomain}) to redirect URLs when the request is under /z/{subdomain}/.... + * Otherwise delegates to DefaultRedirectStrategy unchanged. + */ + private static final class ZoneAwareRedirectStrategy implements RedirectStrategy { + private final RedirectStrategy defaultStrategy = new DefaultRedirectStrategy(); + + @Override + public void sendRedirect(HttpServletRequest request, HttpServletResponse response, String url) throws IOException { + String zonePrefix = UaaUrlUtils.getZonePathPrefix(request); + String targetUrl = url; + if (!zonePrefix.isEmpty() && url.startsWith("/") && !url.startsWith(zonePrefix)) { + targetUrl = zonePrefix + url; + } + defaultStrategy.sendRedirect(request, response, targetUrl); + } + } + public UaaAuthenticationFailureHandler(ExceptionMappingAuthenticationFailureHandler delegate, CurrentUserCookieFactory currentUserCookieFactory) { this.delegate = delegate; this.currentUserCookieFactory = currentUserCookieFactory; + if (delegate != null) { + delegate.setRedirectStrategy(new ZoneAwareRedirectStrategy()); + } } @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { - addCookie(response); + addCookie(request, response); if (exception instanceof PasswordChangeRequiredException passwordChangeRequiredException) { SessionUtils.setForcePasswordExpiredUser(request.getSession(), passwordChangeRequiredException.getAuthentication()); @@ -77,11 +102,15 @@ public void onAuthenticationFailure(HttpServletRequest request, HttpServletRespo @Override public void logout(HttpServletRequest request, HttpServletResponse response, Authentication authentication) { - addCookie(response); + addCookie(request, response); } - private void addCookie(HttpServletResponse response) { + private void addCookie(HttpServletRequest request, HttpServletResponse response) { Cookie clearCurrentUserCookie = currentUserCookieFactory.getNullCookie(); + String zonePrefix = UaaUrlUtils.getZonePathPrefix(request); + if (!zonePrefix.isEmpty()) { + clearCurrentUserCookie.setPath(zonePrefix); + } response.addCookie(clearCurrentUserCookie); } } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/logout/LoggedOutEndpoint.java b/server/src/main/java/org/cloudfoundry/identity/uaa/logout/LoggedOutEndpoint.java index bead7a5ce2b..58c7c1d67c8 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/logout/LoggedOutEndpoint.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/logout/LoggedOutEndpoint.java @@ -1,13 +1,19 @@ package org.cloudfoundry.identity.uaa.logout; +import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +import jakarta.servlet.http.HttpServletRequest; + @Controller public class LoggedOutEndpoint { - @GetMapping("/logged_out") - public String loggedOut() { + @GetMapping({"/logged_out", "/z/{subdomain}/logged_out"}) + public String loggedOut(HttpServletRequest request, Model model) { + String pathPrefix = UaaUrlUtils.getZonePathPrefix(request); + model.addAttribute("loginUrl", pathPrefix.isEmpty() ? "/login" : pathPrefix + "/login"); return "logged_out"; } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenKeyEndpoint.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenKeyEndpoint.java index 4258aec00c2..144e9bd4ad3 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenKeyEndpoint.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/TokenKeyEndpoint.java @@ -40,7 +40,7 @@ public TokenKeyEndpoint( this.keyInfoService = keyInfoService; } - @GetMapping("/token_key") + @GetMapping({"/token_key", "/z/{subdomain}/token_key"}) @ResponseBody public ResponseEntity getKey(Principal principal, @RequestHeader(value = "If-None-Match", required = false, defaultValue = "NaN") String eTag) { @@ -55,7 +55,7 @@ public ResponseEntity getKey(Principal principal, } - @GetMapping("/token_keys") + @GetMapping({"/token_keys", "/z/{subdomain}/token_keys"}) @ResponseBody public ResponseEntity getKeys(Principal principal, @RequestHeader(value = "If-None-Match", required = false, defaultValue = "NaN") String eTag) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaAuthorizationEndpoint.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaAuthorizationEndpoint.java index 9ac74102177..9bce98bb511 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaAuthorizationEndpoint.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/UaaAuthorizationEndpoint.java @@ -151,7 +151,7 @@ public UaaAuthorizationEndpoint( this.implicitLock = new Object(); } - @RequestMapping(value = "/oauth/authorize") + @RequestMapping(value = {"/oauth/authorize", "/z/{subdomain}/oauth/authorize"}) public ModelAndView authorize(Map model, @RequestParam Map parameters, SessionStatus sessionStatus, @@ -398,7 +398,7 @@ Map unmodifiableMap(AuthorizationRequest authorizationRequest) { return authorizationRequestMap; } - @PostMapping(value = "/oauth/authorize", params = OAuth2Utils.USER_OAUTH_APPROVAL) + @PostMapping(value = {"/oauth/authorize", "/z/{subdomain}/oauth/authorize"}, params = OAuth2Utils.USER_OAUTH_APPROVAL) public View approveOrDeny(@RequestParam Map approvalParameters, Map model, SessionStatus sessionStatus, Principal principal) { diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java index ea5f1cf16f2..46e9e5a7568 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/OauthEndpointSecurityConfiguration.java @@ -348,7 +348,7 @@ UaaFilterChain statelessTokenApiSecurity(HttpSecurity http) throws Exception { @Order(FilterChainOrder.OAUTH_05) UaaFilterChain tokenEndpointSecurity(HttpSecurity http) throws Exception { SecurityFilterChain chain = http - .securityMatcher("/oauth/token/**") + .securityMatcher("/oauth/token/**", "/z/*/oauth/token/**") .authenticationManager(clientAuthenticationManager) .authorizeHttpRequests( auth -> { auth.requestMatchers("/**").access(anyOf().fullyAuthenticated()); @@ -374,10 +374,10 @@ UaaFilterChain tokenEndpointSecurity(HttpSecurity http) throws Exception { @Order(FilterChainOrder.OAUTH_06) UaaFilterChain statelessAuthzEndpointSecurity(HttpSecurity http) throws Exception { SecurityFilterChain chain = http - .securityMatcher(oauthAuthorizeRequestMatcher) + .securityMatcher(oauthAuthorizeRequestMatcher.withZonePaths()) .authenticationManager(zoneAwareAuthzAuthenticationManager) .authorizeHttpRequests( auth -> { - auth.requestMatchers(oauthAuthorizeRequestMatcher).access(anyOf().fullyAuthenticated()); + auth.requestMatchers(oauthAuthorizeRequestMatcher.withZonePaths()).access(anyOf().fullyAuthenticated()); auth.anyRequest().denyAll(); }) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) @@ -398,9 +398,9 @@ UaaFilterChain statelessAuthzEndpointSecurity(HttpSecurity http) throws Exceptio @Order(FilterChainOrder.OAUTH_07) UaaFilterChain statelessAuthorizeApiSecurity(HttpSecurity http) throws Exception { SecurityFilterChain chain = http - .securityMatcher(oauthAuthorizeApiRequestMatcher) + .securityMatcher(oauthAuthorizeApiRequestMatcher.withZonePaths()) .authorizeHttpRequests( auth -> { - auth.requestMatchers(oauthAuthorizeApiRequestMatcher).access(anyOf(true).hasScope("uaa.user").isUaaAdmin().isZoneAdmin()); + auth.requestMatchers(oauthAuthorizeApiRequestMatcher.withZonePaths()).access(anyOf(true).hasScope("uaa.user").isUaaAdmin().isZoneAdmin()); auth.anyRequest().denyAll(); }) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) @@ -421,9 +421,9 @@ UaaFilterChain statelessAuthorizeApiSecurity(HttpSecurity http) throws Exception @Order(FilterChainOrder.OAUTH_08) UaaFilterChain promptStatelessTokenApiSecurity(HttpSecurity http) throws Exception { SecurityFilterChain chain = http - .securityMatcher(promptOauthAuthorizeApiRequestMatcher) + .securityMatcher(promptOauthAuthorizeApiRequestMatcher.withZonePaths()) .authorizeHttpRequests( auth -> { - auth.requestMatchers(promptOauthAuthorizeApiRequestMatcher).fullyAuthenticated(); + auth.requestMatchers(promptOauthAuthorizeApiRequestMatcher.withZonePaths()).fullyAuthenticated(); auth.anyRequest().denyAll(); }) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.NEVER)) @@ -468,10 +468,10 @@ UaaFilterChain externalOAuthCallbackEndpointSecurity(HttpSecurity http) throws E @Order(FilterChainOrder.OAUTH_10) UaaFilterChain oldAuthzEndpointSecurity(HttpSecurity http) throws Exception { SecurityFilterChain chain = http - .securityMatcher(oauthAuthorizeRequestMatcherOld) + .securityMatcher(oauthAuthorizeRequestMatcherOld.withZonePaths()) .authenticationManager(zoneAwareAuthzAuthenticationManager) .authorizeHttpRequests( auth -> { - auth.requestMatchers(oauthAuthorizeRequestMatcherOld).access(anyOf().fullyAuthenticated()); + auth.requestMatchers(oauthAuthorizeRequestMatcherOld.withZonePaths()).access(anyOf().fullyAuthenticated()); auth.anyRequest().denyAll(); }) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.NEVER)) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/TokenIntrospectionSecurityConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/TokenIntrospectionSecurityConfiguration.java index 265efbc8a08..3dc666c96cb 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/TokenIntrospectionSecurityConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/beans/TokenIntrospectionSecurityConfiguration.java @@ -75,7 +75,7 @@ UaaFilterChain checkTokenSecurity(HttpSecurity http) throws Exception { @Order(FilterChainOrder.RESOURCE) UaaFilterChain tokenKeySecurity(HttpSecurity http) throws Exception { SecurityFilterChain chain = http - .securityMatcher("/token_key/**", "/token_keys/**") + .securityMatcher("/token_key/**", "/token_keys/**", "/z/*/token_key", "/z/*/token_key/**", "/z/*/token_keys", "/z/*/token_keys/**") .authorizeHttpRequests( auth -> { auth.requestMatchers("/**").access(anyOf().anonymous().fullyAuthenticated()); auth.anyRequest().denyAll(); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java index 66653097bc3..3cee0fde51b 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/oauth/token/UaaTokenEndpoint.java @@ -31,7 +31,7 @@ import static org.springframework.util.StringUtils.hasText; @Controller -@RequestMapping(value = "/oauth/token") //used simply because TokenEndpoint wont match /oauth/token/alias/saml-entity-id +@RequestMapping(path = {"/oauth/token", "/z/{subdomain}/oauth/token"}) //used simply because TokenEndpoint wont match /oauth/token/alias/saml-entity-id public class UaaTokenEndpoint extends TokenEndpoint { private final boolean allowQueryString; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/DisableUserManagementSecurityFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/DisableUserManagementSecurityFilter.java index 4f304e0426c..6861a26bcda 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/DisableUserManagementSecurityFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/DisableUserManagementSecurityFilter.java @@ -40,9 +40,14 @@ public class DisableUserManagementSecurityFilter extends OncePerRequestFilter { regex1 += "|^/verify_user"; regex1 += "|^/change_email"; regex1 += "|^/change_email.do"; + regex1 += "|^/z/[^/]+/change_email"; + regex1 += "|^/z/[^/]+/change_email.do"; regex1 += "|^/verify_email"; + regex1 += "|^/z/[^/]+/verify_email"; regex1 += "|^/change_password"; regex1 += "|^/change_password.do"; + regex1 += "|^/z/[^/]+/change_password"; + regex1 += "|^/z/[^/]+/change_password.do"; regex1 += "|^/forgot_password"; regex1 += "|^/forgot_password.do"; regex1 += "|^/email_sent"; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/beans/ScimSecurityConfiguration.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/beans/ScimSecurityConfiguration.java index 42474ee5251..82e72c1ca29 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/beans/ScimSecurityConfiguration.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/beans/ScimSecurityConfiguration.java @@ -50,7 +50,8 @@ class ScimSecurityConfiguration { @Order(FilterChainOrder.SCIM_PASSWORD) UaaFilterChain scimUserPassword(HttpSecurity http) throws Exception { SecurityFilterChain chain = http - .securityMatcher("/Users/*/password", "/Users/*/password/**") + .securityMatcher("/Users/*/password", "/Users/*/password/**", + "/z/{subdomain}/Users/*/password", "/z/{subdomain}/Users/*/password/**") .authorizeHttpRequests( auth -> { auth.requestMatchers("/**").access(anyOf(true).hasScope("password.write")); auth.anyRequest().denyAll(); @@ -73,7 +74,8 @@ UaaFilterChain scimUserPassword(HttpSecurity http) throws Exception { @Order(FilterChainOrder.SCIM) UaaFilterChain scimUserIds(HttpSecurity http) throws Exception { SecurityFilterChain chain = http - .securityMatcher("/ids/Users", "/ids/Users*", "/ids/Users/**") + .securityMatcher("/ids/Users", "/ids/Users*", "/ids/Users/**", + "/z/{subdomain}/ids/Users", "/z/{subdomain}/ids/Users*", "/z/{subdomain}/ids/Users/**") .authorizeHttpRequests( auth -> { auth.requestMatchers("/**").access(anyOf(true).hasScope("scim.userids")); auth.anyRequest().denyAll(); @@ -96,18 +98,18 @@ UaaFilterChain scimUserIds(HttpSecurity http) throws Exception { @Order(FilterChainOrder.SCIM) UaaFilterChain groupEndpointSecurity(HttpSecurity http) throws Exception { SecurityFilterChain chain = http - .securityMatcher("/Groups", "/Groups/**") + .securityMatcher("/Groups", "/Groups/**", "/z/{subdomain}/Groups", "/z/{subdomain}/Groups/**") .authorizeHttpRequests( auth -> { - auth.requestMatchers("/Groups/zones").access(anyOf(true).hasScope("scim.zones")); - auth.requestMatchers("/Groups/zones/**").access(anyOf(true).hasScope("scim.zones")); - auth.requestMatchers(HttpMethod.GET, "/Groups/External").access(anyOf(true).hasScope("scim.read").isZoneAdmin()); - auth.requestMatchers(HttpMethod.POST, "/Groups/External").access(anyOf(true).hasScope("scim.write").isZoneAdmin()); - auth.requestMatchers(HttpMethod.DELETE, "/Groups/**").access(anyOf(true).hasScope("scim.write").isZoneAdmin()); - auth.requestMatchers(HttpMethod.PUT, "/Groups/**").access(anyOf(true).hasScope("scim.write", "groups.update").isZoneAdmin()); - auth.requestMatchers(HttpMethod.POST, "/Groups/**").access(anyOf(true).hasScope("scim.write", "groups.update").isZoneAdmin()); - auth.requestMatchers(HttpMethod.GET, "/Groups/**").access(anyOf(true).hasScope("scim.read").isZoneAdmin()); - auth.requestMatchers(HttpMethod.PATCH, "/Groups/**").access(anyOf(true).hasScope("scim.write", "groups.update").isZoneAdmin()); - auth.requestMatchers(HttpMethod.POST, "/Groups").access(anyOf(true).hasScope("scim.write").isZoneAdmin()); + auth.requestMatchers("/Groups/zones", "/z/{subdomain}/Groups/zones").access(anyOf(true).hasScope("scim.zones")); + auth.requestMatchers("/Groups/zones/**", "/z/{subdomain}/Groups/zones/**").access(anyOf(true).hasScope("scim.zones")); + auth.requestMatchers(HttpMethod.GET, "/Groups/External", "/z/{subdomain}/Groups/External").access(anyOf(true).hasScope("scim.read").isZoneAdmin()); + auth.requestMatchers(HttpMethod.POST, "/Groups/External", "/z/{subdomain}/Groups/External").access(anyOf(true).hasScope("scim.write").isZoneAdmin()); + auth.requestMatchers(HttpMethod.DELETE, "/Groups/**", "/z/{subdomain}/Groups/**").access(anyOf(true).hasScope("scim.write").isZoneAdmin()); + auth.requestMatchers(HttpMethod.PUT, "/Groups/**", "/z/{subdomain}/Groups/**").access(anyOf(true).hasScope("scim.write", "groups.update").isZoneAdmin()); + auth.requestMatchers(HttpMethod.POST, "/Groups/**", "/z/{subdomain}/Groups/**").access(anyOf(true).hasScope("scim.write", "groups.update").isZoneAdmin()); + auth.requestMatchers(HttpMethod.GET, "/Groups/**", "/z/{subdomain}/Groups/**").access(anyOf(true).hasScope("scim.read").isZoneAdmin()); + auth.requestMatchers(HttpMethod.PATCH, "/Groups/**", "/z/{subdomain}/Groups/**").access(anyOf(true).hasScope("scim.write", "groups.update").isZoneAdmin()); + auth.requestMatchers(HttpMethod.POST, "/Groups", "/z/{subdomain}/Groups").access(anyOf(true).hasScope("scim.write").isZoneAdmin()); auth.anyRequest().denyAll(); }) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) @@ -128,16 +130,16 @@ UaaFilterChain groupEndpointSecurity(HttpSecurity http) throws Exception { @Order(FilterChainOrder.SCIM) UaaFilterChain scimUsers(HttpSecurity http, @Qualifier("self") IsSelfCheck selfCheck) throws Exception { SecurityFilterChain chain = http - .securityMatcher("/Users", "/Users/**") + .securityMatcher("/Users", "/Users/**", "/z/{subdomain}/Users", "/z/{subdomain}/Users/**") .authorizeHttpRequests( auth -> { - auth.requestMatchers(HttpMethod.GET, "/Users/*/verify-link").access(anyOf(true).hasScope("scim.create").isZoneAdmin()); - auth.requestMatchers(HttpMethod.GET, "/Users/*/verify").access(anyOf(true).hasScope("scim.write", "scim.create").isZoneAdmin()); - auth.requestMatchers(HttpMethod.PATCH, "/Users/*/status").access(anyOf(true).hasScope("scim.write", "uaa.account_status.write").isZoneAdmin()); - auth.requestMatchers(HttpMethod.GET, "/Users/**").access(anyOf(true).hasScope("scim.read").or(SelfCheckAuthorizationManager.isUserSelf(selfCheck, 1)).isZoneAdmin()); - auth.requestMatchers(HttpMethod.DELETE, "/Users","/Users/*").access(anyOf(true).hasScope("scim.write").isZoneAdmin()); - auth.requestMatchers(HttpMethod.PUT, "/Users","/Users/*").access(anyOf(true).hasScope("scim.write").or(SelfCheckAuthorizationManager.isUserSelf(selfCheck, 1)).isZoneAdmin()); - auth.requestMatchers(HttpMethod.PATCH, "/Users","/Users/*").access(anyOf(true).hasScope("scim.write").or(SelfCheckAuthorizationManager.isUserSelf(selfCheck, 1)).isZoneAdmin()); - auth.requestMatchers(HttpMethod.POST, "/Users","/Users/*").access(anyOf(true).hasScope("scim.write", "scim.create").isZoneAdmin()); + auth.requestMatchers(HttpMethod.GET, "/Users/*/verify-link", "/z/{subdomain}/Users/*/verify-link").access(anyOf(true).hasScope("scim.create").isZoneAdmin()); + auth.requestMatchers(HttpMethod.GET, "/Users/*/verify", "/z/{subdomain}/Users/*/verify").access(anyOf(true).hasScope("scim.write", "scim.create").isZoneAdmin()); + auth.requestMatchers(HttpMethod.PATCH, "/Users/*/status", "/z/{subdomain}/Users/*/status").access(anyOf(true).hasScope("scim.write", "uaa.account_status.write").isZoneAdmin()); + auth.requestMatchers(HttpMethod.GET, "/Users/**", "/z/{subdomain}/Users/**").access(anyOf(true).hasScope("scim.read").or(SelfCheckAuthorizationManager.isUserSelf(selfCheck, 1)).isZoneAdmin()); + auth.requestMatchers(HttpMethod.DELETE, "/Users", "/Users/*", "/z/{subdomain}/Users", "/z/{subdomain}/Users/*").access(anyOf(true).hasScope("scim.write").isZoneAdmin()); + auth.requestMatchers(HttpMethod.PUT, "/Users", "/Users/*", "/z/{subdomain}/Users", "/z/{subdomain}/Users/*").access(anyOf(true).hasScope("scim.write").or(SelfCheckAuthorizationManager.isUserSelf(selfCheck, 1)).isZoneAdmin()); + auth.requestMatchers(HttpMethod.PATCH, "/Users", "/Users/*", "/z/{subdomain}/Users", "/z/{subdomain}/Users/*").access(anyOf(true).hasScope("scim.write").or(SelfCheckAuthorizationManager.isUserSelf(selfCheck, 1)).isZoneAdmin()); + auth.requestMatchers(HttpMethod.POST, "/Users", "/Users/*", "/z/{subdomain}/Users", "/z/{subdomain}/Users/*").access(anyOf(true).hasScope("scim.write", "scim.create").isZoneAdmin()); auth.anyRequest().denyAll(); }) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java index b1e61d14bee..4ea20e22a3b 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpoints.java @@ -138,7 +138,7 @@ private List filterForCurrentUser(List input, int startInd return response; } - @GetMapping({"/Groups", "/Groups/"}) + @GetMapping({"/Groups", "/Groups/", "/z/{subdomain}/Groups", "/z/{subdomain}/Groups/"}) @ResponseBody public SearchResults listGroups( @RequestParam(value = "attributes", required = false) String attributesCommaSeparated, @@ -183,7 +183,7 @@ public SearchResults listGroups( } } - @GetMapping({"/Groups/External/list"}) + @GetMapping({"/Groups/External/list", "/z/{subdomain}/Groups/External/list"}) @ResponseBody @Deprecated public SearchResults listExternalGroups( @@ -193,7 +193,7 @@ public SearchResults listExternalGroups( return getExternalGroups(startIndex, count, filter, "", ""); } - @GetMapping({"/Groups/External", "/Groups/External/"}) + @GetMapping({"/Groups/External", "/Groups/External/", "/z/{subdomain}/Groups/External", "/z/{subdomain}/Groups/External/"}) @ResponseBody public SearchResults getExternalGroups( @RequestParam(required = false, defaultValue = "1") int startIndex, @@ -241,7 +241,7 @@ public SearchResults getExternalGroups( Arrays.asList(ScimCore.SCHEMAS)); } - @PostMapping({"/Groups/External", "/Groups/External/"}) + @PostMapping({"/Groups/External", "/Groups/External/", "/z/{subdomain}/Groups/External", "/z/{subdomain}/Groups/External/"}) @ResponseBody @ResponseStatus(HttpStatus.CREATED) public ScimGroupExternalMember mapExternalGroup(@RequestBody ScimGroupExternalMember sgm) { @@ -263,7 +263,7 @@ public ScimGroupExternalMember mapExternalGroup(@RequestBody ScimGroupExternalMe } } - @DeleteMapping({"/Groups/External/groupId/{groupId}/externalGroup/{externalGroup}"}) + @DeleteMapping({"/Groups/External/groupId/{groupId}/externalGroup/{externalGroup}", "/z/{subdomain}/Groups/External/groupId/{groupId}/externalGroup/{externalGroup}"}) @ResponseBody @ResponseStatus(HttpStatus.OK) @Deprecated @@ -271,7 +271,7 @@ public ScimGroupExternalMember deprecated2UnmapExternalGroup(@PathVariable Strin return unmapExternalGroup(groupId, externalGroup, null); } - @DeleteMapping({"/Groups/External/groupId/{groupId}/externalGroup/{externalGroup}/origin/{origin}"}) + @DeleteMapping({"/Groups/External/groupId/{groupId}/externalGroup/{externalGroup}/origin/{origin}", "/z/{subdomain}/Groups/External/groupId/{groupId}/externalGroup/{externalGroup}/origin/{origin}"}) @ResponseBody @ResponseStatus(HttpStatus.OK) public ScimGroupExternalMember unmapExternalGroup(@PathVariable String groupId, @@ -294,7 +294,7 @@ public ScimGroupExternalMember unmapExternalGroup(@PathVariable String groupId, } } - @DeleteMapping({"/Groups/External/id/{groupId}/{externalGroup}"}) + @DeleteMapping({"/Groups/External/id/{groupId}/{externalGroup}", "/z/{subdomain}/Groups/External/id/{groupId}/{externalGroup}"}) @ResponseBody @ResponseStatus(HttpStatus.OK) @Deprecated @@ -302,7 +302,7 @@ public ScimGroupExternalMember deprecatedUnmapExternalGroup(@PathVariable String return unmapExternalGroup(groupId, externalGroup, LDAP); } - @DeleteMapping({"/Groups/External/displayName/{displayName}/externalGroup/{externalGroup}"}) + @DeleteMapping({"/Groups/External/displayName/{displayName}/externalGroup/{externalGroup}", "/z/{subdomain}/Groups/External/displayName/{displayName}/externalGroup/{externalGroup}"}) @ResponseBody @ResponseStatus(HttpStatus.OK) @Deprecated @@ -310,7 +310,7 @@ public ScimGroupExternalMember unmapExternalGroupUsingName(@PathVariable String return unmapExternalGroupUsingName(displayName, externalGroup, LDAP); } - @DeleteMapping({"/Groups/External/displayName/{displayName}/externalGroup/{externalGroup}/origin/{origin}"}) + @DeleteMapping({"/Groups/External/displayName/{displayName}/externalGroup/{externalGroup}/origin/{origin}", "/z/{subdomain}/Groups/External/displayName/{displayName}/externalGroup/{externalGroup}/origin/{origin}"}) @ResponseBody @ResponseStatus(HttpStatus.OK) public ScimGroupExternalMember unmapExternalGroupUsingName(@PathVariable String displayName, @@ -334,7 +334,7 @@ public ScimGroupExternalMember unmapExternalGroupUsingName(@PathVariable String } } - @DeleteMapping({"/Groups/External/{displayName}/{externalGroup}"}) + @DeleteMapping({"/Groups/External/{displayName}/{externalGroup}", "/z/{subdomain}/Groups/External/{displayName}/{externalGroup}"}) @ResponseBody @ResponseStatus(HttpStatus.OK) @Deprecated @@ -355,7 +355,7 @@ private String getGroupId(String displayName) { } - @GetMapping({"/Groups/{groupId}"}) + @GetMapping({"/Groups/{groupId}", "/z/{subdomain}/Groups/{groupId}"}) @ResponseBody public ScimGroup getGroup(@PathVariable String groupId, HttpServletResponse httpServletResponse) { String groupIdRequest = UaaStringUtils.getCleanedUserControlString(groupId); @@ -366,7 +366,7 @@ public ScimGroup getGroup(@PathVariable String groupId, HttpServletResponse http return group; } - @PostMapping({"/Groups", "/Groups/"}) + @PostMapping({"/Groups", "/Groups/", "/z/{subdomain}/Groups", "/z/{subdomain}/Groups/"}) @ResponseStatus(HttpStatus.CREATED) @ResponseBody public ScimGroup createGroup(@RequestBody ScimGroup group, HttpServletResponse httpServletResponse) { @@ -392,7 +392,7 @@ public ScimGroup createGroup(@RequestBody ScimGroup group, HttpServletResponse h return created; } - @PutMapping({"/Groups/{groupId}"}) + @PutMapping({"/Groups/{groupId}", "/z/{subdomain}/Groups/{groupId}"}) @ResponseBody public ScimGroup updateGroup(@RequestBody ScimGroup group, @PathVariable String groupId, @RequestHeader(value = "If-Match", required = false) String etag, @@ -436,7 +436,7 @@ public ScimGroup updateGroup(@RequestBody ScimGroup group, @PathVariable String } } - @PatchMapping({"/Groups/{groupId}"}) + @PatchMapping({"/Groups/{groupId}", "/z/{subdomain}/Groups/{groupId}"}) @ResponseBody public ScimGroup patchGroup(@RequestBody ScimGroup patch, @PathVariable String groupId, @@ -454,7 +454,7 @@ public ScimGroup patchGroup(@RequestBody ScimGroup patch, @PathVariable return updateGroup(current, groupId, etag, httpServletResponse); } - @DeleteMapping({"/Groups/{groupId}"}) + @DeleteMapping({"/Groups/{groupId}", "/z/{subdomain}/Groups/{groupId}"}) @ResponseBody public ScimGroup deleteGroup(@PathVariable String groupId, @RequestHeader(value = "If-Match", required = false, defaultValue = "*") String etag, @@ -472,7 +472,7 @@ public ScimGroup deleteGroup(@PathVariable String groupId, return group; } - @PostMapping({"/Groups/zones", "/Groups/zones/"}) + @PostMapping({"/Groups/zones", "/Groups/zones/", "/z/{subdomain}/Groups/zones", "/z/{subdomain}/Groups/zones/"}) @ResponseStatus(HttpStatus.CREATED) @ResponseBody @Deprecated @@ -504,7 +504,7 @@ public ScimGroup addZoneManagers(@RequestBody ScimGroup group, HttpServletRespon } } - @DeleteMapping({"/Groups/zones/{userId}/{zoneId}"}) + @DeleteMapping({"/Groups/zones/{userId}/{zoneId}", "/z/{subdomain}/Groups/zones/{userId}/{zoneId}"}) @ResponseStatus(HttpStatus.OK) @ResponseBody @Deprecated @@ -512,7 +512,7 @@ public ScimGroup deleteZoneAdmin(@PathVariable String userId, @PathVariable Stri return deleteZoneScope(userId, zoneId, "admin", httpServletResponse); } - @DeleteMapping({"/Groups/zones/{userId}/{zoneId}/{scope}"}) + @DeleteMapping({"/Groups/zones/{userId}/{zoneId}/{scope}", "/z/{subdomain}/Groups/zones/{userId}/{zoneId}/{scope}"}) @ResponseStatus(HttpStatus.OK) @ResponseBody @Deprecated @@ -543,7 +543,7 @@ public ScimGroup deleteZoneScope(@PathVariable String userId, return updateGroup(group, group.getId(), String.valueOf(group.getVersion()), httpServletResponse); } - @RequestMapping({"/Groups/{groupId}/members/{memberId}", "/Groups/{groupId}/members/{memberId}/"}) + @RequestMapping({"/Groups/{groupId}/members/{memberId}", "/Groups/{groupId}/members/{memberId}/", "/z/{subdomain}/Groups/{groupId}/members/{memberId}", "/z/{subdomain}/Groups/{groupId}/members/{memberId}/"}) public ResponseEntity getGroupMembership(@PathVariable String groupId, @PathVariable String memberId) { ScimGroupMember membership = membershipManager.getMemberById(groupId, memberId, @@ -551,7 +551,7 @@ public ResponseEntity getGroupMembership(@PathVariable String g return new ResponseEntity<>(membership, HttpStatus.OK); } - @GetMapping({"/Groups/{groupId}/members", "/Groups/{groupId}/members/"}) + @GetMapping({"/Groups/{groupId}/members", "/Groups/{groupId}/members/", "/z/{subdomain}/Groups/{groupId}/members", "/z/{subdomain}/Groups/{groupId}/members/"}) public ResponseEntity> listGroupMemberships(@PathVariable String groupId, @RequestParam(required = false, defaultValue = "false") boolean returnEntities, @RequestParam(required = false, defaultValue = "", name = "filter") String deprecatedFilter) { @@ -562,7 +562,7 @@ public ResponseEntity> listGroupMemberships(@PathVariable return new ResponseEntity<>(members, HttpStatus.OK); } - @PostMapping({"/Groups/{groupId}/members", "/Groups/{groupId}/members/"}) + @PostMapping({"/Groups/{groupId}/members", "/Groups/{groupId}/members/", "/z/{subdomain}/Groups/{groupId}/members", "/z/{subdomain}/Groups/{groupId}/members/"}) @ResponseStatus(HttpStatus.CREATED) @ResponseBody public ScimGroupMember addMemberToGroup(@PathVariable String groupId, @RequestBody ScimGroupMember member) { @@ -570,7 +570,7 @@ public ScimGroupMember addMemberToGroup(@PathVariable String groupId, @RequestBo return membershipManager.addMember(groupId, member, identityZoneManager.getCurrentIdentityZoneId()); } - @DeleteMapping({"/Groups/{groupId}/members/{memberId}", "/Groups/{groupId}/members/{memberId}/"}) + @DeleteMapping({"/Groups/{groupId}/members/{memberId}", "/Groups/{groupId}/members/{memberId}/", "/z/{subdomain}/Groups/{groupId}/members/{memberId}", "/z/{subdomain}/Groups/{groupId}/members/{memberId}/"}) @ResponseBody @ResponseStatus(HttpStatus.OK) public ScimGroupMember deleteGroupMembership(@PathVariable String groupId, @PathVariable String memberId) { 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 7e652b0b452..f48a37d6ebd 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 @@ -220,7 +220,7 @@ public Map getErrorCounts() { return errorCounts; } - @GetMapping("/Users/{userId}") + @GetMapping({"/Users/{userId}", "/z/{subdomain}/Users/{userId}"}) @ResponseBody public ScimUser getUser(@PathVariable String userId, HttpServletResponse response) { ScimUser scimUser = syncApprovals(syncGroups(scimUserProvisioning.retrieve(userId, identityZoneManager.getCurrentIdentityZoneId()))); @@ -228,7 +228,7 @@ public ScimUser getUser(@PathVariable String userId, HttpServletResponse respons return scimUser; } - @PostMapping({"/Users", "/Users/"}) + @PostMapping({"/Users", "/Users/", "/z/{subdomain}/Users", "/z/{subdomain}/Users/"}) @ResponseStatus(HttpStatus.CREATED) @ResponseBody public ScimUser createUser(@RequestBody ScimUser user, HttpServletRequest request, HttpServletResponse response) { @@ -306,7 +306,7 @@ private boolean isUaaUser(@RequestBody ScimUser user) { return OriginKeys.UAA.equals(user.getOrigin()); } - @PutMapping("/Users/{userId}") + @PutMapping({"/Users/{userId}", "/z/{subdomain}/Users/{userId}"}) @ResponseBody public ScimUser updateUser(@RequestBody ScimUser user, @PathVariable String userId, @RequestHeader(value = "If-Match", required = false, defaultValue = "NaN") String etag, @@ -342,7 +342,7 @@ public ScimUser updateUser(@RequestBody ScimUser user, @PathVariable String user return scimUserWithApprovalsAndGroups; } - @PatchMapping("/Users/{userId}") + @PatchMapping({"/Users/{userId}", "/z/{subdomain}/Users/{userId}"}) @ResponseBody public ScimUser patchUser(@RequestBody ScimUser patch, @PathVariable String userId, @RequestHeader(value = "If-Match", required = false, defaultValue = "NaN") String etag, @@ -370,7 +370,7 @@ public ScimUser patchUser(@RequestBody ScimUser patch, @PathVariable String user } } - @DeleteMapping("/Users/{userId}") + @DeleteMapping({"/Users/{userId}", "/z/{subdomain}/Users/{userId}"}) @ResponseBody @Transactional public ScimUser deleteUser(@PathVariable String userId, @@ -431,7 +431,7 @@ public ScimUser deleteUser(@PathVariable String userId, return user; } - @GetMapping("/Users/{userId}/verify-link") + @GetMapping({"/Users/{userId}/verify-link", "/z/{subdomain}/Users/{userId}/verify-link"}) @ResponseBody public ResponseEntity getUserVerificationLink(@PathVariable String userId, @RequestParam(value = "client_id", required = false) String clientId, @@ -458,7 +458,7 @@ public ResponseEntity getUserVerificationLink(@PathVariabl return new ResponseEntity<>(responseBody, HttpStatus.OK); } - @GetMapping("/Users/{userId}/verify") + @GetMapping({"/Users/{userId}/verify", "/z/{subdomain}/Users/{userId}/verify"}) @ResponseBody public ScimUser verifyUser(@PathVariable String userId, @RequestHeader(value = "If-Match", required = false) String etag, @@ -489,7 +489,7 @@ private int getVersion(String userId, String etag) { } } - @GetMapping({"/Users", "/Users/"}) + @GetMapping({"/Users", "/Users/", "/z/{subdomain}/Users", "/z/{subdomain}/Users/"}) @ResponseBody public SearchResults findUsers( @RequestParam(value = "attributes", required = false) String attributesCommaSeparated, @@ -554,7 +554,7 @@ public SearchResults findUsers( } } - @PatchMapping("/Users/{userId}/status") + @PatchMapping({"/Users/{userId}/status", "/z/{subdomain}/Users/{userId}/status"}) public UserAccountStatus updateAccountStatus(@RequestBody UserAccountStatus status, @PathVariable String userId) { ScimUser user = scimUserProvisioning.retrieve(userId, identityZoneManager.getCurrentIdentityZoneId()); diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/security/web/UaaRequestMatcher.java b/server/src/main/java/org/cloudfoundry/identity/uaa/security/web/UaaRequestMatcher.java index dc0a293158e..ea6de37254e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/security/web/UaaRequestMatcher.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/security/web/UaaRequestMatcher.java @@ -5,6 +5,8 @@ import org.springframework.beans.factory.BeanNameAware; import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; +import org.springframework.security.web.util.matcher.OrRequestMatcher; import org.springframework.security.web.util.matcher.RequestMatcher; import org.springframework.util.Assert; @@ -34,6 +36,7 @@ public final class UaaRequestMatcher implements RequestMatcher, BeanNameAware { private static final Logger logger = LoggerFactory.getLogger(UaaRequestMatcher.class); private final String path; + private final RequestMatcher pathPatternMatcher; private List accepts; @@ -46,11 +49,49 @@ public final class UaaRequestMatcher implements RequestMatcher, BeanNameAware { private String name; public UaaRequestMatcher(String path) { + this(path, false); //backwards compatible + } + + public UaaRequestMatcher(String path, boolean withZonePaths) { Assert.hasText(path, "must have text"); + Assert.isTrue(path.startsWith("/"), "path must start with '/'"); if (path.contains("*")) { throw new IllegalArgumentException("UaaRequestMatcher is not intended for use with wildcards"); } this.path = path; + List matchers = new ArrayList<>(); + matchers.add(PathPatternRequestMatcher.withDefaults().matcher(path + "*")); //starts with + matchers.add(PathPatternRequestMatcher.withDefaults().matcher(path + "/**")); //sub paths + if (withZonePaths) { + matchers.add(PathPatternRequestMatcher.withDefaults().matcher("/z/{id}" + path + "*")); + matchers.add(PathPatternRequestMatcher.withDefaults().matcher("/z/{id}" + path + "/**")); + } + this.pathPatternMatcher = new OrRequestMatcher(matchers.toArray(new PathPatternRequestMatcher[0])); + } + + /** + * Generates a DEEP clone of the current matcher with the addition of also + * matching URLs that start with /z/{zone-identifier}/ + * @return a cloned request matcher that matches on path based zone patterns + */ + public UaaRequestMatcher withZonePaths() { + UaaRequestMatcher clone = new UaaRequestMatcher(path, true); + if (this.accepts != null) { + clone.accepts = new ArrayList<>(this.accepts); + } + if (!this.expectedHeaders.isEmpty()) { + clone.expectedHeaders.putAll(this.expectedHeaders); + } + if (this.parameters != null && !this.parameters.isEmpty()) { + clone.parameters.putAll(this.parameters); + } + if (this.method != null) { + clone.method = method; + } + if (this.name != null) { + clone.name = this.name; + } + return clone; } /** @@ -92,7 +133,7 @@ public boolean matches(HttpServletRequest request) { logger.trace("[{}] Checking match of request : '{}", name, message); } - if (!request.getRequestURI().startsWith(request.getContextPath() + path)) { + if (!pathPatternMatcher.matches(request)) { return false; } diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/util/UaaUrlUtils.java b/server/src/main/java/org/cloudfoundry/identity/uaa/util/UaaUrlUtils.java index e789019ff8e..7cf1f259226 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/util/UaaUrlUtils.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/util/UaaUrlUtils.java @@ -293,6 +293,27 @@ public static String getRequestPath(HttpServletRequest request) { return "%s%s".formatted(servletPath, pathInfo); } + /** + * Returns the zone path prefix (e.g. /z/test-zone) if the request is under /z/{subdomain}/..., otherwise "". + * Uses context path and request URI to determine the path after the context. + */ + public static String getZonePathPrefix(HttpServletRequest request) { + String contextPath = request.getContextPath() != null ? request.getContextPath() : ""; + String requestURI = request.getRequestURI() != null ? request.getRequestURI() : ""; + String path = requestURI.startsWith(contextPath) ? requestURI.substring(contextPath.length()) : requestURI; + if (path.isEmpty()) { + path = "/"; + } + if (path.startsWith("/z/")) { + int secondSlash = path.indexOf('/', 3); + if (secondSlash > 0) { + return path.substring(0, secondSlash); + } + return path; + } + return ""; + } + public static boolean uriHasMatchingHost(String uri, String hostname) { if (uri == null) { return false; diff --git a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilter.java b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilter.java index 7c429aa3de8..c0774fe3c8e 100644 --- a/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilter.java +++ b/server/src/main/java/org/cloudfoundry/identity/uaa/zone/IdentityZoneResolvingFilter.java @@ -18,6 +18,7 @@ import org.slf4j.LoggerFactory; import org.springframework.beans.factory.InitializingBean; import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.util.StringUtils; import org.springframework.web.filter.OncePerRequestFilter; import jakarta.servlet.FilterChain; @@ -31,12 +32,14 @@ /** * This filter ensures that all requests are targeting a specific identity zone - * by hostname. If the hostname doesn't match an identity zone, a 404 error is - * sent. - * + * by hostname or by path prefix /z/{subdomain}/. If the hostname doesn't match + * an identity zone, a 404 error is sent. Using both a subdomain (host) and a /z/ + * path is not allowed and returns 400. */ public class IdentityZoneResolvingFilter extends OncePerRequestFilter implements InitializingBean { + private static final String ZONE_PATH_PREFIX = "/z/"; + private final IdentityZoneProvisioning dao; private final Set staticResources = Set.of("/resources/", "/vendor/font-awesome/"); private final Set defaultZoneHostnames = new HashSet<>(); @@ -49,9 +52,21 @@ public IdentityZoneResolvingFilter(final IdentityZoneProvisioning dao) { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { + String requestPath = UaaUrlUtils.getRequestPath(request); + String subdomainFromHost = getSubdomainFromHost(request.getServerName()); + String subdomainFromPath = getSubdomainFromPath(requestPath); + + // 400: path starts with /z/ and host has a zone subdomain + if (requestPath.startsWith(ZONE_PATH_PREFIX) && StringUtils.hasText(subdomainFromHost)) { + response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Cannot use both subdomain and zone path"); + return; + } + + // Host always overrides path domain - path domain only works if there is no Host subdomain + String subdomain = StringUtils.hasText(subdomainFromPath) && "".equals(subdomainFromHost) ? + subdomainFromPath : subdomainFromHost; + IdentityZone identityZone = null; - String hostname = request.getServerName(); - String subdomain = getSubdomain(hostname); if (subdomain != null) { try { identityZone = dao.retrieveBySubdomain(subdomain); @@ -66,7 +81,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } if (identityZone == null) { // skip filter to static resources in order to serve images and css in case of invalid zones - boolean isStaticResource = staticResources.stream().anyMatch(UaaUrlUtils.getRequestPath(request)::startsWith); + boolean isStaticResource = staticResources.stream().anyMatch(requestPath::startsWith); if (isStaticResource) { filterChain.doFilter(request, response); return; @@ -84,7 +99,23 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse } } - private String getSubdomain(String hostname) { + /** + * Returns the subdomain if path starts with /z/{subdomain}/, otherwise null. + */ + private String getSubdomainFromPath(String path) { + if (path == null || !path.startsWith(ZONE_PATH_PREFIX)) { + return null; + } + String afterPrefix = path.substring(ZONE_PATH_PREFIX.length()); + int slash = afterPrefix.indexOf('/'); + if (slash < 0) { + return null; + } + String subdomain = afterPrefix.substring(0, slash); + return StringUtils.hasText(subdomain) ? subdomain : null; + } + + private String getSubdomainFromHost(String hostname) { String lowerHostName = hostname.toLowerCase(); if (defaultZoneHostnames.contains(lowerHostName)) { return ""; diff --git a/server/src/main/resources/templates/web/invitations/accept_invite.html b/server/src/main/resources/templates/web/invitations/accept_invite.html index 0ed0b2890a3..bb6449bf579 100644 --- a/server/src/main/resources/templates/web/invitations/accept_invite.html +++ b/server/src/main/resources/templates/web/invitations/accept_invite.html @@ -16,7 +16,7 @@

Create -
+ @@ -33,7 +33,7 @@

Create

Sign in with enterprise credentials:

- + diff --git a/server/src/main/resources/templates/web/logged_out.html b/server/src/main/resources/templates/web/logged_out.html index cc82952381a..6fb6a21b18c 100644 --- a/server/src/main/resources/templates/web/logged_out.html +++ b/server/src/main/resources/templates/web/logged_out.html @@ -3,7 +3,7 @@

You have successfully logged out.

diff --git a/server/src/main/resources/templates/web/nav.html b/server/src/main/resources/templates/web/nav.html index bab6342c01d..2fbcd8ba7a6 100644 --- a/server/src/main/resources/templates/web/nav.html +++ b/server/src/main/resources/templates/web/nav.html @@ -15,8 +15,8 @@
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 a664702877a..a3d591e84b8 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 @@ -180,6 +180,24 @@ void different_uri_skip_filter() throws ServletException, IOException { verifyNoInteractions(response); } + @Test + void zone_path_reset_password_do_processed_by_filter() throws ServletException, IOException { + when(service.resetPassword(any(ExpiringCode.class), eq(password))).thenReturn(new ResetPasswordService.ResetPasswordResponse(user, null, null)); + String code = request.getParameter("code"); + var zonePathRequest = MockMvcRequestBuilders.post("/z/testzone/reset_password.do") + .param("code", code) + .param("password", password) + .param("password_confirmation", password) + .param("email", email) + .buildRequest(new MockServletContext()); + + filter.doFilterInternal(zonePathRequest, response, chain); + + verify(service, times(1)).resetPassword(any(ExpiringCode.class), eq(password)); + verify(response, times(1)).sendRedirect(zonePathRequest.getContextPath() + "/login?success=password_reset"); + verify(chain, times(0)).doFilter(any(), any()); + } + @Test void autowired_constructor() { var filter = new ResetPasswordAuthenticationFilter(service, new InMemoryExpiringCodeStore(new TimeServiceImpl())); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/PasswordChangeUiRequiredFilterTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/PasswordChangeUiRequiredFilterTest.java index baf4c01f89d..169328a5f93 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/PasswordChangeUiRequiredFilterTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/authentication/PasswordChangeUiRequiredFilterTest.java @@ -7,6 +7,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.EnumSource; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -36,6 +38,14 @@ @ExtendWith(MockitoExtension.class) class PasswordChangeUiRequiredFilterTest { + /** Whether the test uses the default path or the zone path prefix {@code /z/{subdomain}/}. */ + enum RequestPathMode { + DEFAULT, + ZONE_PATH + } + + private static final String ZONE_PATH_SUBDOMAIN = "testsubdomain"; + private MockHttpServletRequest mockHttpServletRequest; @Mock @@ -64,6 +74,17 @@ void tearDown() { SecurityContextHolder.clearContext(); } + private static String pathFor(RequestPathMode mode, String path) { + return mode == RequestPathMode.ZONE_PATH ? "/z/" + ZONE_PATH_SUBDOMAIN + path : path; + } + + private void setRequestPath(RequestPathMode mode, String path) { + String fullPath = pathFor(mode, path); + mockHttpServletRequest.setRequestURI(fullPath); + mockHttpServletRequest.setServletPath(fullPath); + mockHttpServletRequest.setPathInfo(null); + } + @Test void notAuthenticated() throws Exception { passwordChangeUiRequiredFilter.doFilterInternal(mockHttpServletRequest, mockHttpServletResponse, mockFilterChain); @@ -79,21 +100,23 @@ void authenticated() throws Exception { verify(mockFilterChain, times(1)).doFilter(same(mockHttpServletRequest), same(mockHttpServletResponse)); } - @Test - void authenticatedPasswordExpired() throws Exception { - mockHttpServletRequest.setPathInfo("/oauth/authorize"); + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void authenticatedPasswordExpired(RequestPathMode mode) throws Exception { + setRequestPath(mode, "/oauth/authorize"); SecurityContextHolder.getContext().setAuthentication(mockUaaAuthentication); when(mockUaaAuthentication.isAuthenticated()).thenReturn(true); setRequiresPasswordChange(mockHttpServletRequest, true); passwordChangeUiRequiredFilter.doFilterInternal(mockHttpServletRequest, mockHttpServletResponse, mockFilterChain); verify(mockFilterChain, never()).doFilter(any(), any()); - verify(mockHttpServletResponse, times(1)).sendRedirect("/force_password_change"); + verify(mockHttpServletResponse, times(1)).sendRedirect(pathFor(mode, "/force_password_change")); verify(mockRequestCache, times(1)).saveRequest(any(), any()); } - @Test - void loadingChangePasswordPage() throws Exception { - mockHttpServletRequest.setPathInfo("/force_password_change"); + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void loadingChangePasswordPage(RequestPathMode mode) throws Exception { + setRequestPath(mode, "/force_password_change"); mockHttpServletRequest.setMethod(HttpMethod.GET.name()); SecurityContextHolder.getContext().setAuthentication(mockUaaAuthentication); when(mockUaaAuthentication.isAuthenticated()).thenReturn(true); @@ -103,9 +126,10 @@ void loadingChangePasswordPage() throws Exception { verify(mockHttpServletResponse, never()).sendRedirect(anyString()); } - @Test - void submitChangePassword() throws Exception { - mockHttpServletRequest.setPathInfo("/force_password_change"); + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void submitChangePassword(RequestPathMode mode) throws Exception { + setRequestPath(mode, "/force_password_change"); mockHttpServletRequest.setMethod(HttpMethod.POST.name()); SecurityContextHolder.getContext().setAuthentication(mockUaaAuthentication); when(mockUaaAuthentication.isAuthenticated()).thenReturn(true); @@ -115,24 +139,26 @@ void submitChangePassword() throws Exception { verify(mockHttpServletResponse, never()).sendRedirect(anyString()); } - @Test - void followCompletedRedirect() throws Exception { - mockHttpServletRequest.setPathInfo("/force_password_change_completed"); + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void followCompletedRedirect(RequestPathMode mode) throws Exception { + setRequestPath(mode, "/force_password_change_completed"); mockHttpServletRequest.setMethod(HttpMethod.POST.name()); SecurityContextHolder.getContext().setAuthentication(mockUaaAuthentication); when(mockUaaAuthentication.isAuthenticated()).thenReturn(true); setRequiresPasswordChange(mockHttpServletRequest, false); passwordChangeUiRequiredFilter.doFilterInternal(mockHttpServletRequest, mockHttpServletResponse, mockFilterChain); verify(mockFilterChain, never()).doFilter(any(), any()); - verify(mockHttpServletResponse, times(1)).sendRedirect("/"); + verify(mockHttpServletResponse, times(1)).sendRedirect(pathFor(mode, "/")); } - @Test - void followCompletedRedirectWithSavedRequest() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void followCompletedRedirectWithSavedRequest(RequestPathMode mode) throws Exception { String location = "/oauth/authorize"; SavedRequest savedRequest = getSavedRequest(location); when(mockRequestCache.getRequest(any(), any())).thenReturn(savedRequest); - mockHttpServletRequest.setPathInfo("/force_password_change_completed"); + setRequestPath(mode, "/force_password_change_completed"); mockHttpServletRequest.setMethod(HttpMethod.POST.name()); SecurityContextHolder.getContext().setAuthentication(mockUaaAuthentication); when(mockUaaAuthentication.isAuthenticated()).thenReturn(true); @@ -142,27 +168,30 @@ void followCompletedRedirectWithSavedRequest() throws Exception { verify(mockHttpServletResponse, times(1)).sendRedirect(location); } - @Test - void tryingAccessForcePasswordPage() throws Exception { - mockHttpServletRequest.setPathInfo("/force_password_change"); + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void tryingAccessForcePasswordPage(RequestPathMode mode) throws Exception { + setRequestPath(mode, "/force_password_change"); SecurityContextHolder.getContext().setAuthentication(mockUaaAuthentication); when(mockUaaAuthentication.isAuthenticated()).thenReturn(true); setRequiresPasswordChange(mockHttpServletRequest, false); passwordChangeUiRequiredFilter.doFilterInternal(mockHttpServletRequest, mockHttpServletResponse, mockFilterChain); verify(mockFilterChain, never()).doFilter(any(), any()); - verify(mockHttpServletResponse, times(1)).sendRedirect("/"); + verify(mockHttpServletResponse, times(1)).sendRedirect(pathFor(mode, "/")); } - @Test - void tryingAccessForcePasswordPageNotAuthenticated() throws Exception { - mockHttpServletRequest.setPathInfo("/force_password_change"); + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void tryingAccessForcePasswordPageNotAuthenticated(RequestPathMode mode) throws Exception { + setRequestPath(mode, "/force_password_change"); passwordChangeUiRequiredFilter.doFilterInternal(mockHttpServletRequest, mockHttpServletResponse, mockFilterChain); verify(mockFilterChain, times(1)).doFilter(same(mockHttpServletRequest), same(mockHttpServletResponse)); } - @Test - void completedButStillRequiresChange() throws Exception { - mockHttpServletRequest.setPathInfo("/force_password_change_completed"); + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void completedButStillRequiresChange(RequestPathMode mode) throws Exception { + setRequestPath(mode, "/force_password_change_completed"); mockHttpServletRequest.setMethod(HttpMethod.POST.name()); SecurityContextHolder.getContext().setAuthentication(mockUaaAuthentication); when(mockUaaAuthentication.isAuthenticated()).thenReturn(true); @@ -171,7 +200,7 @@ void completedButStillRequiresChange() throws Exception { passwordChangeUiRequiredFilter.doFilterInternal(mockHttpServletRequest, mockHttpServletResponse, mockFilterChain); verify(mockFilterChain, never()).doFilter(any(), any()); - verify(mockHttpServletResponse, times(1)).sendRedirect("/force_password_change"); + verify(mockHttpServletResponse, times(1)).sendRedirect(pathFor(mode, "/force_password_change")); } private SavedRequest getSavedRequest(final String redirectUrl) { 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 04efac27a4b..32c7aa10021 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 @@ -29,11 +29,14 @@ import org.cloudfoundry.identity.uaa.zone.BrandingInformation; import org.cloudfoundry.identity.uaa.zone.Consent; import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.util.ZoneRequestPathMode; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManagerImpl; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.ArgumentCaptor; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; @@ -51,7 +54,9 @@ 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.ResultActions; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; +import org.springframework.mock.web.MockHttpSession; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.client.HttpClientErrorException; import org.springframework.web.context.ConfigurableWebApplicationContext; @@ -86,6 +91,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrlPattern; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.xpath; @@ -137,8 +143,24 @@ void tearDown() { SecurityContextHolder.clearContext(); } - @Test - void acceptInvitationsPage() throws Exception { + private static MockHttpServletRequestBuilder requestGet(ZoneRequestPathMode mode, String pathSuffix) { + if (mode.redirectPrefix().isEmpty()) { + return get(pathSuffix); + } + return get("/z/{subdomain}" + pathSuffix, mode.getSubdomain()); + } + + private static MockHttpServletRequestBuilder requestPost(ZoneRequestPathMode mode, String pathSuffix) { + if (mode.redirectPrefix().isEmpty()) { + return post(pathSuffix); + } + return post("/z/{subdomain}" + pathSuffix, mode.getSubdomain()); + } + + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInvitationsPage(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); String zoneId = IdentityZoneHolder.get().getId(); Map codeData = new HashMap<>(); codeData.put("user_id", "user-id-001"); @@ -150,7 +172,7 @@ void acceptInvitationsPage() throws Exception { provider.setType(OriginKeys.UAA); when(providerProvisioning.retrieveByOrigin(any(), any())).thenReturn(provider); - mockMvc.perform(get("/invitations/accept").param("code", "code")) + mockMvc.perform(requestGet(mode, "/invitations/accept").param("code", "code")) .andExpect(status().isOk()) .andExpect(model().attribute("email", "user@example.com")) .andExpect(model().attribute("code", "code")) @@ -162,14 +184,16 @@ void acceptInvitationsPage() throws Exception { assertThat(principal.getName()).isEqualTo("user@example.com"); assertThat(principal.getEmail()).isEqualTo("user@example.com"); - mockMvc.perform(get("/invitations/accept").param("code", "code")) + mockMvc.perform(requestGet(mode, "/invitations/accept").param("code", "code")) .andExpect(status().isUnprocessableEntity()) .andExpect(view().name("invitations/accept_invite")) .andExpect(model().attribute("error_message_code", "code_expired")); } - @Test - void incorrectCodeIntent() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void incorrectCodeIntent(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); String zoneId = IdentityZoneHolder.get().getId(); Map codeData = new HashMap<>(); codeData.put("user_id", "user-id-001"); @@ -178,14 +202,14 @@ void incorrectCodeIntent() throws Exception { codeData.put("redirect_uri", "blah.test.com"); when(expiringCodeStore.retrieveCode("the_secret_code", zoneId)).thenReturn(new ExpiringCode("code", new Timestamp(System.currentTimeMillis()), JsonUtils.writeValueAsString(codeData), "incorrect-code-intent")); - MockHttpServletRequestBuilder get = get("/invitations/accept") - .param("code", "the_secret_code"); - - mockMvc.perform(get).andExpect(status().isUnprocessableEntity()); + mockMvc.perform(requestGet(mode, "/invitations/accept").param("code", "the_secret_code")) + .andExpect(status().isUnprocessableEntity()); } - @Test - void acceptInvitePage_for_unverifiedSamlUser() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInvitePage_for_unverifiedSamlUser(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); Map codeData = getInvitationsCode("test-saml"); String zoneId = IdentityZoneHolder.get().getId(); when(expiringCodeStore.peekCode("the_secret_code", zoneId)).thenReturn(createCode(codeData)); @@ -200,10 +224,8 @@ void acceptInvitePage_for_unverifiedSamlUser() throws Exception { provider.setConfig(definition); provider.setType(OriginKeys.SAML); when(providerProvisioning.retrieveByOrigin(eq("test-saml"), anyString())).thenReturn(provider); - MockHttpServletRequestBuilder get = get("/invitations/accept") - .param("code", "the_secret_code"); - MvcResult result = mockMvc.perform(get) + MvcResult result = mockMvc.perform(requestGet(mode, "/invitations/accept").param("code", "the_secret_code")) .andExpect(redirectedUrl("/saml2/authenticate/test-saml")) .andReturn(); @@ -211,8 +233,10 @@ void acceptInvitePage_for_unverifiedSamlUser() throws Exception { assertThat(result.getRequest().getSession().getAttribute("user_id")).isEqualTo("user-id-001"); } - @Test - void acceptInvitePage_for_unverifiedOIDCUser() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInvitePage_for_unverifiedOIDCUser(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); Map codeData = getInvitationsCode("test-oidc"); String zoneId = IdentityZoneHolder.get().getId(); when(expiringCodeStore.peekCode("the_secret_code", zoneId)).thenReturn(createCode(codeData)); @@ -226,10 +250,7 @@ void acceptInvitePage_for_unverifiedOIDCUser() throws Exception { when(providerProvisioning.retrieveByOrigin(eq("test-oidc"), anyString())).thenReturn(provider); when(externalOAuthProviderConfigurator.getIdpAuthenticationUrl(any(), any(), any())).thenReturn("http://example.com"); - MockHttpServletRequestBuilder get = get("/invitations/accept") - .param("code", "the_secret_code"); - - MvcResult result = mockMvc.perform(get) + MvcResult result = mockMvc.perform(requestGet(mode, "/invitations/accept").param("code", "the_secret_code")) .andExpect(redirectedUrl("http://example.com")) .andReturn(); @@ -237,8 +258,10 @@ void acceptInvitePage_for_unverifiedOIDCUser() throws Exception { assertThat(result.getRequest().getSession().getAttribute("user_id")).isEqualTo("user-id-001"); } - @Test - void acceptInvitePage_for_unverifiedLdapUser() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInvitePage_for_unverifiedLdapUser(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); Map codeData = getInvitationsCode(LDAP); String zoneId = IdentityZoneHolder.get().getId(); when(expiringCodeStore.peekCode("the_secret_code", zoneId)).thenReturn(createCode(codeData)); @@ -247,17 +270,17 @@ void acceptInvitePage_for_unverifiedLdapUser() throws Exception { provider.setType(LDAP); when(providerProvisioning.retrieveByOrigin(eq(LDAP), anyString())).thenReturn(provider); - MockHttpServletRequestBuilder get = get("/invitations/accept") - .param("code", "the_secret_code"); - - mockMvc.perform(get) + ResultActions actions = mockMvc.perform(requestGet(mode, "/invitations/accept").param("code", "the_secret_code")) .andExpect(view().name("invitations/accept_invite")) .andExpect(status().isOk()) .andExpect(content().string(containsString("Email: " + "user@example.com"))) .andExpect(content().string(containsString("Sign in with enterprise credentials:"))) .andExpect(content().string(containsString("username"))) - .andExpect(model().attribute("code", "the_secret_code")) - .andReturn(); + .andExpect(model().attribute("code", "the_secret_code")); + if (mode.redirectPrefix() != null && !mode.redirectPrefix().isEmpty()) { + // LDAP flow shows enterprise form, so form action is accept_enterprise.do + actions.andExpect(content().string(containsString("action=\"" + mode.redirectPrefix() + "/invitations/accept_enterprise.do\""))); + } } private Map getInvitationsCode(String origin) { @@ -270,8 +293,10 @@ private Map getInvitationsCode(String origin) { return codeData; } - @Test - void unverifiedLdapUser_acceptsInvite_byLoggingIn() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void unverifiedLdapUser_acceptsInvite_byLoggingIn(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); Map codeData = getInvitationsCode(LDAP); String zoneId = IdentityZoneHolder.get().getId(); when(expiringCodeStore.retrieveCode("the_secret_code", zoneId)).thenReturn(new ExpiringCode("code", new Timestamp(System.currentTimeMillis()), JsonUtils.writeValueAsString(codeData), null)); @@ -299,12 +324,13 @@ void unverifiedLdapUser_acceptsInvite_byLoggingIn() throws Exception { when(invitationsService.acceptInvitation(anyString(), anyString())).thenReturn(new AcceptedInvitation("blah.test.com", new ScimUser())); when(expiringCodeStore.generateCode(anyString(), any(), eq(null), eq(zoneId))).thenReturn(new ExpiringCode("code", new Timestamp(System.currentTimeMillis()), JsonUtils.writeValueAsString(codeData), null)); - mockMvc.perform(post("/invitations/accept_enterprise.do") + String expectedRedirect = mode.redirectPrefix() + "/login?success=invite_accepted&form_redirect_uri=blah.test.com"; + mockMvc.perform(requestPost(mode, "/invitations/accept_enterprise.do") .param("enterprise_username", "test-ldap-user") .param("enterprise_password", "password") .param("enterprise_email", "email") .param("code", "the_secret_code")) - .andExpect(redirectedUrl("/login?success=invite_accepted&form_redirect_uri=blah.test.com")) + .andExpect(redirectedUrl(expectedRedirect)) .andReturn(); verify(ldapActual).authenticate(any()); @@ -316,8 +342,10 @@ void unverifiedLdapUser_acceptsInvite_byLoggingIn() throws Exception { verify(ldapAuthenticationManager).authenticate(any()); } - @Test - void unverifiedLdapUser_acceptsInvite_byLoggingIn_bad_credentials() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void unverifiedLdapUser_acceptsInvite_byLoggingIn_bad_credentials(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); Map codeData = getInvitationsCode("ldap"); String zoneId = IdentityZoneHolder.get().getId(); when(expiringCodeStore.retrieveCode("the_secret_code", zoneId)).thenReturn(new ExpiringCode("code", new Timestamp(System.currentTimeMillis()), JsonUtils.writeValueAsString(codeData), null)); @@ -332,7 +360,7 @@ void unverifiedLdapUser_acceptsInvite_byLoggingIn_bad_credentials() throws Excep when(auth.isAuthenticated()).thenReturn(true); when(ldapActual.authenticate(any())).thenThrow(new BadCredentialsException("bad creds")); - mockMvc.perform(post("/invitations/accept_enterprise.do") + mockMvc.perform(requestPost(mode, "/invitations/accept_enterprise.do") .param("enterprise_username", "test-ldap-user") .param("enterprise_password", "password") .param("enterprise_email", "email") @@ -343,8 +371,10 @@ void unverifiedLdapUser_acceptsInvite_byLoggingIn_bad_credentials() throws Excep .andReturn(); } - @Test - void unverifiedLdapUser_acceptsInvite_byLoggingIn_whereEmailDoesNotMatchAuthenticatedEmail() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void unverifiedLdapUser_acceptsInvite_byLoggingIn_whereEmailDoesNotMatchAuthenticatedEmail(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); Map codeData = getInvitationsCode(LDAP); String zoneId = IdentityZoneHolder.get().getId(); when(expiringCodeStore.retrieveCode("the_secret_code", zoneId)).thenReturn(new ExpiringCode("code", new Timestamp(System.currentTimeMillis()), JsonUtils.writeValueAsString(codeData), null)); @@ -365,7 +395,7 @@ void unverifiedLdapUser_acceptsInvite_byLoggingIn_whereEmailDoesNotMatchAuthenti when(scimUserProvisioning.retrieve("user-id-001", zoneId)).thenReturn(invitedUser); when(expiringCodeStore.generateCode(anyString(), any(), eq(null), eq(zoneId))).thenReturn(new ExpiringCode("code", new Timestamp(System.currentTimeMillis()), JsonUtils.writeValueAsString(codeData), null)); - mockMvc.perform(post("/invitations/accept_enterprise.do") + mockMvc.perform(requestPost(mode, "/invitations/accept_enterprise.do") .param("enterprise_username", "test-ldap-user") .param("enterprise_password", "password") .param("enterprise_email", "email") @@ -382,8 +412,10 @@ void unverifiedLdapUser_acceptsInvite_byLoggingIn_whereEmailDoesNotMatchAuthenti verify(ldapActual).authenticate(any()); } - @Test - void acceptInvitePage_for_verifiedUser() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInvitePage_for_verifiedUser(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); String zoneId = IdentityZoneHolder.get().getId(); UaaUser user = new UaaUser("user@example.com", "", "user@example.com", "Given", "family"); user.modifyId("verified-user"); @@ -399,10 +431,8 @@ void acceptInvitePage_for_verifiedUser() throws Exception { IdentityProvider provider = new IdentityProvider<>(); provider.setType(OriginKeys.UAA); when(providerProvisioning.retrieveByOrigin(anyString(), anyString())).thenReturn(provider); - MockHttpServletRequestBuilder get = get("/invitations/accept") - .param("code", "the_secret_code"); - mockMvc.perform(get) + mockMvc.perform(requestGet(mode, "/invitations/accept").param("code", "the_secret_code")) .andExpect(redirectedUrl("blah.test.com")); } @@ -410,8 +440,10 @@ private ExpiringCode createCode(Map codeData) { return new ExpiringCode("code", new Timestamp(System.currentTimeMillis()), JsonUtils.writeValueAsString(codeData), INVITATION.name()); } - @Test - void incorrectGeneratedCodeIntent_for_verifiedUser() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void incorrectGeneratedCodeIntent_for_verifiedUser(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); String zoneId = IdentityZoneHolder.get().getId(); UaaUser user = new UaaUser("user@example.com", "", "user@example.com", "Given", "family"); user.modifyId("verified-user"); @@ -425,18 +457,17 @@ void incorrectGeneratedCodeIntent_for_verifiedUser() throws Exception { when(expiringCodeStore.generateCode(anyString(), any(), eq(null), eq(zoneId))).thenReturn(new ExpiringCode("code", new Timestamp(System.currentTimeMillis()), JsonUtils.writeValueAsString(codeData), "incorrect-code-intent")); when(invitationsService.acceptInvitation("incorrect-code-intent", "")).thenThrow(new HttpClientErrorException(BAD_REQUEST)); - MockHttpServletRequestBuilder get = get("/invitations/accept") - .param("code", "the_secret_code"); - - mockMvc.perform(get).andExpect(status().isUnprocessableEntity()); + mockMvc.perform(requestGet(mode, "/invitations/accept").param("code", "the_secret_code")) + .andExpect(status().isUnprocessableEntity()); } - @Test - void acceptInvitePageWithExpiredCode() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInvitePageWithExpiredCode(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); String zoneId = IdentityZoneHolder.get().getId(); when(expiringCodeStore.retrieveCode(anyString(), eq(zoneId))).thenReturn(null); - MockHttpServletRequestBuilder get = get("/invitations/accept").param("code", "the_secret_code"); - mockMvc.perform(get) + mockMvc.perform(requestGet(mode, "/invitations/accept").param("code", "the_secret_code")) .andExpect(status().isUnprocessableEntity()) .andExpect(model().attribute("error_message_code", "code_expired")) .andExpect(view().name("invitations/accept_invite")) @@ -445,9 +476,11 @@ void acceptInvitePageWithExpiredCode() throws Exception { assertThat(SecurityContextHolder.getContext().getAuthentication()).isNull(); } - @Test - void missing_code() throws Exception { - MockHttpServletRequestBuilder post = startAcceptInviteFlow("a", "a"); + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void missing_code(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + MockHttpServletRequestBuilder post = startAcceptInviteFlow(mode, "a", "a"); String zoneId = IdentityZoneHolder.get().getId(); when(expiringCodeStore.retrieveCode("thecode", zoneId)).thenReturn(null); @@ -464,9 +497,11 @@ void missing_code() throws Exception { verify(invitationsService, never()).acceptInvitation(anyString(), anyString()); } - @Test - void invalid_principal_id() throws Exception { - MockHttpServletRequestBuilder post = startAcceptInviteFlow("a", "a"); + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void invalid_principal_id(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + MockHttpServletRequestBuilder post = startAcceptInviteFlow(mode, "a", "a"); String zoneId = IdentityZoneHolder.get().getId(); Map codeData = getInvitationsCode(OriginKeys.UAA); @@ -486,10 +521,12 @@ void invalid_principal_id() throws Exception { verify(invitationsService, never()).acceptInvitation(anyString(), anyString()); } - @Test - void acceptInviteWithContraveningPassword() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInviteWithContraveningPassword(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); doThrow(new InvalidPasswordException(Arrays.asList("Msg 2c", "Msg 1c"))).when(passwordValidator).validate("a"); - MockHttpServletRequestBuilder post = startAcceptInviteFlow("a", "a"); + MockHttpServletRequestBuilder post = startAcceptInviteFlow(mode, "a", "a"); String zoneId = IdentityZoneHolder.get().getId(); Map codeData = getInvitationsCode(OriginKeys.UAA); @@ -504,21 +541,25 @@ void acceptInviteWithContraveningPassword() throws Exception { IdentityProvider identityProvider = new IdentityProvider<>(); identityProvider.setType(OriginKeys.UAA); when(providerProvisioning.retrieveByOrigin("uaa", "uaa")).thenReturn(identityProvider); + // Redirect may include query params (e.g. error_message, code) due to model on redirect + String redirectPattern = mode.redirectPrefix().isEmpty() ? "accept*" : mode.redirectPrefix() + "/invitations/accept*"; mockMvc.perform(post) .andExpect(status().isFound()) + .andExpect(redirectedUrlPattern(redirectPattern)) .andExpect(model().attribute("error_message", "Msg 1c Msg 2c")) - .andExpect(model().attribute("code", "thenewcode2")) - .andExpect(view().name("redirect:accept")); + .andExpect(model().attribute("code", "thenewcode2")); verify(expiringCodeStore).retrieveCode("thecode", zoneId); verify(expiringCodeStore, times(2)).generateCode(anyString(), any(), anyString(), eq(zoneId)); verify(invitationsService, never()).acceptInvitation(anyString(), anyString()); } - @Test - void acceptInvite() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInvite(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); ScimUser user = new ScimUser("user-id-001", "user@example.com", "fname", "lname"); user.setPrimaryEmail(user.getUserName()); - MockHttpServletRequestBuilder post = startAcceptInviteFlow("passw0rd", "passw0rd"); + MockHttpServletRequestBuilder post = startAcceptInviteFlow(mode, "passw0rd", "passw0rd"); String zoneId = IdentityZoneHolder.get().getId(); Map codeData = getInvitationsCode(OriginKeys.UAA); @@ -534,27 +575,34 @@ void acceptInvite() throws Exception { when(invitationsService.acceptInvitation(anyString(), eq("passw0rd"))).thenReturn(new AcceptedInvitation("/home", user)); + String expectedRedirect = mode.redirectPrefix() + "/login?success=invite_accepted"; mockMvc.perform(post) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/login?success=invite_accepted")).andReturn(); + .andExpect(redirectedUrl(expectedRedirect)).andReturn(); verify(invitationsService).acceptInvitation(anyString(), eq("passw0rd")); } private MockHttpServletRequestBuilder startAcceptInviteFlow(String password, String passwordConfirmation) { + return startAcceptInviteFlow(ZoneRequestPathMode.DEFAULT, password, passwordConfirmation); + } + + private MockHttpServletRequestBuilder startAcceptInviteFlow(ZoneRequestPathMode mode, 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); UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(uaaPrincipal, null, UaaAuthority.USER_AUTHORITIES); SecurityContextHolder.getContext().setAuthentication(token); - return post("/invitations/accept.do") + return requestPost(mode, "/invitations/accept.do") .param("code", "thecode") .param("password", password) .param("password_confirmation", passwordConfirmation); } - @Test - void acceptInviteWithValidClientRedirect() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInviteWithValidClientRedirect(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"); @@ -569,18 +617,24 @@ void acceptInviteWithValidClientRedirect() throws Exception { 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("valid.redirect.com", user)); - MockHttpServletRequestBuilder post = post("/invitations/accept.do") + MockHttpServletRequestBuilder post = requestPost(mode, "/invitations/accept.do") .param("password", "password") .param("password_confirmation", "password") .param("code", "thecode"); + String expectedRedirect = mode.redirectPrefix() + "/login?success=invite_accepted&form_redirect_uri=valid.redirect.com"; + if (mode.redirectPrefix().isEmpty()) { + expectedRedirect = "/login?success=invite_accepted&form_redirect_uri=valid.redirect.com"; + } mockMvc.perform(post) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/login?success=invite_accepted&form_redirect_uri=valid.redirect.com")); + .andExpect(redirectedUrl(expectedRedirect)); } - @Test - void acceptInviteWithInvalidClientRedirect() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInviteWithInvalidClientRedirect(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); UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(uaaPrincipal, null, UaaAuthority.USER_AUTHORITIES); @@ -596,18 +650,21 @@ void acceptInviteWithInvalidClientRedirect() throws Exception { when(invitationsService.acceptInvitation(anyString(), eq("password"))).thenReturn(new AcceptedInvitation("/home", user)); - MockHttpServletRequestBuilder post = post("/invitations/accept.do") + MockHttpServletRequestBuilder post = requestPost(mode, "/invitations/accept.do") .param("code", "thecode") .param("password", "password") .param("password_confirmation", "password"); + String expectedRedirect = mode.redirectPrefix().isEmpty() ? "/login?success=invite_accepted" : mode.redirectPrefix() + "/login?success=invite_accepted"; mockMvc.perform(post) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/login?success=invite_accepted")); + .andExpect(redirectedUrl(expectedRedirect)); } - @Test - void invalidCodeOnAcceptPost() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void invalidCodeOnAcceptPost(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); UsernamePasswordAuthenticationToken token = new UsernamePasswordAuthenticationToken(uaaPrincipal, null, UaaAuthority.USER_AUTHORITIES); @@ -620,7 +677,7 @@ void invalidCodeOnAcceptPost() throws Exception { doThrow(new HttpClientErrorException(BAD_REQUEST)).when(invitationsService).acceptInvitation(anyString(), anyString()); - MockHttpServletRequestBuilder post = post("/invitations/accept.do") + MockHttpServletRequestBuilder post = requestPost(mode, "/invitations/accept.do") .param("code", "thecode") .param("password", "password") .param("password_confirmation", "password"); @@ -631,9 +688,11 @@ void invalidCodeOnAcceptPost() throws Exception { .andExpect(view().name("invitations/accept_invite")); } - @Test - void acceptInviteWithoutMatchingPasswords() throws Exception { - MockHttpServletRequestBuilder post = startAcceptInviteFlow("a", "b"); + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInviteWithoutMatchingPasswords(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + MockHttpServletRequestBuilder post = startAcceptInviteFlow(mode, "a", "b"); String zoneId = IdentityZoneHolder.get().getId(); Map codeData = getInvitationsCode(OriginKeys.UAA); @@ -648,18 +707,22 @@ void acceptInviteWithoutMatchingPasswords() throws Exception { IdentityProvider identityProvider = new IdentityProvider<>(); identityProvider.setType(OriginKeys.UAA); when(providerProvisioning.retrieveByOrigin("uaa", "uaa")).thenReturn(identityProvider); + // Redirect may include query params (e.g. error_message_code, code) due to model on redirect + String redirectPattern = mode.redirectPrefix().isEmpty() ? "accept*" : mode.redirectPrefix() + "/invitations/accept*"; mockMvc.perform(post) .andExpect(status().isFound()) + .andExpect(redirectedUrlPattern(redirectPattern)) .andExpect(model().attribute("error_message_code", "form_error")) - .andExpect(model().attribute("code", "thenewcode2")) - .andExpect(view().name("redirect:accept")); + .andExpect(model().attribute("code", "thenewcode2")); verify(expiringCodeStore).retrieveCode("thecode", zoneId); verify(expiringCodeStore, times(2)).generateCode(anyString(), any(), anyString(), eq(zoneId)); verify(invitationsService, never()).acceptInvitation(anyString(), anyString()); } - @Test - void acceptInviteDisplaysConsentText() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInviteDisplaysConsentText(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); IdentityZone defaultZone = IdentityZoneHolder.get(); String zoneId = IdentityZoneHolder.get().getId(); BrandingInformation branding = new BrandingInformation(); @@ -676,16 +739,17 @@ void acceptInviteDisplaysConsentText() throws Exception { when(expiringCodeStore.peekCode("thecode", zoneId)) .thenReturn(expiringCode, null); - mockMvc.perform(get("/invitations/accept") - .param("code", "thecode")) + mockMvc.perform(requestGet(mode, "/invitations/accept").param("code", "thecode")) .andExpect(content().string(containsString("Jaskanwal"))); // cleanup changes to default zone defaultZone.getConfig().setBranding(null); } - @Test - void acceptInviteDoesNotDisplayConsentCheckboxWhenNotConfiguredForZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInviteDoesNotDisplayConsentCheckboxWhenNotConfiguredForZone(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); IdentityProvider identityProvider = new IdentityProvider<>(); identityProvider.setType(OriginKeys.UAA); when(providerProvisioning.retrieveByOrigin(anyString(), anyString())).thenReturn(identityProvider); @@ -699,13 +763,14 @@ void acceptInviteDoesNotDisplayConsentCheckboxWhenNotConfiguredForZone() throws when(expiringCodeStore.generateCode(anyString(), any(), eq(INVITATION.name()), eq(zoneId))) .thenReturn(expiringCode); - mockMvc.perform(get("/invitations/accept") - .param("code", "thecode")) + mockMvc.perform(requestGet(mode, "/invitations/accept").param("code", "thecode")) .andExpect(content().string(not(containsString("I agree")))); } - @Test - void acceptInviteDisplaysErrorMessageIfConsentNotChecked() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInviteDisplaysErrorMessageIfConsentNotChecked(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); IdentityZone defaultZone = IdentityZoneHolder.get(); String zoneId = IdentityZoneHolder.get().getId(); BrandingInformation branding = new BrandingInformation(); @@ -726,18 +791,22 @@ void acceptInviteDisplaysErrorMessageIfConsentNotChecked() throws Exception { when(expiringCodeStore.generateCode(anyString(), any(), eq(INVITATION.name()), eq(zoneId))) .thenReturn(expiringCode); - MvcResult mvcResult = mockMvc.perform(startAcceptInviteFlow("password", "password")) + MvcResult mvcResult = mockMvc.perform(startAcceptInviteFlow(mode, "password", "password")) .andReturn(); - mockMvc.perform(get("/invitations/" + mvcResult.getResponse().getHeader("Location"))) + String redirectLocation = mvcResult.getResponse().getRedirectedUrl(); + String followPath = (redirectLocation != null && redirectLocation.startsWith("/")) ? redirectLocation : "/invitations/" + redirectLocation; + mockMvc.perform(get(followPath).session((MockHttpSession) mvcResult.getRequest().getSession())) .andExpect(model().attribute("error_message_code", "missing_consent")); // cleanup changes to default zone defaultZone.getConfig().setBranding(null); } - @Test - void acceptInviteWorksWithConsentProvided() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void acceptInviteWorksWithConsentProvided(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); IdentityZone defaultZone = IdentityZoneHolder.get(); String zoneId = IdentityZoneHolder.get().getId(); BrandingInformation branding = new BrandingInformation(); @@ -759,7 +828,7 @@ void acceptInviteWorksWithConsentProvided() throws Exception { when(invitationsService.acceptInvitation(anyString(), anyString())) .thenReturn(new AcceptedInvitation(codeData.get("redirect_uri"), null)); - MvcResult mvcResult = mockMvc.perform(startAcceptInviteFlow("password", "password") + MvcResult mvcResult = mockMvc.perform(startAcceptInviteFlow(mode, "password", "password") .param("does_user_consent", "true")) .andReturn(); assertThat(mvcResult.getResponse().getHeader("Location")).contains(codeData.get("redirect_uri")); 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 ac033a34142..19850771907 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 @@ -13,10 +13,15 @@ import org.cloudfoundry.identity.uaa.user.UaaUser; import org.cloudfoundry.identity.uaa.user.UaaUserDatabase; import org.cloudfoundry.identity.uaa.util.beans.TestBuildInfo; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.MultitenancyFixture; +import org.junit.jupiter.api.AfterEach; 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.EnumSource; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; @@ -63,6 +68,13 @@ @SpringJUnitConfig(classes = ChangeEmailControllerTest.ContextConfiguration.class) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class ChangeEmailControllerTest { + /** Whether the test uses the default path or the zone path prefix {@code /z/{subdomain}/}. */ + enum RequestPathMode { + DEFAULT, + ZONE_PATH + } + + private static final String ZONE_PATH_SUBDOMAIN = "testsubdomain"; private MockMvc mockMvc; @Autowired @@ -75,14 +87,32 @@ class ChangeEmailControllerTest { @BeforeEach void setUp() { SecurityContextHolder.clearContext(); + IdentityZoneHolder.set(IdentityZone.getUaa()); mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); } - @Test - void changeEmailPage() throws Exception { + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + IdentityZoneHolder.set(IdentityZone.getUaa()); + } + + private String pathPrefixFor(RequestPathMode mode) { + if (mode == RequestPathMode.ZONE_PATH) { + IdentityZone zone = MultitenancyFixture.identityZone("test-zone-id", ZONE_PATH_SUBDOMAIN); + IdentityZoneHolder.set(zone); + return "/z/" + ZONE_PATH_SUBDOMAIN; + } + return ""; + } + + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void changeEmailPage(RequestPathMode mode) throws Exception { setupSecurityContext(); + String pathPrefix = pathPrefixFor(mode); - mockMvc.perform(get("/change_email").param("client_id", "client-id").param("redirect_uri", "http://example.com/redirect")) + mockMvc.perform(get(pathPrefix + "/change_email").param("client_id", "client-id").param("redirect_uri", "http://example.com/redirect")) .andExpect(status().isOk()) .andExpect(view().name("change_email")) .andExpect(model().attribute("email", "user@example.com")) @@ -92,11 +122,13 @@ void changeEmailPage() throws Exception { .andExpect(xpath("//*[@type='hidden' and @value='http://example.com/redirect']").exists()); } - @Test - void changeEmail() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void changeEmail(RequestPathMode mode) throws Exception { setupSecurityContext(); + String pathPrefix = pathPrefixFor(mode); - MockHttpServletRequestBuilder post = post("/change_email.do") + MockHttpServletRequestBuilder post = post(pathPrefix + "/change_email.do") .contentType(APPLICATION_FORM_URLENCODED) .param("newEmail", "new@example.com") .param("client_id", "app"); @@ -108,11 +140,13 @@ void changeEmail() throws Exception { verify(changeEmailService).beginEmailChange("user-id-001", "bob", "new@example.com", "app", null); } - @Test - void changeEmailWithClientIdAndRedirectUri() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void changeEmailWithClientIdAndRedirectUri(RequestPathMode mode) throws Exception { setupSecurityContext(); + String pathPrefix = pathPrefixFor(mode); - MockHttpServletRequestBuilder post = post("/change_email.do") + MockHttpServletRequestBuilder post = post(pathPrefix + "/change_email.do") .contentType(APPLICATION_FORM_URLENCODED) .param("newEmail", "new@example.com") .param("client_id", "app") @@ -125,13 +159,15 @@ void changeEmailWithClientIdAndRedirectUri() throws Exception { verify(changeEmailService).beginEmailChange("user-id-001", "bob", "new@example.com", "app", "http://redirect.uri"); } - @Test - void changeEmailWithUsernameConflict() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void changeEmailWithUsernameConflict(RequestPathMode mode) throws Exception { setupSecurityContext(); + String pathPrefix = pathPrefixFor(mode); doThrow(new UaaException("username already exists", 409)).when(changeEmailService).beginEmailChange("user-id-001", "bob", "new@example.com", "", null); - MockHttpServletRequestBuilder post = post("/change_email.do") + MockHttpServletRequestBuilder post = post(pathPrefix + "/change_email.do") .contentType(APPLICATION_FORM_URLENCODED) .param("newEmail", "new@example.com") .param("client_id", ""); @@ -143,8 +179,10 @@ void changeEmailWithUsernameConflict() throws Exception { .andExpect(model().attribute("email", "user@example.com")); } - @Test - void nonUAAOriginUser() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void nonUAAOriginUser(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); Authentication authentication = new UaaAuthentication( new UaaPrincipal("user-id-001", "bob", "user@example.com", "NON-UAA-origin ", null, IdentityZoneHolder.get().getId()), Collections.singletonList(UaaAuthority.UAA_USER), @@ -152,7 +190,7 @@ void nonUAAOriginUser() throws Exception { ); SecurityContextHolder.getContext().setAuthentication(authentication); - MockHttpServletRequestBuilder post = post("/change_email.do") + MockHttpServletRequestBuilder post = post(pathPrefix + "/change_email.do") .contentType(APPLICATION_FORM_URLENCODED) .param("newEmail", "new@example.com") .param("client_id", "app"); @@ -164,11 +202,13 @@ void nonUAAOriginUser() throws Exception { Mockito.verifyNoInteractions(changeEmailService); } - @Test - void invalidEmail() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void invalidEmail(RequestPathMode mode) throws Exception { setupSecurityContext(); + String pathPrefix = pathPrefixFor(mode); - MockHttpServletRequestBuilder post = post("/change_email.do") + MockHttpServletRequestBuilder post = post(pathPrefix + "/change_email.do") .contentType(APPLICATION_FORM_URLENCODED) .param("newEmail", "invalid") .param("client_id", "app"); @@ -180,8 +220,10 @@ void invalidEmail() throws Exception { .andExpect(model().attribute("email", "user@example.com")); } - @Test - void verifyEmail() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void verifyEmail(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); 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); @@ -191,7 +233,7 @@ void verifyEmail() throws Exception { response.put("email", "new@example.com"); when(changeEmailService.completeVerification("the_secret_code")).thenReturn(response); - MockHttpServletRequestBuilder get = get("/verify_email") + MockHttpServletRequestBuilder get = get(pathPrefix + "/verify_email") .contentType(APPLICATION_FORM_URLENCODED) .param("code", "the_secret_code"); @@ -200,8 +242,10 @@ void verifyEmail() throws Exception { .andExpect(redirectedUrl("login?success=change_email_success")); } - @Test - void verifyEmailWhenAuthenticated() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void verifyEmailWhenAuthenticated(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); 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); @@ -213,7 +257,7 @@ void verifyEmailWhenAuthenticated() throws Exception { setupSecurityContext(); - MockHttpServletRequestBuilder get = get("/verify_email") + MockHttpServletRequestBuilder get = get(pathPrefix + "/verify_email") .contentType(APPLICATION_FORM_URLENCODED) .param("code", "the_secret_code"); @@ -227,8 +271,10 @@ void verifyEmailWhenAuthenticated() throws Exception { assertThat(principal.getEmail()).isEqualTo("new@example.com"); } - @Test - void verifyEmailWithRedirectUrl() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void verifyEmailWithRedirectUrl(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); 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); @@ -239,7 +285,7 @@ void verifyEmailWithRedirectUrl() throws Exception { response.put("redirect_url", "//example.com/callback"); when(changeEmailService.completeVerification("the_secret_code")).thenReturn(response); - MockHttpServletRequestBuilder get = get("/verify_email") + MockHttpServletRequestBuilder get = get(pathPrefix + "/verify_email") .contentType(APPLICATION_FORM_URLENCODED) .param("code", "the_secret_code"); @@ -248,8 +294,10 @@ void verifyEmailWithRedirectUrl() throws Exception { .andExpect(redirectedUrl("login?success=change_email_success&form_redirect_uri=//example.com/callback")); } - @Test - void verifyEmailWithRedirectWhenAuthenticated() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void verifyEmailWithRedirectWhenAuthenticated(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); 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); @@ -262,7 +310,7 @@ void verifyEmailWithRedirectWhenAuthenticated() throws Exception { setupSecurityContext(); - MockHttpServletRequestBuilder get = get("/verify_email") + MockHttpServletRequestBuilder get = get(pathPrefix + "/verify_email") .contentType(APPLICATION_FORM_URLENCODED) .param("code", "the_secret_code"); @@ -277,8 +325,10 @@ void verifyEmailWithRedirectWhenAuthenticated() throws Exception { } - @Test - void verifyEmailWithInvalidCode() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void verifyEmailWithInvalidCode(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); Authentication authentication = new AnonymousAuthenticationToken( "anon", "anonymousUser", @@ -287,7 +337,7 @@ void verifyEmailWithInvalidCode() throws Exception { SecurityContextHolder.getContext().setAuthentication(authentication); when(changeEmailService.completeVerification("the_secret_code")).thenThrow(new UaaException("Bad Request", 400)); - MockHttpServletRequestBuilder get = get("/verify_email") + MockHttpServletRequestBuilder get = get(pathPrefix + "/verify_email") .contentType(APPLICATION_FORM_URLENCODED) .param("code", "the_secret_code"); @@ -297,13 +347,17 @@ void verifyEmailWithInvalidCode() throws Exception { setupSecurityContext(); - mockMvc.perform(get) + mockMvc.perform(get(pathPrefix + "/verify_email") + .contentType(APPLICATION_FORM_URLENCODED) + .param("code", "the_secret_code")) .andExpect(status().isFound()) .andExpect(redirectedUrl("profile?error_message_code=email_change.invalid_code")); } - @Test - void verifyEmailWhenAutheticatedAsOtherUser() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void verifyEmailWhenAutheticatedAsOtherUser(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); UaaUser user = new UaaUser("user-id-002", "new2@example.com", "password", "new2@example.com", Collections.emptyList(), "name", "name", null, null, OriginKeys.UAA, null, true, IdentityZoneHolder.get().getId(), "user-id-002", null); when(uaaUserDatabase.retrieveUserById(anyString())).thenReturn(user); @@ -315,7 +369,7 @@ void verifyEmailWhenAutheticatedAsOtherUser() throws Exception { setupSecurityContext(); - MockHttpServletRequestBuilder get = get("/verify_email") + MockHttpServletRequestBuilder get = get(pathPrefix + "/verify_email") .contentType(APPLICATION_FORM_URLENCODED) .param("code", "the_secret_code"); @@ -329,8 +383,10 @@ void verifyEmailWhenAutheticatedAsOtherUser() throws Exception { assertThat(principal.getEmail()).isEqualTo("user@example.com"); } - @Test - void verifyEmailDoesNotDeleteAuthenticationMethods() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void verifyEmailDoesNotDeleteAuthenticationMethods(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); 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); @@ -344,7 +400,7 @@ void verifyEmailDoesNotDeleteAuthenticationMethods() throws Exception { UaaAuthentication authentication = (UaaAuthentication) SecurityContextHolder.getContext().getAuthentication(); authentication.setAuthenticationMethods(Collections.singleton("pwd")); - MockHttpServletRequestBuilder get = get("/verify_email") + MockHttpServletRequestBuilder get = get(pathPrefix + "/verify_email") .contentType(APPLICATION_FORM_URLENCODED) .param("code", "the_secret_code"); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/login/ChangePasswordControllerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/login/ChangePasswordControllerTest.java index cc1686599dd..44665d3d446 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/login/ChangePasswordControllerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/login/ChangePasswordControllerTest.java @@ -9,10 +9,14 @@ import org.cloudfoundry.identity.uaa.scim.exception.InvalidPasswordException; import org.cloudfoundry.identity.uaa.user.UaaAuthority; import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.MultitenancyFixture; import org.junit.jupiter.api.AfterEach; 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.EnumSource; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; @@ -42,6 +46,14 @@ @ExtendWith(PollutionPreventionExtension.class) class ChangePasswordControllerTest { + /** Whether the test uses the default path or the zone path prefix {@code /z/{subdomain}/}. */ + enum RequestPathMode { + DEFAULT, + ZONE_PATH + } + + private static final String ZONE_PATH_SUBDOMAIN = "testsubdomain"; + private MockMvc mockMvc; private ChangePasswordService changePasswordService; private UaaAuthentication authentication; @@ -50,6 +62,7 @@ class ChangePasswordControllerTest { @BeforeEach void setUp() { SecurityContextHolder.clearContext(); + IdentityZoneHolder.set(IdentityZone.getUaa()); changePasswordService = mock(ChangePasswordService.class); ChangePasswordController controller = new ChangePasswordController(changePasswordService); @@ -72,18 +85,32 @@ void setUp() { @AfterEach void tearDown() { SecurityContextHolder.clearContext(); + IdentityZoneHolder.set(IdentityZone.getUaa()); + } + + private String pathPrefixFor(RequestPathMode mode) { + if (mode == RequestPathMode.ZONE_PATH) { + IdentityZone zone = MultitenancyFixture.identityZone("test-zone-id", ZONE_PATH_SUBDOMAIN); + IdentityZoneHolder.set(zone); + return "/z/" + ZONE_PATH_SUBDOMAIN; + } + return ""; } - @Test - void changePasswordPage_RendersChangePasswordPage() throws Exception { - mockMvc.perform(get("/change_password")) + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void changePasswordPage_RendersChangePasswordPage(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); + mockMvc.perform(get(pathPrefix + "/change_password")) .andExpect(status().isOk()) .andExpect(view().name("change_password")); } - @Test - void changePassword_Returns302Found_SuccessfullyChangedPassword() throws Exception { - MockHttpServletRequestBuilder post = createRequest("secret", "new secret", "new secret"); + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void changePassword_Returns302Found_SuccessfullyChangedPassword(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); + MockHttpServletRequestBuilder post = createRequest(pathPrefix, "secret", "new secret", "new secret"); mockMvc.perform(post) .andExpect(status().isFound()) .andExpect(redirectedUrl("profile")); @@ -94,9 +121,11 @@ void changePassword_Returns302Found_SuccessfullyChangedPassword() throws Excepti assertThat(afterAuth).isSameAs(authentication); } - @Test - void changePassword_ConfirmationPasswordDoesNotMatch() throws Exception { - MockHttpServletRequestBuilder post = createRequest("secret", "new secret", "newsecret"); + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void changePassword_ConfirmationPasswordDoesNotMatch(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); + MockHttpServletRequestBuilder post = createRequest(pathPrefix, "secret", "new secret", "newsecret"); mockMvc.perform(post) .andExpect(status().isUnprocessableEntity()) .andExpect(view().name("change_password")) @@ -105,47 +134,53 @@ void changePassword_ConfirmationPasswordDoesNotMatch() throws Exception { verifyNoInteractions(changePasswordService); } - @Test - void changePassword_PasswordPolicyViolationReported() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void changePassword_PasswordPolicyViolationReported(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); doThrow(new InvalidPasswordException(asList("Msg 2b", "Msg 1b"))).when(changePasswordService).changePassword( "bob", "secret", "new secret"); - MockHttpServletRequestBuilder post = createRequest("secret", "new secret", "new secret"); + MockHttpServletRequestBuilder post = createRequest(pathPrefix, "secret", "new secret", "new secret"); mockMvc.perform(post) .andExpect(status().isUnprocessableEntity()) .andExpect(view().name("change_password")) .andExpect(model().attribute("message", "Msg 1b Msg 2b")); } - @Test - void changePassword_Returns401Unauthorized_WrongCurrentPassword() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void changePassword_Returns401Unauthorized_WrongCurrentPassword(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); doThrow(new BadCredentialsException("401 Unauthorized")).when(changePasswordService).changePassword("bob", "wrong", "new secret"); - MockHttpServletRequestBuilder post = createRequest("wrong", "new secret", "new secret"); + MockHttpServletRequestBuilder post = createRequest(pathPrefix, "wrong", "new secret", "new secret"); mockMvc.perform(post) .andExpect(status().isUnprocessableEntity()) .andExpect(view().name("change_password")) .andExpect(model().attribute("message_code", "unauthorized")); } - @Test - void changePassword_PasswordNoveltyViolationReported_NewPasswordSameAsCurrentPassword() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void changePassword_PasswordNoveltyViolationReported_NewPasswordSameAsCurrentPassword(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); doThrow(new InvalidPasswordException("Your new password cannot be the same as the old password.")).when( changePasswordService).changePassword("bob", "secret", "new secret"); - MockHttpServletRequestBuilder post = createRequest("secret", "new secret", "new secret"); + MockHttpServletRequestBuilder post = createRequest(pathPrefix, "secret", "new secret", "new secret"); mockMvc.perform(post) .andExpect(status().isUnprocessableEntity()) .andExpect(view().name("change_password")) .andExpect(model().attribute("message", "Your new password cannot be the same as the old password.")); } - private MockHttpServletRequestBuilder createRequest(String currentPassword, String newPassword, String confirmPassword) { - return post("/change_password.do") + private MockHttpServletRequestBuilder createRequest(String pathPrefix, String currentPassword, String newPassword, String confirmPassword) { + return post(pathPrefix + "/change_password.do") .contentType(APPLICATION_FORM_URLENCODED) .param("current_password", currentPassword) .param("new_password", newPassword) diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeControllerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeControllerTest.java index 81c16984c5f..5a435006be9 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeControllerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeControllerTest.java @@ -5,11 +5,14 @@ import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication; import org.cloudfoundry.identity.uaa.authentication.UaaPrincipal; import org.cloudfoundry.identity.uaa.extensions.PollutionPreventionExtension; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.cloudfoundry.identity.uaa.zone.beans.IdentityZoneManagerImpl; +import org.junit.jupiter.api.AfterEach; 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.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.core.io.support.ResourcePropertySource; import org.springframework.security.core.context.SecurityContextHolder; @@ -33,10 +36,22 @@ @SpringJUnitConfig(classes = {ThymeleafAdditional.class, ThymeleafConfig.class}) class ForcePasswordChangeControllerTest extends TestClassNullifier { + /** Whether the test uses the default path or the zone path prefix {@code /z/{subdomain}/}. */ + enum RequestPathMode { + DEFAULT, + ZONE_PATH + } + + private static final String ZONE_PATH_SUBDOMAIN = "testsubdomain"; + private MockMvc mockMvc; private ResourcePropertySource mockResourcePropertySource; private UaaAuthentication mockUaaAuthentication; + private static String pathPrefixFor(RequestPathMode mode) { + return mode == RequestPathMode.ZONE_PATH ? "/z/" + ZONE_PATH_SUBDOMAIN : ""; + } + @BeforeEach void beforeEach() { mockResourcePropertySource = mock(ResourcePropertySource.class); @@ -57,53 +72,80 @@ void beforeEach() { SecurityContextHolder.getContext().setAuthentication(mockUaaAuthentication); } + @AfterEach + void afterEach() { + SecurityContextHolder.clearContext(); + IdentityZoneHolder.set(IdentityZone.getUaa()); + } + @ParameterizedTest - @ValueSource(strings = {"/force_password_change", "/force_password_change/"}) - void forcePasswordChange(String url) throws Exception { - mockMvc.perform(get(url)) + @EnumSource(RequestPathMode.class) + void forcePasswordChange(RequestPathMode mode) throws Exception { + String prefix = pathPrefixFor(mode); + mockMvc.perform(get(prefix + "/force_password_change")) + .andExpect(status().isOk()) + .andExpect(view().name("force_password_change")) + .andExpect(model().attribute("email", "mail")); + mockMvc.perform(get(prefix + "/force_password_change/")) .andExpect(status().isOk()) .andExpect(view().name("force_password_change")) .andExpect(model().attribute("email", "mail")); } @ParameterizedTest - @ValueSource(strings = {"/force_password_change", "/force_password_change/"}) - void redirectToLogInIfPasswordIsNotExpired(String url) throws Exception { - mockMvc.perform(get(url)) + @EnumSource(RequestPathMode.class) + void redirectToLogInIfPasswordIsNotExpired(RequestPathMode mode) throws Exception { + String prefix = pathPrefixFor(mode); + mockMvc.perform(get(prefix + "/force_password_change")) + .andExpect(status().isOk()) + .andExpect(view().name("force_password_change")); + mockMvc.perform(get(prefix + "/force_password_change/")) .andExpect(status().isOk()) .andExpect(view().name("force_password_change")); } @ParameterizedTest - @ValueSource(strings = {"/uaa/force_password_change", "/uaa/force_password_change/"}) - void handleForcePasswordChange(String url) throws Exception { - mockMvc.perform( - post(url) - .param("password", "pwd") - .param("password_confirmation", "pwd") - .contextPath("/uaa")) - .andExpect(status().isFound()) - .andExpect(redirectedUrl("/uaa/force_password_change_completed")); + @EnumSource(RequestPathMode.class) + void handleForcePasswordChange(RequestPathMode mode) throws Exception { + String prefix = pathPrefixFor(mode); + if (mode == RequestPathMode.DEFAULT) { + mockMvc.perform( + post("/uaa/force_password_change") + .param("password", "pwd") + .param("password_confirmation", "pwd") + .contextPath("/uaa")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/uaa/force_password_change_completed")); + } else { + mockMvc.perform( + post(prefix + "/force_password_change") + .param("password", "pwd") + .param("password_confirmation", "pwd")) + .andExpect(status().isFound()) + .andExpect(redirectedUrl(prefix + "/force_password_change_completed")); + } verify(mockUaaAuthentication, times(1)).setAuthenticatedTime(anyLong()); } @ParameterizedTest - @ValueSource(strings = {"/force_password_change", "/force_password_change/"}) - void handleForcePasswordChangeWithRedirect(String url) throws Exception { + @EnumSource(RequestPathMode.class) + void handleForcePasswordChangeWithRedirect(RequestPathMode mode) throws Exception { + String prefix = pathPrefixFor(mode); mockMvc.perform( - post(url) + post(prefix + "/force_password_change") .param("password", "pwd") .param("password_confirmation", "pwd")) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/force_password_change_completed")); + .andExpect(redirectedUrl(prefix + "/force_password_change_completed")); } @ParameterizedTest - @ValueSource(strings = {"/force_password_change", "/force_password_change/"}) - void passwordAndConfirmAreDifferent(String url) throws Exception { + @EnumSource(RequestPathMode.class) + void passwordAndConfirmAreDifferent(RequestPathMode mode) throws Exception { + String prefix = pathPrefixFor(mode); when(mockResourcePropertySource.getProperty("force_password_change.form_error")).thenReturn("Passwords must match and not be empty."); mockMvc.perform( - post(url) + post(prefix + "/force_password_change") .param("password", "pwd") .param("password_confirmation", "nopwd")) .andExpect(status().isUnprocessableEntity()); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/login/HomeControllerViewTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/login/HomeControllerViewTests.java index 70297c299f4..d873832ccfe 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/login/HomeControllerViewTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/login/HomeControllerViewTests.java @@ -2,6 +2,7 @@ import jakarta.annotation.PostConstruct; import jakarta.servlet.RequestDispatcher; +import jakarta.servlet.http.HttpServletRequest; import org.cloudfoundry.identity.uaa.TestClassNullifier; import org.cloudfoundry.identity.uaa.client.ClientMetadata; import org.cloudfoundry.identity.uaa.client.JdbcClientMetadataProvisioning; @@ -9,6 +10,7 @@ import org.cloudfoundry.identity.uaa.home.BuildInfo; import org.cloudfoundry.identity.uaa.home.HomeController; import org.cloudfoundry.identity.uaa.provider.saml.MetadataProviderNotFoundException; +import org.cloudfoundry.identity.uaa.util.ZoneRequestPathMode; import org.cloudfoundry.identity.uaa.util.beans.TestBuildInfo; import org.cloudfoundry.identity.uaa.zone.BrandingInformation; import org.cloudfoundry.identity.uaa.zone.IdentityZone; @@ -18,13 +20,15 @@ import org.cloudfoundry.identity.uaa.zone.MultitenancyFixture; import org.junit.jupiter.api.AfterEach; 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.ValueSource; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +import org.springframework.context.support.ResourceBundleMessageSource; import org.springframework.security.authentication.InternalAuthenticationServiceException; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.saml2.Saml2Exception; @@ -35,6 +39,7 @@ import org.springframework.test.context.web.WebAppConfiguration; import org.springframework.test.util.ReflectionTestUtils; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.ui.Model; import org.springframework.web.context.WebApplicationContext; @@ -47,6 +52,7 @@ import java.net.URI; import java.util.ArrayList; import java.util.List; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; import static org.hamcrest.CoreMatchers.containsString; @@ -54,18 +60,55 @@ import static org.mockito.Mockito.mock; import static org.mockito.Mockito.when; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.xpath; +/** + * View tests for HomeController. Each test is parameterized by {@link ZoneRequestPathMode} so that + * both default paths ({@code /home}, {@code /error}, ...) and zone paths ({@code /z/{subdomain}/home}, ...) are covered. + */ @ExtendWith(PollutionPreventionExtension.class) @WebAppConfiguration @SpringJUnitConfig(classes = HomeControllerViewTests.ContextConfiguration.class) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class HomeControllerViewTests extends TestClassNullifier { - private static final String base64EncodedImg = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAXRQTFRFAAAAOjo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ozk4Ojo6Ojk5NkZMFp/PFqDPNkVKOjo6Ojk5MFhnEq3nEqvjEqzjEbDpMFdlOjo5Ojo6Ojo6Ozg2GZ3TFqXeFKfgF6DVOjo6Ozg2G5jPGZ7ZGKHbGZvROjo6Ojo5M1FfG5vYGp3aM1BdOjo6Ojo6Ojk4KHWeH5PSHpTSKHSbOjk4Ojo6Ojs8IY/QIY/QOjs7Ojo6Ojo6Ozc0JYfJJYjKOzYyOjo5Ozc0KX7AKH/AOzUxOjo5Ojo6Ojo6Ojo6Ojs8LHi6LHi6Ojs7Ojo6Ojo6Ojo6Ojo6Ojo6L3K5L3S7LnW8LnS7Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6NlFvMmWeMmaeNVJwOjo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojk5Ojk4Ojk4Ojk5Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6FaXeFabfGZ/aGKDaHJnVG5rW////xZzURgAAAHV0Uk5TAAACPaXbAVzltTa4MykoM5HlPY/k5Iw85QnBs2D7+lzAtWD7+lyO6EKem0Ey47Mx2dYvtVZVop5Q2i4qlZAnBiGemh0EDXuddqypcHkShPJwYufmX2rvihSJ+qxlg4JiqP2HPtnW1NjZ2svRVAglGTi91RAXr3/WIQAAAAFiS0dEe0/StfwAAAAJcEhZcwAAAEgAAABIAEbJaz4AAADVSURBVBjTY2BgYGBkYmZhZWVhZmJkAANGNnYODk5ODg52NrAIIyMXBzcPLx8/NwcXIyNYQEBQSFhEVExcQgAiICklLSNbWiYnLy0lCRFQUFRSLq9QUVVUgAgwqqlraFZWaWmrqzFCTNXR1dM3MDQy1tWB2MvIaMJqamZuYWnCCHeIlbWNrZ0VG5QPFLF3cHRydoErcHVz9/D08nb3kYSY6evnHxAYFBwSGhYeAbbWNzIqOiY2Lj4hMckVoiQ5JTUtPSMzKzsH6pfcvPyCwqKc4pJcoAAA2pghnaBVZ0kAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTUtMTAtMDhUMTI6NDg6MDkrMDA6MDDsQS6eAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE1LTEwLTA4VDEyOjQ4OjA5KzAwOjAwnRyWIgAAAEZ0RVh0c29mdHdhcmUASW1hZ2VNYWdpY2sgNi43LjgtOSAyMDE0LTA1LTEyIFExNiBodHRwOi8vd3d3LmltYWdlbWFnaWNrLm9yZ9yG7QAAAAAYdEVYdFRodW1iOjpEb2N1bWVudDo6UGFnZXMAMaf/uy8AAAAYdEVYdFRodW1iOjpJbWFnZTo6aGVpZ2h0ADE5Mg8AcoUAAAAXdEVYdFRodW1iOjpJbWFnZTo6V2lkdGgAMTky06whCAAAABl0RVh0VGh1bWI6Ok1pbWV0eXBlAGltYWdlL3BuZz+yVk4AAAAXdEVYdFRodW1iOjpNVGltZQAxNDQ0MzA4NDg5qdC9PQAAAA90RVh0VGh1bWI6OlNpemUAMEJClKI+7AAAAFZ0RVh0VGh1bWI6OlVSSQBmaWxlOi8vL21udGxvZy9mYXZpY29ucy8yMDE1LTEwLTA4LzJiMjljNmYwZWRhZWUzM2ViNmM1Mzg4ODMxMjg3OTg1Lmljby5wbmdoJKG+AAAAAElFTkSuQmCC"; + // --- Zone path helpers (single place for all mode logic) --- + + /** GET request for the given path; uses /z/{subdomain} when mode is ZONE_PATH so handler mapping matches. */ + private static MockHttpServletRequestBuilder request(ZoneRequestPathMode mode, String pathSuffix) { + if (mode.redirectPrefix().isEmpty()) { + return get(pathSuffix); + } + return get("/z/{subdomain}" + pathSuffix, mode.getSubdomain()); + } + + /** Expected href string for nav/links: {@code href="/profile"} or {@code href="/z/test-zone/profile"}. */ + private static String expectedHref(ZoneRequestPathMode mode, String path) { + String prefix = mode.redirectPrefix(); + return prefix.isEmpty() ? "href=\"" + path + "\"" : "href=\"" + prefix + path + "\""; + } + + /** Expected resource path in response (e.g. script src, img src): always {@code /resources/...} (resources are not under /z/{subdomain}/). */ + private static String expectedResourcePath(ZoneRequestPathMode mode, String path) { + return path; + } + + /** Ensures current zone has test branding so error-page assertions (footer, logo) pass for ZONE_PATH. */ + private void applyTestBrandingToCurrentZone() { + IdentityZoneConfiguration newConfiguration = new IdentityZoneConfiguration(); + newConfiguration.setBranding(new BrandingInformation()); + newConfiguration.getBranding().setFooterLegalText(customFooterText); + newConfiguration.getBranding().setProductLogo(base64ProductLogo); + IdentityZoneHolder.get().setConfig(newConfiguration); + } + + // --- Constants and fields --- + + private static final String base64EncodedImg = "iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAABGdBTUEAALGPC/xhBQAAAAFzUkdCAK7OHOkAAAAgY0hSTQAAeiYAAICEAAD6AAAAgOgAAHUwAADqYAAAOpgAABdwnLpRPAAAAXRQTFRFAAAAOjo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ozk4Ojo6Ojk5NkZMFp/PFqDPNkVKOjo6Ojk5MFhnEq3nEqvjEqzjEbDpMFdlOjo5Ojo6Ojo6Ozg2GZ3TFqXeFKfgF6DVOjo6Ozg2G5jPGZ7ZGKHbGZvROjo6Ojo5M1FfG5vYGp3aM1BdOjo6Ojo6Ojk4KHWeH5PSHpTSKHSbOjk4Ojo6Ojs8IY/QIY/QOjs7Ojo6Ojo6Ozc0JYfJJYjKOzYyOjo5Ozc0KX7AKH/AOzUxOjo5Ojo6Ojo6Ojo6Ojs8LHi6LHi6Ojs7Ojo6Ojo6Ojo6Ojo6Ojo6L3K5L3S7LnW8LnS7Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6NlFvMmWeMmaeNVJwOjo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojk5Ojk4Ojk4Ojk5Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6Ojo6FaXeFabfGZ/aGKDaHJnVG5rW////xZzURgAAAHV0Uk5TAAACPaXbAVzltTa4MykoM5HlPY/k5Iw85QnBs2D7+lzAtWD7+lyO6EKem0Ey47Mx2dYvtVZVop5Q2i4qlZAnBiGemh0EDXuddqypcHkShPJwYufmX2rvihSJ+qxlg4JiqP2HPtnW1NjZ2svRVAglGTi91RAXr3/WIQAAAAFiS0dEe0/StfwAAAAJcEhZcwAAAEgAAABIAEbJaz4AAADVSURBVBjTY2BgYGBkYmZhZWVhZmJkAANGNnYODk5ODg52NrAIIyMXBzcPLx8/NwcXIyNYQEBQSFhEVExcQgAiICklLSNbWiYnLy0lCRFQUFRSLq9QUVVUgAgwqqlraFZWaWmrqzFCTNXR1dM3MDQy1tWB2MvIaMJqamZuYWnCCHeIlbWNrZ0VG5QPFLF3cHRydoErcHVz9/D08nb3kYSY6evnHxAYFBwSGhYeAbbWNzIqOiY2Lj4hMckVoiQ5JTUtPSMzKzsH6pfcvPyCwqKc4pJcoAAA2pghnaBVZ0kAAAAldEVYdGRhdGU6Y3JlYXRlADIwMTUtMTAtMDhUMTI6NDg6MDkrMDA6MDDsQS6eAAAAJXRFWHRkYXRlOm1vZGlmeQAyMDE1LTEwLTA4VDEyOjQ4OjA5KzAwOjAwnRyWIgAAAEZ0RVh0c29mdHdhcmUASW1hZ2VNYWdpY2sgNi43LjgtOSAyMDE0LTA1LTEyIFExNiBodHRwOi8vd3d3LmltYWdlbWFnaWNrLm9yZ9yG7QAAAAAYdEVYdFRodW1iOjpEb2N1bWVudDo6UGFnZXMAMaf/uy8AAAAYdEVYdFRodW1iOjpJbWFnZTo6aGVpZ2h0ADE5Mg8AcoUAAAAXdEVYdFRodW1iOjpJbWFnZTo6V2lkdGgAMTky06whCAAAABl0RVh0VGh1bWI6Ok1pbWV0eXBlAGltYWdlL3BuZz+yVk4AAAAXdEVYdFRodW1iOjpNVGltZQAxNDQ0MzA4NDg5qdC9PQAAAA90RVh0VGh1bWI6OlNpemUAMEJClKI+7AAAAFZ0RVh0VGh1bWI6OlVSSQBmaWxlOi8vL21udGxvZy9mYXZpY29ucy8yMDE1LTEwLTA4LzJiMjljNmYwZWRhZWUzM2ViNmM1Mzg4ODMxMjg3OTg1Lmljby5wbmdoJKG+AAAAAElFTkSuQmCC"; private static final String customFooterText = "custom footer text"; private static final String base64ProductLogo = "D44vIpdmc0ne8IPLEbYD2vvLpu71spjxwaLYYdj39gTYa9kyWs"; @Autowired @@ -82,14 +125,8 @@ class HomeControllerViewTests extends TestClassNullifier { void beforeEach() { SecurityContextHolder.clearContext(); IdentityZoneHolder.clear(); - mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext) - .build(); + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); originalConfiguration = IdentityZoneHolder.get().getConfig(); - IdentityZoneConfiguration newConfiguration = new IdentityZoneConfiguration(); - newConfiguration.setBranding(new BrandingInformation()); - newConfiguration.getBranding().setFooterLegalText(customFooterText); - newConfiguration.getBranding().setProductLogo(base64ProductLogo); - IdentityZoneHolder.get().setConfig(newConfiguration); } @AfterEach @@ -99,9 +136,15 @@ void afterEach() { IdentityZoneHolder.get().setConfig(originalConfiguration); } - @Test - void tilesFromClientMetadataAndTilesConfigShown() throws Exception { - mockMvc.perform(get("/")) + // --- Tests (each parameterized by ZoneRequestPathMode) --- + + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void tilesFromClientMetadataAndTilesConfigShown(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + applyTestBrandingToCurrentZone(); + mockMvc.perform(request(mode, "/")) + .andDo(print()) .andExpect(xpath("//*[@id='tile-1'][text()[contains(.,'client-1')]]").exists()) .andExpect(xpath("//*[@class='tile-1']/@href").string("http://app.launch/url")) @@ -112,138 +155,194 @@ void tilesFromClientMetadataAndTilesConfigShown() throws Exception { .andExpect(xpath("//*[@class='tile-3']").doesNotExist()); } - @Test - void tilesFromClientMetadataAndTilesConfigShown_forOtherZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void tilesFromClientMetadataAndTilesConfigShown_forOtherZone(ZoneRequestPathMode mode) throws Exception { IdentityZone identityZone = MultitenancyFixture.identityZone("test", "test"); IdentityZoneHolder.set(identityZone); - mockMvc.perform(get("/")) + mockMvc.perform(request(mode, "/")) .andExpect(xpath("//*[@id='tile-1'][text()[contains(.,'client-1')]]").exists()) .andExpect(xpath("//*[@class='tile-1']/@href").string("http://app.launch/url")) - .andExpect(xpath("//head/style[1]").string(".tile-1 .tile-icon {background-image: url(\"data:image/png;base64," + base64EncodedImg + "\")}")) .andExpect(xpath("//*[@id='tile-2'][text()[contains(.,'Client 2 Name')]]").exists()) .andExpect(xpath("//*[@class='tile-2']/@href").string("http://second.url/")) - .andExpect(xpath("//*[@class='tile-3']").doesNotExist()); } - @Test - void configuredHomePage() throws Exception { - mockMvc.perform(get("/home")) + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void configuredHomePage(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + mockMvc.perform(request(mode, "/home")) .andExpect(status().isOk()); String customHomePage = "http://custom.home/page"; IdentityZoneHolder.get().getConfig().getLinks().setHomeRedirect(customHomePage); - mockMvc.perform(get("/home")) + mockMvc.perform(request(mode, "/home")) .andExpect(status().is3xxRedirection()) .andExpect(header().string("Location", customHomePage)); IdentityZone zone = MultitenancyFixture.identityZone("zone", "zone"); zone.setConfig(new IdentityZoneConfiguration()); IdentityZoneHolder.set(zone); - mockMvc.perform(get("/home")) + mockMvc.perform(request(mode, "/home")) .andExpect(status().isOk()); zone.getConfig().getLinks().setHomeRedirect(customHomePage); - mockMvc.perform(get("/home")) + mockMvc.perform(request(mode, "/home")) .andExpect(status().is3xxRedirection()) .andExpect(header().string("Location", customHomePage)); } @ParameterizedTest - @ValueSource(strings = { - "/error", - "/error404", - "/error500", - "/oauth_error", - "/saml_error" - }) - void errorBranding(final String errorUrl) throws Exception { - mockMvc.perform(get(errorUrl).sessionAttr(WebAttributes.AUTHENTICATION_EXCEPTION, new InternalAuthenticationServiceException("auth error"))) + @EnumSource(ZoneRequestPathMode.class) + void homePageContainsCorrectNavLinks(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + mockMvc.perform(request(mode, "/home")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString(expectedHref(mode, "/profile")))) + .andExpect(content().string(containsString(expectedHref(mode, "/logout.do")))); + } + + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void errorPageContainsCorrectNavLinks(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + mockMvc.perform(request(mode, "/error")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Uh oh."))); + } + + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void errorPageContainsCorrectResourceLink(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + String imagePath = "/resources/images/sad_cloud.png"; + mockMvc.perform(request(mode, "/error")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("src=\"" + expectedResourcePath(mode, imagePath) + "\""))); + } + + static Stream errorBrandingParams() { + return Stream.of("/error", "/error404", "/error500", "/oauth_error", "/saml_error") + .flatMap(url -> Stream.of(ZoneRequestPathMode.DEFAULT, ZoneRequestPathMode.ZONE_PATH) + .map(mode -> Arguments.of(mode, url))); + } + + @ParameterizedTest + @MethodSource("errorBrandingParams") + void errorBranding(ZoneRequestPathMode mode, String errorUrl) throws Exception { + mode.setZone(); + applyTestBrandingToCurrentZone(); + mockMvc.perform(request(mode, errorUrl).sessionAttr(WebAttributes.AUTHENTICATION_EXCEPTION, new InternalAuthenticationServiceException("auth error"))) .andExpect(status().isOk()) .andExpect(content().string(containsString(customFooterText))) .andExpect(content().string(containsString(base64ProductLogo))); } - @Test - void errorOauthWithExceptionString() throws Exception { - mockMvc.perform(get("/oauth_error").sessionAttr("oauth_error", "auth error")) + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void errorOauthWithExceptionString(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + applyTestBrandingToCurrentZone(); + mockMvc.perform(request(mode, "/oauth_error").sessionAttr("oauth_error", "auth error")) .andExpect(status().isOk()) .andExpect(content().string(containsString(customFooterText))) .andExpect(content().string(containsString(base64ProductLogo))); } - @Test - void error500WithGenericException() throws Exception { - mockMvc.perform(get("/error500").requestAttr(RequestDispatcher.ERROR_EXCEPTION, new Exception("bad"))) + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void error500WithGenericException(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + applyTestBrandingToCurrentZone(); + mockMvc.perform(request(mode, "/error500").requestAttr(RequestDispatcher.ERROR_EXCEPTION, new Exception("bad"))) .andExpect(status().isOk()) .andExpect(content().string(containsString(customFooterText))) .andExpect(content().string(containsString(base64ProductLogo))); } - @Test - void error500WithSAMLExceptionAsCause() throws Exception { - mockMvc.perform(get("/error500").requestAttr(RequestDispatcher.ERROR_EXCEPTION, new Exception(new Saml2Exception("bad")))) + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void error500WithSAMLExceptionAsCause(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + applyTestBrandingToCurrentZone(); + mockMvc.perform(request(mode, "/error500").requestAttr(RequestDispatcher.ERROR_EXCEPTION, new Exception(new Saml2Exception("bad")))) .andExpect(status().isBadRequest()) .andExpect(content().string(containsString(customFooterText))) .andExpect(content().string(containsString(base64ProductLogo))); } - @Test - void error500WithMetadataProviderNotFoundExceptionCause() throws Exception { - mockMvc.perform(get("/error500").requestAttr(RequestDispatcher.ERROR_EXCEPTION, new Exception(new MetadataProviderNotFoundException("bad", new RuntimeException())))) + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void error500WithMetadataProviderNotFoundExceptionCause(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + applyTestBrandingToCurrentZone(); + mockMvc.perform(request(mode, "/error500").requestAttr(RequestDispatcher.ERROR_EXCEPTION, new Exception(new MetadataProviderNotFoundException("bad", new RuntimeException())))) .andExpect(status().isBadRequest()) .andExpect(content().string(containsString(customFooterText))) .andExpect(content().string(containsString(base64ProductLogo))); } @ParameterizedTest - @ValueSource(strings = { - "/rejected" - }) - void errorRejection(final String errorUrl) throws Exception { - mockMvc.perform(get(errorUrl)) + @EnumSource(ZoneRequestPathMode.class) + void error429(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + mockMvc.perform(request(mode, "/error429")) + .andExpect(status().isTooManyRequests()); + } + + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void errorRejection(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + mockMvc.perform(request(mode, "/rejected")) .andExpect(status().isBadRequest()); } - @Test - void handleRequestRejected() { - assertThat(homeController.handleRequestRejected(mock(Model.class), new RequestRejectedException(""), "")).isEqualTo("external_auth_error"); + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void handleRequestRejected(ZoneRequestPathMode mode) { + assertThat(homeController.handleRequestRejected( + mock(Model.class), + mock(HttpServletRequest.class), + new RequestRejectedException(""), + "")).isEqualTo("external_auth_error"); } - @Test - void configuredGlobalHomePage() throws Exception { - //nothing configured - mockMvc.perform(get("/home")) + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void configuredGlobalHomePage(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + + mockMvc.perform(request(mode, "/home")) .andExpect(status().isOk()); String globalHomePage = "http://{zone.subdomain}.custom.home/{zone.id}"; ReflectionTestUtils.setField(homeController, "globalLinks", new Links().setHomeRedirect(globalHomePage)); - //global home redirect configured - mockMvc.perform(get("/home")) + String expectedGlobalRedirect = mode == ZoneRequestPathMode.ZONE_PATH + ? "http://test-zone.custom.home/test-zone-id" + : "http://.custom.home/uaa"; + mockMvc.perform(request(mode, "/home")) .andExpect(status().is3xxRedirection()) - .andExpect(header().string("Location", "http://.custom.home/uaa")); + .andExpect(header().string("Location", expectedGlobalRedirect)); - //configure home redirect on the default zone String customHomePage = "http://custom.home/page"; IdentityZoneHolder.get().getConfig().getLinks().setHomeRedirect(customHomePage); - mockMvc.perform(get("/home")) + mockMvc.perform(request(mode, "/home")) .andExpect(status().is3xxRedirection()) .andExpect(header().string("Location", customHomePage)); - - //create a new zone, no config, inherits the global redirect IdentityZone zone = MultitenancyFixture.identityZone("zoneId", "zonesubdomain"); zone.setConfig(new IdentityZoneConfiguration()); IdentityZoneHolder.set(zone); - mockMvc.perform(get("/home")) + mockMvc.perform(request(mode, "/home")) .andExpect(status().is3xxRedirection()) .andExpect(header().string("Location", "http://zonesubdomain.custom.home/zoneId")); - //zone configures its own home redirect zone.getConfig().getLinks().setHomeRedirect(customHomePage); - mockMvc.perform(get("/home")) + mockMvc.perform(request(mode, "/home")) .andExpect(status().is3xxRedirection()) .andExpect(header().string("Location", customHomePage)); } 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 7eb5d318a19..b21ecf11317 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 @@ -28,6 +28,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.EnumSource; import org.mockito.ArgumentCaptor; import org.mockito.Mockito; import org.springframework.beans.factory.annotation.Autowired; @@ -76,6 +78,14 @@ @SpringJUnitConfig(classes = ResetPasswordControllerTest.ContextConfiguration.class) @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) class ResetPasswordControllerTest extends TestClassNullifier { + /** Whether the test uses the default path or the zone path prefix {@code /z/{subdomain}/}. */ + enum RequestPathMode { + DEFAULT, + ZONE_PATH + } + + private static final String ZONE_PATH_SUBDOMAIN = "testsubdomain"; + private MockMvc mockMvc; private String companyName = "Best Company"; @@ -113,9 +123,11 @@ void tearDown() { IdentityZoneHolder.set(IdentityZone.getUaa()); } - @Test - void forgotPasswordPage() throws Exception { - mockMvc.perform(get("/forgot_password") + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void forgotPasswordPage(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); + mockMvc.perform(get(pathPrefix + "/forgot_password") .param("client_id", "example") .param("redirect_uri", "http://example.com")) .andExpect(status().isOk()) @@ -124,13 +136,14 @@ void forgotPasswordPage() throws Exception { .andExpect(model().attribute("redirect_uri", "http://example.com")); } - @Test - void forgotPasswordWithSelfServiceDisabled() throws Exception { - IdentityZone zone = MultitenancyFixture.identityZone("test-zone-id", "testsubdomain"); + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void forgotPasswordWithSelfServiceDisabled(RequestPathMode mode) throws Exception { + IdentityZone zone = MultitenancyFixture.identityZone("test-zone-id", ZONE_PATH_SUBDOMAIN); zone.getConfig().getLinks().getSelfService().setSelfServiceLinksEnabled(false); IdentityZoneHolder.set(zone); - - mockMvc.perform(get("/forgot_password") + String pathPrefix = pathPrefixString(mode); + mockMvc.perform(get(pathPrefix + "/forgot_password") .param("client_id", "example") .param("redirect_uri", "http://example.com")) .andExpect(status().isNotFound()) @@ -143,14 +156,19 @@ void forgotPassword_Conflict_SendsEmailWithUnavailableEmailHtml() throws Excepti forgotPasswordWithConflict(null, companyName); } - @Test - void forgotPassword_ConflictInOtherZone_SendsEmailWithUnavailableEmailHtml() throws Exception { - String subdomain = "testsubdomain"; + @ParameterizedTest + @EnumSource(value = RequestPathMode.class, names = {"DEFAULT"}) // ZONE_PATH: conflict email content/domain + void forgotPassword_ConflictInOtherZone_SendsEmailWithUnavailableEmailHtml(RequestPathMode mode) throws Exception { + String subdomain = ZONE_PATH_SUBDOMAIN; IdentityZoneHolder.set(MultitenancyFixture.identityZone("test-zone-id", subdomain)); - forgotPasswordWithConflict(subdomain, "The Twiglet Zone"); + forgotPasswordWithConflict(subdomain, "The Twiglet Zone", mode); } private void forgotPasswordWithConflict(String zoneDomain, String companyName) throws Exception { + forgotPasswordWithConflict(zoneDomain, companyName, RequestPathMode.DEFAULT); + } + + private void forgotPasswordWithConflict(String zoneDomain, String companyName, RequestPathMode mode) throws Exception { IdentityZoneConfiguration defaultConfig = IdentityZoneHolder.get().getConfig(); BrandingInformation branding = new BrandingInformation(); branding.setCompanyName(companyName); @@ -161,14 +179,17 @@ private void forgotPasswordWithConflict(String zoneDomain, String companyName) t try { String domain = zoneDomain == null ? "localhost" : zoneDomain + ".localhost"; when(resetPasswordService.forgotPassword("user@example.com", "", "")).thenThrow(new ConflictException("abcd", "user@example.com")); - MockHttpServletRequestBuilder post = post("/forgot_password.do") + String path = pathPrefixString(mode) + "/forgot_password.do"; + MockHttpServletRequestBuilder post = post(path) .contentType(APPLICATION_FORM_URLENCODED) .param("username", "user@example.com"); - post.with(request -> { - request.setServerName(domain); - return request; - }); + if (mode == RequestPathMode.DEFAULT) { + post.with(request -> { + request.setServerName(domain); + return request; + }); + } mockMvc.perform(post) .andExpect(status().isFound()) @@ -191,10 +212,12 @@ private void forgotPasswordWithConflict(String zoneDomain, String companyName) t } } - @Test - void forgotPassword_DoesNotSendEmail_UserNotFound() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void forgotPassword_DoesNotSendEmail_UserNotFound(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); when(resetPasswordService.forgotPassword("user@example.com", "", "")).thenThrow(new NotFoundException()); - MockHttpServletRequestBuilder post = post("/forgot_password.do") + MockHttpServletRequestBuilder post = post(pathPrefix + "/forgot_password.do") .contentType(APPLICATION_FORM_URLENCODED) .param("username", "user@example.com"); mockMvc.perform(post) @@ -204,9 +227,14 @@ void forgotPassword_DoesNotSendEmail_UserNotFound() throws Exception { Mockito.verifyNoInteractions(messageService); } - @Test - void forgotPassword_Successful() throws Exception { - forgotPasswordSuccessful("http://localhost/reset_password?code=code1"); + @ParameterizedTest + @EnumSource(value = RequestPathMode.class, names = {"DEFAULT"}) // ZONE_PATH: email reset link built from request + void forgotPassword_Successful(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); + String resetUrl = mode == RequestPathMode.DEFAULT + ? "http://localhost/reset_password?code=code1" + : "http://localhost/z/" + ZONE_PATH_SUBDOMAIN + "/reset_password?code=code1"; + forgotPasswordSuccessful(resetUrl, "Best Company", pathPrefix + "/forgot_password.do"); } @Test @@ -219,20 +247,25 @@ void forgotPassword_SuccessfulDefaultCompanyName() throws Exception { forgotPasswordSuccessful("http://localhost/reset_password?code=code1", "Cloud Foundry"); } - @Test - void forgotPassword_SuccessfulInOtherZone() throws Exception { - IdentityZone zone = MultitenancyFixture.identityZone("test-zone-id", "testsubdomain"); - IdentityZoneHolder.set(zone); - forgotPasswordSuccessful("http://testsubdomain.localhost/reset_password?code=code1", "The Twiglet Zone"); + @ParameterizedTest + @EnumSource(value = RequestPathMode.class, names = {"DEFAULT"}) // ZONE_PATH: email reset link built from request + void forgotPassword_SuccessfulInOtherZone(RequestPathMode mode) throws Exception { + IdentityZoneHolder.set(MultitenancyFixture.identityZone("test-zone-id", ZONE_PATH_SUBDOMAIN)); + String pathPrefix = pathPrefixString(mode); + String resetUrl = mode == RequestPathMode.DEFAULT + ? "http://testsubdomain.localhost/reset_password?code=code1" + : "http://localhost/z/" + ZONE_PATH_SUBDOMAIN + "/reset_password?code=code1"; + forgotPasswordSuccessful(resetUrl, "The Twiglet Zone", pathPrefix + "/forgot_password.do"); } - @Test - void forgotPasswordPostWithSelfServiceDisabled() throws Exception { - IdentityZone zone = MultitenancyFixture.identityZone("test-zone-id", "testsubdomain"); + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void forgotPasswordPostWithSelfServiceDisabled(RequestPathMode mode) throws Exception { + IdentityZone zone = MultitenancyFixture.identityZone("test-zone-id", ZONE_PATH_SUBDOMAIN); zone.getConfig().getLinks().getSelfService().setSelfServiceLinksEnabled(false); IdentityZoneHolder.set(zone); - - mockMvc.perform(post("/forgot_password.do") + String pathPrefix = pathPrefixString(mode); + mockMvc.perform(post(pathPrefix + "/forgot_password.do") .contentType(APPLICATION_FORM_URLENCODED) .param("username", "user@example.com") .param("client_id", "example") @@ -243,10 +276,14 @@ void forgotPasswordPostWithSelfServiceDisabled() throws Exception { } private void forgotPasswordSuccessful(String url) throws Exception { - forgotPasswordSuccessful(url, "Best Company"); + forgotPasswordSuccessful(url, "Best Company", "/forgot_password.do"); } private void forgotPasswordSuccessful(String url, String companyName) throws Exception { + forgotPasswordSuccessful(url, companyName, "/forgot_password.do"); + } + + private void forgotPasswordSuccessful(String url, String companyName, String forgotPasswordPath) throws Exception { IdentityZoneConfiguration defaultConfig = IdentityZoneHolder.get().getConfig(); BrandingInformation branding = new BrandingInformation(); branding.setCompanyName(companyName); @@ -255,7 +292,7 @@ private void forgotPasswordSuccessful(String url, String companyName) throws Exc IdentityZoneHolder.get().setConfig(config); try { when(resetPasswordService.forgotPassword("user@example.com", "example", "redirect.example.com")).thenReturn(new ForgotPasswordInfo("123", "user@example.com", new ExpiringCode("code1", new Timestamp(System.currentTimeMillis()), "someData", null))); - MockHttpServletRequestBuilder post = post("/forgot_password.do") + MockHttpServletRequestBuilder post = post(forgotPasswordPath) .contentType(APPLICATION_FORM_URLENCODED) .param("username", "user@example.com") .param("client_id", "example") @@ -282,33 +319,57 @@ private void forgotPasswordSuccessful(String url, String companyName) throws Exc } } - @Test - void instructions() throws Exception { - mockMvc.perform(get("/email_sent").param("code", "reset_password")) + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void instructions(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); + mockMvc.perform(get(pathPrefix + "/email_sent").param("code", "reset_password")) .andExpect(status().isOk()) .andExpect(header().string("Content-Security-Policy", "frame-ancestors 'none'")) .andExpect(model().attribute("code", "reset_password")); } - @Test - void resetPasswordPage() throws Exception { - ExpiringCode code = codeStore.generateCode("{\"user_id\" : \"some-user-id\"}", new Timestamp(System.currentTimeMillis() + 1000000), null, IdentityZoneHolder.get().getId()); - mockMvc.perform(get("/reset_password").param("email", "user@example.com").param("code", code.getCode())) + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void resetPasswordPage(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); + IdentityZone zone = IdentityZoneHolder.get(); + ExpiringCode code = codeStore.generateCode("{\"user_id\" : \"some-user-id\"}", new Timestamp(System.currentTimeMillis() + 1000000), null, zone.getId()); + var result = mockMvc.perform(get(pathPrefix + "/reset_password").param("email", "user@example.com").param("code", code.getCode())) .andExpect(status().isOk()) - .andDo(print()) .andExpect(view().name("reset_password")) .andExpect(model().attribute("email", "email")) - .andExpect(model().attribute("username", "username")) - .andExpect(content().string(containsString("
Username: username
"))) - .andExpect(content().string(containsString(""))); + .andExpect(model().attribute("username", "username")); + if (mode == RequestPathMode.DEFAULT) { + result.andDo(print()) + .andExpect(content().string(containsString("
Username: username
"))) + .andExpect(content().string(containsString(""))); + } } - @Test - void resetPasswordPageWithPriorHeadRequest() throws Exception { - ExpiringCode code = codeStore.generateCode("{\"user_id\" : \"some-user-id\"}", new Timestamp(System.currentTimeMillis() + 1000000), null, IdentityZoneHolder.get().getId()); - mockMvc.perform(head("/reset_password").param("email", "user@example.com").param("code", code.getCode())) + private String pathPrefixFor(RequestPathMode mode) { + if (mode == RequestPathMode.ZONE_PATH) { + IdentityZone zone = MultitenancyFixture.identityZone("test-zone-id", ZONE_PATH_SUBDOMAIN); + IdentityZoneHolder.set(zone); + return "/z/" + ZONE_PATH_SUBDOMAIN; + } + return ""; + } + + /** Returns path prefix only; does not set IdentityZoneHolder (use when test sets zone itself). */ + private String pathPrefixString(RequestPathMode mode) { + return mode == RequestPathMode.ZONE_PATH ? "/z/" + ZONE_PATH_SUBDOMAIN : ""; + } + + @ParameterizedTest + @EnumSource(value = RequestPathMode.class, names = {"DEFAULT"}) // ZONE_PATH: view resolver looks for template under path + void resetPasswordPageWithPriorHeadRequest(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); + IdentityZone zone = IdentityZoneHolder.get(); + ExpiringCode code = codeStore.generateCode("{\"user_id\" : \"some-user-id\"}", new Timestamp(System.currentTimeMillis() + 1000000), null, zone.getId()); + mockMvc.perform(head(pathPrefix + "/reset_password").param("email", "user@example.com").param("code", code.getCode())) .andExpect(status().isOk()); - mockMvc.perform(get("/reset_password").param("email", "user@example.com").param("code", code.getCode())) + mockMvc.perform(get(pathPrefix + "/reset_password").param("email", "user@example.com").param("code", code.getCode())) .andExpect(status().isOk()) .andDo(print()) .andExpect(view().name("reset_password")) @@ -318,20 +379,25 @@ void resetPasswordPageWithPriorHeadRequest() throws Exception { .andExpect(content().string(containsString(""))); } - @Test - void resetPasswordPageDuplicate() throws Exception { - ExpiringCode code = codeStore.generateCode("{\"user_id\" : \"some-user-id\"}", new Timestamp(System.currentTimeMillis() + 1000000), null, IdentityZoneHolder.get().getId()); - mockMvc.perform(get("/reset_password").param("email", "user@example.com").param("code", code.getCode())) + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void resetPasswordPageDuplicate(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); + IdentityZone zone = IdentityZoneHolder.get(); + ExpiringCode code = codeStore.generateCode("{\"user_id\" : \"some-user-id\"}", new Timestamp(System.currentTimeMillis() + 1000000), null, zone.getId()); + mockMvc.perform(get(pathPrefix + "/reset_password").param("email", "user@example.com").param("code", code.getCode())) .andExpect(status().isOk()) .andExpect(view().name("reset_password")); - mockMvc.perform(get("/reset_password").param("email", "user@example.com").param("code", code.getCode())) + mockMvc.perform(get(pathPrefix + "/reset_password").param("email", "user@example.com").param("code", code.getCode())) .andExpect(status().isUnprocessableEntity()) .andExpect(view().name("forgot_password")); } - @Test - void resetPasswordPageWhenExpiringCodeNull() throws Exception { - mockMvc.perform(get("/reset_password").param("email", "user@example.com").param("code", "code1")) + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void resetPasswordPageWhenExpiringCodeNull(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); + mockMvc.perform(get(pathPrefix + "/reset_password").param("email", "user@example.com").param("code", "code1")) .andExpect(status().isUnprocessableEntity()) .andExpect(view().name("forgot_password")) .andExpect(model().attribute("message_code", "bad_code")); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/login/SessionControllerViewTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/login/SessionControllerViewTests.java new file mode 100644 index 00000000000..ef06a3ebcc7 --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/login/SessionControllerViewTests.java @@ -0,0 +1,118 @@ +package org.cloudfoundry.identity.uaa.login; + +import org.cloudfoundry.identity.uaa.TestClassNullifier; +import org.cloudfoundry.identity.uaa.extensions.PollutionPreventionExtension; +import org.cloudfoundry.identity.uaa.util.ZoneRequestPathMode; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.test.annotation.DirtiesContext; +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.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 static org.hamcrest.CoreMatchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +/** + * View tests for SessionController. Each test is parameterized by {@link ZoneRequestPathMode} so that + * both default paths ({@code /session}, {@code /session_management}) and zone paths ({@code /z/{subdomain}/session}, ...) are covered. + * Asserts that paths in the response content (e.g. script src) are correct for the mode. + */ +@ExtendWith(PollutionPreventionExtension.class) +@WebAppConfiguration +@SpringJUnitConfig(classes = SessionControllerViewTests.ContextConfiguration.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class SessionControllerViewTests extends TestClassNullifier { + + private static final String CLIENT_ID = "test-client"; + private static final String MESSAGE_ORIGIN = "https://origin.example.com"; + + /** GET request for the given path; uses /z/{subdomain} when mode is ZONE_PATH so handler mapping matches. */ + private static MockHttpServletRequestBuilder request(ZoneRequestPathMode mode, String pathSuffix) { + if (mode.redirectPrefix().isEmpty()) { + return get(pathSuffix); + } + return get("/z/{subdomain}" + pathSuffix, mode.getSubdomain()); + } + + @Autowired + private WebApplicationContext webApplicationContext; + + private MockMvc mockMvc; + + @BeforeEach + void beforeEach() { + SecurityContextHolder.clearContext(); + IdentityZoneHolder.clear(); + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + } + + @AfterEach + void afterEach() { + SecurityContextHolder.clearContext(); + IdentityZoneHolder.clear(); + } + + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void sessionPageReturnsOkAndContainsExpectedPaths(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + String scriptPath = "/resources/javascripts/session/session_message_handler.js"; + mockMvc.perform(request(mode, "/session") + .param("clientId", CLIENT_ID) + .param("messageOrigin", MESSAGE_ORIGIN)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("src=\"" + scriptPath + "\""))) + .andExpect(content().string(containsString(CLIENT_ID))) + .andExpect(content().string(containsString(MESSAGE_ORIGIN))); + } + + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void sessionManagementPageReturnsOkAndContainsExpectedPaths(ZoneRequestPathMode mode) throws Exception { + mode.setZone(); + String sjcl = "/resources/javascripts/session/sjcl.js"; + String sessionJs = "/resources/javascripts/session/session.js"; + String handlerJs = "/resources/javascripts/session/session_management_message_handler.js"; + mockMvc.perform(request(mode, "/session_management") + .param("clientId", CLIENT_ID) + .param("messageOrigin", MESSAGE_ORIGIN)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("src=\"" + sjcl + "\""))) + .andExpect(content().string(containsString("src=\"" + sessionJs + "\""))) + .andExpect(content().string(containsString("src=\"" + handlerJs + "\""))) + .andExpect(content().string(containsString(CLIENT_ID))) + .andExpect(content().string(containsString(MESSAGE_ORIGIN))); + } + + @EnableWebMvc + @Import(ThymeleafConfig.class) + static class ContextConfiguration implements WebMvcConfigurer { + + @Override + public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) { + configurer.enable(); + } + + @Bean + SessionController sessionController() { + return new SessionController(); + } + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/login/UaaAuthenticationFailureHandlerTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/login/UaaAuthenticationFailureHandlerTests.java index 0d67a1bd58c..f13cfc41b01 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/login/UaaAuthenticationFailureHandlerTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/login/UaaAuthenticationFailureHandlerTests.java @@ -34,8 +34,10 @@ import org.cloudfoundry.identity.uaa.authentication.PasswordChangeRequiredException; import org.cloudfoundry.identity.uaa.authentication.UaaAuthentication; import org.cloudfoundry.identity.uaa.util.SessionUtils; +import org.cloudfoundry.identity.uaa.util.ZoneRequestPathMode; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.security.authentication.BadCredentialsException; @@ -64,30 +66,38 @@ void setup() { response = new MockHttpServletResponse(); } - @Test - void onAuthenticationFailure() throws Exception { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void onAuthenticationFailure(ZoneRequestPathMode mode) throws Exception { + mode.applyRequestPath(request, "/login.do"); AuthenticationException exception = mock(AuthenticationException.class); uaaAuthenticationFailureHandler.onAuthenticationFailure(request, response, exception); verify(failureHandler, times(1)).onAuthenticationFailure(same(request), same(response), same(exception)); - validateCookie(); + validateCookie(mode); } - @Test - void onAuthenticationFailure_Without_Delegate() throws Exception { - AuthenticationException exception = mock(AuthenticationException.class); + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void onAuthenticationFailure_Without_Delegate(ZoneRequestPathMode mode) throws Exception { + mode.applyRequestPath(request, "/login.do"); uaaAuthenticationFailureHandler = new UaaAuthenticationFailureHandler(null, cookieFactory); + AuthenticationException exception = mock(AuthenticationException.class); uaaAuthenticationFailureHandler.onAuthenticationFailure(request, response, exception); - validateCookie(); + validateCookie(mode); } - @Test - void logout() { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void logout(ZoneRequestPathMode mode) { + mode.applyRequestPath(request, "/login"); uaaAuthenticationFailureHandler.logout(request, response, mock(Authentication.class)); - validateCookie(); + validateCookie(mode); } - @Test - void onAuthenticationFailure_ForcePasswordChange() throws IOException, ServletException { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void onAuthenticationFailure_ForcePasswordChange(ZoneRequestPathMode mode) throws IOException, ServletException { + mode.applyRequestPath(request, "/login.do"); UaaAuthentication uaaAuthentication = mock(UaaAuthentication.class); PasswordChangeRequiredException exception = new PasswordChangeRequiredException(uaaAuthentication, "mock"); uaaAuthenticationFailureHandler.onAuthenticationFailure(request, response, exception); @@ -95,40 +105,93 @@ void onAuthenticationFailure_ForcePasswordChange() throws IOException, ServletEx assertThat(uaaAuthenticationFromSession) .isNotNull() .isEqualTo(uaaAuthentication); - validateCookie(); - assertThat(response.getRedirectedUrl()).isEqualTo("/force_password_change"); + validateCookie(mode); + assertThat(response.getRedirectedUrl()).isEqualTo(mode.redirectPrefix() + "/force_password_change"); } - @Test - void redirectUrls() throws ServletException, IOException { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void redirectUrls(ZoneRequestPathMode mode) throws ServletException, IOException { var handler = new UaaAuthenticationFailureHandler(cookieFactory); + String redirectPrefix = mode.redirectPrefix(); + + mode.applyRequestPath(request, "/login.do"); - var response = new MockHttpServletResponse(); + MockHttpServletResponse response = new MockHttpServletResponse(); handler.onAuthenticationFailure(request, response, new AccountNotVerifiedException("test")); - assertThat(response.getRedirectedUrl()).isEqualTo("/login?error=account_not_verified"); + assertThat(response.getRedirectedUrl()).isEqualTo(redirectPrefix + "/login?error=account_not_verified"); response = new MockHttpServletResponse(); + mode.applyRequestPath(request, "/login.do"); handler.onAuthenticationFailure(request, response, new AuthenticationPolicyRejectionException("test")); - assertThat(response.getRedirectedUrl()).isEqualTo("/login?error=account_locked"); + assertThat(response.getRedirectedUrl()).isEqualTo(redirectPrefix + "/login?error=account_locked"); response = new MockHttpServletResponse(); + mode.applyRequestPath(request, "/login.do"); handler.onAuthenticationFailure(request, response, new AccountNotPreCreatedException("test")); - assertThat(response.getRedirectedUrl()).isEqualTo("/login?error=account_not_precreated"); + assertThat(response.getRedirectedUrl()).isEqualTo(redirectPrefix + "/login?error=account_not_precreated"); response = new MockHttpServletResponse(); + mode.applyRequestPath(request, "/force_password_change"); handler.onAuthenticationFailure(request, response, new PasswordChangeRequiredException(mock(UaaAuthentication.class), "test")); - assertThat(response.getRedirectedUrl()).isEqualTo("/force_password_change"); + assertThat(response.getRedirectedUrl()).isEqualTo(redirectPrefix + "/force_password_change"); response = new MockHttpServletResponse(); + mode.applyRequestPath(request, "/login.do"); handler.onAuthenticationFailure(request, response, new BadCredentialsException("test")); - assertThat(response.getRedirectedUrl()).isEqualTo("/login?error=login_failure"); + assertThat(response.getRedirectedUrl()).isEqualTo(redirectPrefix + "/login?error=login_failure"); + validateCookie(response, mode); + } + + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void redirectUrlsWithContextPath(ZoneRequestPathMode mode) throws ServletException, IOException { + var handler = new UaaAuthenticationFailureHandler(cookieFactory); + request.setContextPath("/uaa"); + String redirectPrefix = "/uaa" + mode.redirectPrefix(); + + mode.applyRequestPath(request, "/login.do"); + + MockHttpServletResponse response = new MockHttpServletResponse(); + handler.onAuthenticationFailure(request, response, new AccountNotVerifiedException("test")); + assertThat(response.getRedirectedUrl()).isEqualTo(redirectPrefix + "/login?error=account_not_verified"); + + response = new MockHttpServletResponse(); + mode.applyRequestPath(request, "/login.do"); + handler.onAuthenticationFailure(request, response, new AuthenticationPolicyRejectionException("test")); + assertThat(response.getRedirectedUrl()).isEqualTo(redirectPrefix + "/login?error=account_locked"); + + response = new MockHttpServletResponse(); + mode.applyRequestPath(request, "/login.do"); + handler.onAuthenticationFailure(request, response, new AccountNotPreCreatedException("test")); + assertThat(response.getRedirectedUrl()).isEqualTo(redirectPrefix + "/login?error=account_not_precreated"); + + response = new MockHttpServletResponse(); + mode.applyRequestPath(request, "/force_password_change"); + handler.onAuthenticationFailure(request, response, new PasswordChangeRequiredException(mock(UaaAuthentication.class), "test")); + assertThat(response.getRedirectedUrl()).isEqualTo(redirectPrefix + "/force_password_change"); + + response = new MockHttpServletResponse(); + mode.applyRequestPath(request, "/login.do"); + handler.onAuthenticationFailure(request, response, new BadCredentialsException("test")); + assertThat(response.getRedirectedUrl()).isEqualTo(redirectPrefix + "/login?error=login_failure"); + validateCookie(response, mode); + } + + private void validateCookie(ZoneRequestPathMode mode) { + validateCookie(response, mode); } - private void validateCookie() { + private void validateCookie(MockHttpServletResponse response, ZoneRequestPathMode mode) { Cookie cookie = response.getCookie("Current-User"); assertThat(cookie).isNotNull(); assertThat(cookie.getMaxAge()).isZero(); assertThat(cookie.isHttpOnly()).isFalse(); + if (mode == ZoneRequestPathMode.DEFAULT) { + assertThat(cookie.getPath()).isEqualTo("/"); + } else { + assertThat(cookie.getPath()).startsWith("/z/" + mode.getSubdomain()); + } } } \ No newline at end of file diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/logout/LoggedOutEndpointTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/logout/LoggedOutEndpointTest.java new file mode 100644 index 00000000000..fb48f30577a --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/logout/LoggedOutEndpointTest.java @@ -0,0 +1,139 @@ +package org.cloudfoundry.identity.uaa.logout; + +import org.cloudfoundry.identity.uaa.TestClassNullifier; +import org.cloudfoundry.identity.uaa.extensions.PollutionPreventionExtension; +import org.cloudfoundry.identity.uaa.home.BuildInfo; +import org.cloudfoundry.identity.uaa.login.ThymeleafConfig; +import org.cloudfoundry.identity.uaa.util.beans.TestBuildInfo; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.MultitenancyFixture; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.context.support.ResourceBundleMessageSource; +import org.springframework.test.annotation.DirtiesContext; +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.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 static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; + +@ExtendWith(PollutionPreventionExtension.class) +@WebAppConfiguration +@SpringJUnitConfig(classes = LoggedOutEndpointTest.ContextConfiguration.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +class LoggedOutEndpointTest extends TestClassNullifier { + + /** Whether the test uses the default path or the zone path prefix {@code /z/{subdomain}/}. */ + enum RequestPathMode { + DEFAULT, + ZONE_PATH + } + + /** Whether the request uses no context path or context path {@code /uaa}. */ + enum ContextPathMode { + NONE, + UAA + } + + private static final String ZONE_PATH_SUBDOMAIN = "testsubdomain"; + private static final String UAA_CONTEXT_PATH = "/uaa"; + + @Autowired + private WebApplicationContext webApplicationContext; + + private MockMvc mockMvc; + + private static String pathPrefixFor(RequestPathMode mode) { + return mode == RequestPathMode.ZONE_PATH ? "/z/" + ZONE_PATH_SUBDOMAIN : ""; + } + + @BeforeEach + void setUp() { + IdentityZoneHolder.set(IdentityZone.getUaa()); + mockMvc = MockMvcBuilders.webAppContextSetup(webApplicationContext).build(); + } + + @AfterEach + void tearDown() { + IdentityZoneHolder.set(IdentityZone.getUaa()); + } + + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void loggedOutPage(RequestPathMode mode) throws Exception { + if (mode == RequestPathMode.ZONE_PATH) { + IdentityZoneHolder.set(MultitenancyFixture.identityZone("test-zone-id", ZONE_PATH_SUBDOMAIN)); + } + String pathPrefix = pathPrefixFor(mode); + mockMvc.perform(get(pathPrefix + "/logged_out")) + .andExpect(status().isOk()) + .andExpect(view().name("logged_out")); + } + + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void loggedOutPageLoginLink(RequestPathMode mode) throws Exception { + if (mode == RequestPathMode.ZONE_PATH) { + IdentityZoneHolder.set(MultitenancyFixture.identityZone("test-zone-id", ZONE_PATH_SUBDOMAIN)); + } + String pathPrefix = pathPrefixFor(mode); + String expectedLoginHref = pathPrefix.isEmpty() ? "href=\"/login\"" : "href=\"/z/" + ZONE_PATH_SUBDOMAIN + "/login\""; + mockMvc.perform(get(pathPrefix + "/logged_out")) + .andExpect(status().isOk()) + .andExpect(content().string(containsString(expectedLoginHref))); + } + + @ParameterizedTest + @EnumSource(ContextPathMode.class) + void loggedOutPageLoginLinkWithContextPath(ContextPathMode contextPathMode) throws Exception { + String contextPath = contextPathMode == ContextPathMode.UAA ? UAA_CONTEXT_PATH : ""; + String requestPath = contextPath.isEmpty() ? "/logged_out" : contextPath + "/logged_out"; + String expectedLoginHref = contextPath.isEmpty() ? "href=\"/login\"" : "href=\"" + contextPath + "/login\""; + mockMvc.perform(get(requestPath).contextPath(contextPath)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString(expectedLoginHref))); + } + + @EnableWebMvc + @Import(ThymeleafConfig.class) + static class ContextConfiguration implements WebMvcConfigurer { + + @Override + public void configureDefaultServletHandling(DefaultServletHandlerConfigurer configurer) { + configurer.enable(); + } + + @Bean + BuildInfo buildInfo() { + return new TestBuildInfo(); + } + + @Bean + ResourceBundleMessageSource messageSource() { + ResourceBundleMessageSource messageSource = new ResourceBundleMessageSource(); + messageSource.setBasename("messages"); + return messageSource; + } + + @Bean + LoggedOutEndpoint loggedOutEndpoint() { + return new LoggedOutEndpoint(); + } + } +} diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlLoginAuthenticationFailureHandlerTest.java b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlLoginAuthenticationFailureHandlerTest.java index d7ff035ec65..6d925b5c457 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlLoginAuthenticationFailureHandlerTest.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/provider/saml/SamlLoginAuthenticationFailureHandlerTest.java @@ -2,7 +2,9 @@ import org.apache.hc.core5.http.HttpStatus; import org.cloudfoundry.identity.uaa.util.SessionUtils; -import org.junit.jupiter.api.Test; +import org.cloudfoundry.identity.uaa.util.ZoneRequestPathMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.mock.web.MockHttpSession; @@ -21,8 +23,9 @@ class SamlLoginAuthenticationFailureHandlerTest { - @Test - void errorRedirect() throws IOException, ServletException { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void errorRedirect(ZoneRequestPathMode mode) throws IOException, ServletException { SamlLoginAuthenticationFailureHandler handler = new SamlLoginAuthenticationFailureHandler(); DefaultSavedRequest savedRequest = mock(DefaultSavedRequest.class); @@ -33,6 +36,7 @@ void errorRedirect() throws IOException, ServletException { MockHttpSession session = new MockHttpSession(); SessionUtils.setSavedRequestSession(session, savedRequest); MockHttpServletRequest request = new MockHttpServletRequest(); + mode.applyRequestPath(request, "/saml/callback"); request.setSession(session); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -40,13 +44,13 @@ void errorRedirect() throws IOException, ServletException { handler.onAuthenticationFailure(request, response, exception); String actual = response.getRedirectedUrl(); - assertThat(actual).isEqualTo("https://example.com?error=access_denied&error_description=Denied%21"); - int status = response.getStatus(); - assertThat(status).isEqualTo(HttpStatus.SC_MOVED_TEMPORARILY); + assertRedirectUrl(actual, "https://example.com?error=access_denied&error_description=Denied%21", mode); + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_MOVED_TEMPORARILY); } - @Test - void errorRedirectWithExistingQueryParameters() throws IOException, ServletException { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void errorRedirectWithExistingQueryParameters(ZoneRequestPathMode mode) throws IOException, ServletException { SamlLoginAuthenticationFailureHandler handler = new SamlLoginAuthenticationFailureHandler(); DefaultSavedRequest savedRequest = mock(DefaultSavedRequest.class); @@ -57,6 +61,7 @@ void errorRedirectWithExistingQueryParameters() throws IOException, ServletExcep MockHttpSession session = new MockHttpSession(); SessionUtils.setSavedRequestSession(session, savedRequest); MockHttpServletRequest request = new MockHttpServletRequest(); + mode.applyRequestPath(request, "/saml/callback"); request.setSession(session); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -64,13 +69,13 @@ void errorRedirectWithExistingQueryParameters() throws IOException, ServletExcep handler.onAuthenticationFailure(request, response, exception); String actual = response.getRedirectedUrl(); - assertThat(actual).isEqualTo("https://example.com?go=bears&error=access_denied&error_description=Denied%21"); - int status = response.getStatus(); - assertThat(status).isEqualTo(HttpStatus.SC_MOVED_TEMPORARILY); + assertRedirectUrl(actual, "https://example.com?go=bears&error=access_denied&error_description=Denied%21", mode); + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_MOVED_TEMPORARILY); } - @Test - void someOtherErrorCondition() throws IOException, ServletException { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void someOtherErrorCondition(ZoneRequestPathMode mode) throws IOException, ServletException { SamlLoginAuthenticationFailureHandler handler = new SamlLoginAuthenticationFailureHandler(); DefaultSavedRequest savedRequest = mock(DefaultSavedRequest.class); @@ -81,6 +86,7 @@ void someOtherErrorCondition() throws IOException, ServletException { MockHttpSession session = new MockHttpSession(); SessionUtils.setSavedRequestSession(session, savedRequest); MockHttpServletRequest request = new MockHttpServletRequest(); + mode.applyRequestPath(request, "/saml/callback"); request.setSession(session); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -93,29 +99,30 @@ void someOtherErrorCondition() throws IOException, ServletException { }; handler.onAuthenticationFailure(request, response, exception); String actual = response.getRedirectedUrl(); - assertThat(actual).isNull(); - int status = response.getStatus(); - assertThat(status).isEqualTo(HttpStatus.SC_UNAUTHORIZED); + assertRedirectUrl(actual, null, mode); + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_UNAUTHORIZED); } - @Test - void noSession() throws IOException, ServletException { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void noSession(ZoneRequestPathMode mode) throws IOException, ServletException { SamlLoginAuthenticationFailureHandler handler = new SamlLoginAuthenticationFailureHandler(); MockHttpServletRequest request = new MockHttpServletRequest(); + mode.applyRequestPath(request, "/saml/callback"); MockHttpServletResponse response = new MockHttpServletResponse(); SamlLoginException exception = new SamlLoginException("Denied!"); handler.onAuthenticationFailure(request, response, exception); String actual = response.getRedirectedUrl(); - assertThat(actual).isNull(); - int status = response.getStatus(); - assertThat(status).isEqualTo(HttpStatus.SC_UNAUTHORIZED); + assertRedirectUrl(actual, null, mode); + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_UNAUTHORIZED); } - @Test - void noSavedRequest() throws IOException, ServletException { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void noSavedRequest(ZoneRequestPathMode mode) throws IOException, ServletException { SamlLoginAuthenticationFailureHandler handler = new SamlLoginAuthenticationFailureHandler(); DefaultSavedRequest savedRequest = mock(DefaultSavedRequest.class); @@ -125,6 +132,7 @@ void noSavedRequest() throws IOException, ServletException { MockHttpSession session = new MockHttpSession(); MockHttpServletRequest request = new MockHttpServletRequest(); + mode.applyRequestPath(request, "/saml/callback"); request.setSession(session); MockHttpServletResponse response = new MockHttpServletResponse(); @@ -132,13 +140,13 @@ void noSavedRequest() throws IOException, ServletException { handler.onAuthenticationFailure(request, response, exception); String actual = response.getRedirectedUrl(); - assertThat(actual).isNull(); - int status = response.getStatus(); - assertThat(status).isEqualTo(HttpStatus.SC_UNAUTHORIZED); + assertRedirectUrl(actual, null, mode); + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_UNAUTHORIZED); } - @Test - void noRedirectURI() throws IOException, ServletException { + @ParameterizedTest + @EnumSource(ZoneRequestPathMode.class) + void noRedirectURI(ZoneRequestPathMode mode) throws IOException, ServletException { SamlLoginAuthenticationFailureHandler handler = new SamlLoginAuthenticationFailureHandler(); DefaultSavedRequest savedRequest = mock(DefaultSavedRequest.class); @@ -148,14 +156,42 @@ void noRedirectURI() throws IOException, ServletException { MockHttpSession session = new MockHttpSession(); SessionUtils.setSavedRequestSession(session, savedRequest); MockHttpServletRequest request = new MockHttpServletRequest(); + mode.applyRequestPath(request, "/saml/callback"); request.setSession(session); MockHttpServletResponse response = new MockHttpServletResponse(); SamlLoginException exception = new SamlLoginException("Denied!"); handler.onAuthenticationFailure(request, response, exception); String actual = response.getRedirectedUrl(); - assertThat(actual).isNull(); - int status = response.getStatus(); - assertThat(status).isEqualTo(HttpStatus.SC_UNAUTHORIZED); + assertRedirectUrl(actual, null, mode); + assertThat(response.getStatus()).isEqualTo(HttpStatus.SC_UNAUTHORIZED); + } + + /** + * Asserts redirect URL: server-relative paths (starting with "/") must start with "/z/{subdomain}" when + * mode is ZONE_PATH and must not start with "/z/" when mode is DEFAULT. External URLs and null are compared as-is. + */ + private static void assertRedirectUrl(String actual, String expected, ZoneRequestPathMode mode) { + System.out.println("[assertRedirectUrl] mode=" + mode + ", actual=" + actual + ", expected=" + expected); + if (expected == null) { + System.out.println("[assertRedirectUrl] asserting actual is null; actual=" + actual + ", expected=" + expected); + assertThat(actual).isNull(); + } else if (expected.startsWith("/")) { + String expectedWithPrefix = mode.redirectPrefix() + expected; + System.out.println("[assertRedirectUrl] server path: asserting actual == expectedWithPrefix; actual=" + actual + ", expected=" + expected + ", expectedWithPrefix=" + expectedWithPrefix); + assertThat(actual).isEqualTo(expectedWithPrefix); + } else { + System.out.println("[assertRedirectUrl] external URL: asserting actual == expected; actual=" + actual + ", expected=" + expected); + assertThat(actual).isEqualTo(expected); + } + if (actual != null && actual.startsWith("/")) { + if (mode == ZoneRequestPathMode.ZONE_PATH) { + System.out.println("[assertRedirectUrl] asserting actual path starts with \"/z/" + mode.getSubdomain() + "\"; actual=" + actual + ", expected=" + expected); + assertThat(actual).startsWith("/z/" + mode.getSubdomain()); + } else { + System.out.println("[assertRedirectUrl] asserting actual path does not start with \"/z/\"; actual=" + actual + ", expected=" + expected); + assertThat(actual).doesNotStartWith("/z/"); + } + } } } diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/security/web/UaaRequestMatcherTests.java b/server/src/test/java/org/cloudfoundry/identity/uaa/security/web/UaaRequestMatcherTests.java index 7c9e34caf98..32f61e56d00 100644 --- a/server/src/test/java/org/cloudfoundry/identity/uaa/security/web/UaaRequestMatcherTests.java +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/security/web/UaaRequestMatcherTests.java @@ -14,7 +14,10 @@ package org.cloudfoundry.identity.uaa.security.web; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedClass; import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.http.MediaType; import org.springframework.mock.web.MockHttpServletRequest; @@ -25,18 +28,40 @@ import java.util.LinkedHashMap; import java.util.List; import java.util.Map; +import java.util.UUID; +import java.util.stream.Stream; import static org.assertj.core.api.Assertions.assertThat; /** * */ +@ParameterizedClass +@MethodSource("useZonePaths") class UaaRequestMatcherTests { + static Stream useZonePaths() { + return Stream.of( + Arguments.of(false, (String) null), + Arguments.of(true, UUID.randomUUID().toString()) + ); + } + + private final boolean withZonePrefix; + private final String zoneId; + private final UaaRequestMatcher matcher; + + UaaRequestMatcherTests(boolean withZonePrefix, String zoneId) { + this.withZonePrefix = withZonePrefix; + this.zoneId = zoneId; + this.matcher = new UaaRequestMatcher("/somePath", withZonePrefix); + } + private MockHttpServletRequest request(String path, String accept, String... parameters) { + String actualPath = withZonePrefix ? "/z/" + zoneId + path : path; MockHttpServletRequest request = new MockHttpServletRequest(); request.setContextPath("/ctx"); - request.setRequestURI("/ctx" + path); + request.setRequestURI("/ctx" + actualPath); if (accept != null) { request.addHeader("Accept", accept); } @@ -50,7 +75,6 @@ private MockHttpServletRequest request(String path, String accept, String... par @Test void pathMatcherMatchesExpectedPaths() { - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); assertThat(matcher.matches(request("/somePath", null))).isTrue(); assertThat(matcher.matches(request("/somePath", "application/json"))).isTrue(); assertThat(matcher.matches(request("/somePath", "application/html"))).isTrue(); @@ -64,7 +88,6 @@ void pathMatcherMatchesExpectedPaths() { @Test void pathMatcherMatchesExpectedPathsAndAcceptHeaderNull() { // Accept only JSON - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON.toString())); assertThat(matcher.matches(request("/somePath", null))).isTrue(); } @@ -78,7 +101,6 @@ void pathMatcherMatchesExpectedPathsAndAcceptHeaderNull() { }) void pathMatcherMatchesExpectedPathsAndAcceptHeaderBlankOrEmpty(final String blankAcceptHeaderValue) { // Accept only JSON - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON.toString())); assertThat(matcher.matches(request("/somePath", blankAcceptHeaderValue))).isFalse(); @@ -87,7 +109,6 @@ void pathMatcherMatchesExpectedPathsAndAcceptHeaderBlankOrEmpty(final String bla @Test void pathMatcherMatchesExpectedPathsAndMatchingAcceptHeader() { // Accept only JSON - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON.toString())); assertThat(matcher.matches(request("/somePath", "application/json"))).isTrue(); } @@ -95,7 +116,6 @@ void pathMatcherMatchesExpectedPathsAndMatchingAcceptHeader() { @Test void pathMatcherMatchesExpectedPathsAndNonMatchingAcceptHeader() { // Accept only JSON - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON.toString())); assertThat(matcher.matches(request("/somePath", "application/html"))).isFalse(); } @@ -103,7 +123,6 @@ void pathMatcherMatchesExpectedPathsAndNonMatchingAcceptHeader() { @Test void pathMatcherMatchesExpectedPathsAndRequestParameters() { // Accept only JSON - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON.toString())); matcher.setParameters(Collections.singletonMap("response_type", "token")); assertThat(matcher.matches(request("/somePath", null, "response_type", "token"))).isTrue(); @@ -112,7 +131,6 @@ void pathMatcherMatchesExpectedPathsAndRequestParameters() { @Test void pathMatcherMatchesExpectedPathsAndMultipleRequestParameters() { // Accept only JSON - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON.toString())); Map params = new LinkedHashMap<>(); params.put("source", "foo"); @@ -125,7 +143,6 @@ void pathMatcherMatchesExpectedPathsAndMultipleRequestParameters() { @Test void pathMatcherMatchesExpectedPathsAndEmptyParameters() { // Accept only JSON - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON.toString())); matcher.setParameters(Collections.singletonMap("code", "")); assertThat(matcher.matches(request("/somePath", null, "code", "FOO"))).isTrue(); @@ -135,7 +152,6 @@ void pathMatcherMatchesExpectedPathsAndEmptyParameters() { @Test void pathMatcherMatchesExpectedPathsAndRequestParametersWithAcceptHeader() { // Accept only JSON - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON.toString())); matcher.setParameters(Collections.singletonMap("response_type", "token")); assertThat(matcher.matches(request("/somePath", "application/json", "response_type", "token"))).isTrue(); @@ -144,7 +160,6 @@ void pathMatcherMatchesExpectedPathsAndRequestParametersWithAcceptHeader() { @Test void pathMatcherMatchesExpectedPathsAndRequestParametersWithNonMatchingAcceptHeader() { // Accept only JSON - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON.toString())); matcher.setParameters(Collections.singletonMap("response_type", "token")); assertThat(matcher.matches(request("/somePath", "application/html", "response_type", "token"))).isFalse(); @@ -153,18 +168,15 @@ void pathMatcherMatchesExpectedPathsAndRequestParametersWithNonMatchingAcceptHea @Test void pathMatcherMatchesWithMultipleAccepts() { // Accept only JSON - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON.toString())); assertThat(matcher .matches(request("/somePath", "%s,%s".formatted(MediaType.APPLICATION_JSON.toString(), MediaType.APPLICATION_XML.toString())))).isTrue(); } - @Test void pathMatcherMatchesWithMultipleAcceptTargets() { // Accept only JSON - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setAccept(Arrays.asList(MediaType.APPLICATION_JSON.toString(), MediaType.APPLICATION_FORM_URLENCODED.toString())); assertThat(matcher @@ -175,7 +187,6 @@ void pathMatcherMatchesWithMultipleAcceptTargets() { @Test void pathMatcherMatchesWithSingleHeader() { - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setHeaders(Collections.singletonMap("Authorization", Collections.singletonList("Basic"))); MockHttpServletRequest testRequest = request( "/somePath", @@ -188,7 +199,6 @@ void pathMatcherMatchesWithSingleHeader() { @Test void pathMatcherDoesNotMatchInvalidHeader() { - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); matcher.setHeaders(Collections.singletonMap("Authorization", Collections.singletonList("Basic"))); MockHttpServletRequest testRequest = request( "/somePath", @@ -200,7 +210,6 @@ void pathMatcherDoesNotMatchInvalidHeader() { @Test void pathMatcherMatchesOneOfMultipleHeaders() { - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); Map> configMap = new HashMap<>(); configMap.put("Authorization", Arrays.asList(new String[]{"Basic", "Bearer"})); matcher.setHeaders(configMap); @@ -215,7 +224,6 @@ void pathMatcherMatchesOneOfMultipleHeaders() { @Test void pathMatcherDoesNotMatchOneOfMultipleHeaders() { - UaaRequestMatcher matcher = new UaaRequestMatcher("/somePath"); Map> configMap = new HashMap<>(); configMap.put("Authorization", Arrays.asList(new String[]{"Basic", "Bearer"})); matcher.setHeaders(configMap); diff --git a/server/src/test/java/org/cloudfoundry/identity/uaa/util/ZoneRequestPathMode.java b/server/src/test/java/org/cloudfoundry/identity/uaa/util/ZoneRequestPathMode.java new file mode 100644 index 00000000000..db08ab7058d --- /dev/null +++ b/server/src/test/java/org/cloudfoundry/identity/uaa/util/ZoneRequestPathMode.java @@ -0,0 +1,47 @@ +package org.cloudfoundry.identity.uaa.util; + +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.zone.MultitenancyFixture; +import org.springframework.mock.web.MockHttpServletRequest; + +/** + * Request path mode: default path or zone path {@code /z/{subdomain}/...} where subdomain is arbitrary. + * Used by tests to parameterize behavior for zone path-based requests. + */ +public enum ZoneRequestPathMode { + DEFAULT(""), + ZONE_PATH("test-zone"); + + private final String subdomain; + + ZoneRequestPathMode(String subdomain) { + this.subdomain = subdomain; + } + + public String getSubdomain() { + return subdomain; + } + + /** Path prefix for redirects/links: "" for DEFAULT, "/z/test-zone" for ZONE_PATH. */ + public String redirectPrefix() { + return subdomain.isEmpty() ? "" : "/z/" + subdomain; + } + + /** Sets IdentityZoneHolder so it matches this mode. ZONE_PATH uses a test zone; DEFAULT leaves current zone. */ + public void setZone() { + if (this == ZONE_PATH) { + IdentityZoneHolder.set(MultitenancyFixture.identityZone("test-zone-id", subdomain)); + } + } + + public void applyRequestPath(MockHttpServletRequest request, String pathSuffix) { + if (subdomain.isEmpty()) { + request.setRequestURI(pathSuffix); + request.setServletPath(pathSuffix); + } else { + String path = "/z/" + subdomain + pathSuffix; + request.setRequestURI(path); + request.setServletPath(path); + } + } +} diff --git a/uaa/src/main/resources/uaa.yml b/uaa/src/main/resources/uaa.yml index 3f47ed49da0..9759af18a28 100755 --- a/uaa/src/main/resources/uaa.yml +++ b/uaa/src/main/resources/uaa.yml @@ -543,16 +543,27 @@ uaa: whitelist: endpoints: - /oauth/authorize/** + - /z/*/oauth/authorize/** - /oauth/token/** + - /z/*/oauth/token/** - /check_token/** + - /z/*/check_token/** - /login/** + - /z/*/login/** - /login.do + - /z/*/login.do - /logout/** + - /z/*/logout/** - /logout.do + - /z/*/logout.do - /saml/** + - /z/*/saml/** - /autologin/** + - /z/*/autologin/** - /authenticate/** + - /z/*/authenticate/** - /idp_discovery/** + - /z/*/idp_discovery/** methods: - GET - HEAD diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpointMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpointMockMvcTests.java index ac12eae2690..26b492996a2 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpointMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/invitations/InvitationsEndpointMockMvcTests.java @@ -7,6 +7,8 @@ import org.cloudfoundry.identity.uaa.codestore.ExpiringCodeStore; import org.cloudfoundry.identity.uaa.codestore.ExpiringCodeType; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.IdentityZoneCreationResult; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; import org.cloudfoundry.identity.uaa.oauth.common.util.OAuth2Utils; import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.cloudfoundry.identity.uaa.provider.IdentityProvider; @@ -20,6 +22,7 @@ import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; import org.cloudfoundry.identity.uaa.zone.IdentityZoneProvisioning; import org.cloudfoundry.identity.uaa.zone.MultitenantJdbcClientDetailsService; import org.flywaydb.core.internal.util.StringUtils; @@ -29,8 +32,10 @@ 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.EnumSource; import org.junit.jupiter.params.provider.ValueSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpMethod; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -120,6 +125,43 @@ void inviteUserWithClientCredentials() throws Exception { assertResponseAndCodeCorrect(expiringCodeStore, new String[]{email}, redirectUrl, null, response, clientDetails); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void inviteUserWithClientCredentialsWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + ClientDetails zonedClientDetails = MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton("http://redirect.url"), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + String email = "user1@example.com"; + String redirectUrl = "example.com"; + InvitationsRequest invitations = new InvitationsRequest(new String[]{email}); + String requestBody = writeValueAsString(invitations); + + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(OAuth2Utils.CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, redirectUrl) + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(requestBody); + + MvcResult mvcResult = mockMvc.perform(post) + .andExpect(status().isOk()) + .andReturn(); + + InvitationsResponse response = readValue(mvcResult.getResponse().getContentAsString(), InvitationsResponse.class); + assertResponseAndCodeCorrect(expiringCodeStore, new String[]{email}, redirectUrl, mode, zoneResult.getIdentityZone(), response, zonedClientDetails); + } + @Test void inviteMultipleUsersWithClientCredentials() throws Exception { String[] emails = new String[]{"user1@" + emailDomain, "user2@" + emailDomain}; @@ -128,6 +170,41 @@ void inviteMultipleUsersWithClientCredentials() throws Exception { assertResponseAndCodeCorrect(expiringCodeStore, emails, redirectUri, null, response, clientDetails); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void inviteMultipleUsersWithClientCredentialsWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + ClientDetails zonedClientDetails = MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton("http://redirect.url"), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + String[] emails = new String[]{"user1@" + emailDomain, "user2@" + emailDomain}; + String redirectUrl = "example.com"; + InvitationsRequest invitations = new InvitationsRequest(emails); + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(OAuth2Utils.CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, redirectUrl) + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(writeValueAsString(invitations)); + + MvcResult mvcResult = mockMvc.perform(post) + .andExpect(status().isOk()) + .andReturn(); + + InvitationsResponse response = readValue(mvcResult.getResponse().getContentAsString(), InvitationsResponse.class); + assertResponseAndCodeCorrect(expiringCodeStore, emails, redirectUrl, mode, zoneResult.getIdentityZone(), response, zonedClientDetails); + } + @Test void inviteUserWithUserCredentials() throws Exception { String email = "user1@example.com"; @@ -137,6 +214,41 @@ void inviteUserWithUserCredentials() throws Exception { assertResponseAndCodeCorrect(expiringCodeStore, new String[]{email}, redirectUri, null, response, clientDetails); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void inviteUserWithUserCredentialsWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + ClientDetails zonedClientDetails = MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton("http://redirect.url"), zoneResult.getIdentityZone()); + + String userToken = MockMvcUtils.getScimInviteUserToken(mockMvc, zonedClientId, zonedClientSecret, zoneResult.getIdentityZone(), "admin", "admin-secret"); + + String email = "user1@example.com"; + String redirectUrl = "example.com"; + InvitationsRequest invitations = new InvitationsRequest(new String[]{email}); + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(OAuth2Utils.CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, redirectUrl) + .header("Authorization", "Bearer " + userToken) + .contentType(APPLICATION_JSON) + .content(writeValueAsString(invitations)); + + MvcResult mvcResult = mockMvc.perform(post) + .andExpect(status().isOk()) + .andReturn(); + + InvitationsResponse response = readValue(mvcResult.getResponse().getContentAsString(), InvitationsResponse.class); + assertResponseAndCodeCorrect(expiringCodeStore, new String[]{email}, redirectUrl, mode, zoneResult.getIdentityZone(), response, zonedClientDetails); + } + @Nested @DefaultTestContext @ExtendWith(ZoneSeederExtension.class) @@ -370,6 +482,53 @@ void multipleUsersEmailExistsWithOneOrigin() throws Exception { assertThat(response.getFailedInvites().getFirst().getErrorCode()).isEqualTo("user.ambiguous"); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void multipleUsersEmailExistsWithOneOriginWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton("http://redirect.url"), zoneResult.getIdentityZone()); + + String zoneAdminToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(ZoneResolutionMode.SUBDOMAIN, mockMvc, "admin", "admin-secret", "scim.read scim.write", subdomain, false); + String username1 = generator.generate(); + String username2 = generator.generate(); + String email = generator.generate().toLowerCase() + "@" + emailDomain; + ScimUser user1 = new ScimUser(null, username1, "givenName", "familyName"); + user1.setPrimaryEmail(email); + user1.setOrigin(UAA); + user1.setPassword("password"); + MockMvcUtils.createUserInZone(mockMvc, zoneAdminToken, user1, subdomain); + ScimUser user2 = new ScimUser(null, username2, "givenName", "familyName"); + user2.setPrimaryEmail(email); + user2.setOrigin(UAA); + user2.setPassword("password"); + MockMvcUtils.createUserInZone(mockMvc, zoneAdminToken, user2, subdomain); + + String userToken = MockMvcUtils.getScimInviteUserToken(mockMvc, zonedClientId, zonedClientSecret, zoneResult.getIdentityZone(), "admin", "admin-secret"); + + InvitationsRequest invitations = new InvitationsRequest(new String[]{email}); + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(OAuth2Utils.CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, "example.com") + .header("Authorization", "Bearer " + userToken) + .contentType(APPLICATION_JSON) + .content(writeValueAsString(invitations)); + + MvcResult mvcResult = mockMvc.perform(post).andExpect(status().isOk()).andReturn(); + InvitationsResponse response = readValue(mvcResult.getResponse().getContentAsString(), InvitationsResponse.class); + assertThat(response.getNewInvites()).isEmpty(); + assertThat(response.getFailedInvites()).hasSize(1); + assertThat(response.getFailedInvites().getFirst().getErrorCode()).isEqualTo("user.ambiguous"); + } + @Test void inviteUserWithInvalidEmails() throws Exception { String invalidEmail1 = "user1example."; @@ -388,6 +547,47 @@ void inviteUserWithInvalidEmails() throws Exception { assertThat(response.getFailedInvites().get(2).getErrorMessage()).isEqualTo("No authentication provider found."); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void inviteUserWithInvalidEmailsWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton("http://redirect.url"), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + String invalidEmail1 = "user1example."; + String invalidEmail2 = "user1example@"; + String invalidEmail3 = "user1example@invalid"; + String redirectUrl = "test.com"; + InvitationsRequest invitations = new InvitationsRequest(new String[]{invalidEmail1, invalidEmail2, invalidEmail3}); + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(OAuth2Utils.CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, redirectUrl) + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(writeValueAsString(invitations)); + + MvcResult mvcResult = mockMvc.perform(post).andExpect(status().isOk()).andReturn(); + InvitationsResponse response = readValue(mvcResult.getResponse().getContentAsString(), InvitationsResponse.class); + assertThat(response.getNewInvites()).isEmpty(); + assertThat(response.getFailedInvites()).hasSize(3); + assertThat(response.getFailedInvites().getFirst().getErrorCode()).isEqualTo("email.invalid"); + assertThat(response.getFailedInvites().get(1).getErrorCode()).isEqualTo("email.invalid"); + assertThat(response.getFailedInvites().get(2).getErrorCode()).isEqualTo("provider.non-existent"); + assertThat(response.getFailedInvites().getFirst().getErrorMessage()).isEqualTo(invalidEmail1 + " is invalid email."); + assertThat(response.getFailedInvites().get(1).getErrorMessage()).isEqualTo(invalidEmail2 + " is invalid email."); + assertThat(response.getFailedInvites().get(2).getErrorMessage()).isEqualTo("No authentication provider found."); + } + @Test void acceptInvitationEmailWithDefaultCompanyName() throws Exception { mockMvc.perform(get(getAcceptInvitationLink(webApplicationContext, mockMvc, clientId, clientSecret, generator, emailDomain, null, "admin", "adminsecret"))) @@ -395,6 +595,46 @@ void acceptInvitationEmailWithDefaultCompanyName() throws Exception { .andExpect(content().string(containsString("Create account"))); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void acceptInvitationEmailWithDefaultCompanyNameWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton("http://redirect.url"), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + String email = generator.generate().toLowerCase() + "@" + emailDomain; + InvitationsRequest invitations = new InvitationsRequest(new String[]{email}); + MockHttpServletRequestBuilder postReq = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(OAuth2Utils.CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, "example.com") + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(writeValueAsString(invitations)); + MvcResult inviteResult = mockMvc.perform(postReq).andExpect(status().isOk()).andReturn(); + InvitationsResponse inviteResponse = readValue(inviteResult.getResponse().getContentAsString(), InvitationsResponse.class); + assertThat(inviteResponse.getNewInvites()).hasSize(1); + String acceptUrl = inviteResponse.getNewInvites().get(0).getInviteLink().toString(); + String code = acceptUrl.contains("code=") ? acceptUrl.substring(acceptUrl.indexOf("code=") + 5).split("&")[0] : null; + assertThat(code).isNotNull(); + + MockHttpServletRequestBuilder accept = mode.createRequestBuilder(subdomain, HttpMethod.GET, "/invitations/accept") + .param("code", code); + mockMvc.perform(accept) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Create your account"))) + .andExpect(content().string(containsString("Create account"))); + } + @Test void acceptInvitationEmailWithCompanyName() throws Exception { IdentityZoneConfiguration defaultConfig = IdentityZoneHolder.get().getConfig(); @@ -417,22 +657,94 @@ void acceptInvitationEmailWithCompanyName() throws Exception { } } - @Test - void invitationsAcceptGetSecurity(@Autowired JdbcTemplate jdbcTemplate) throws Exception { - jdbcTemplate.update("DELETE FROM expiring_code_store"); + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void acceptInvitationEmailWithCompanyNameWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); - String userToken = MockMvcUtils.getScimInviteUserToken(mockMvc, clientId, clientSecret, null, "admin", "adminsecret"); - sendRequestWithToken(webApplicationContext, mockMvc, userToken, clientId, "user1@" + emailDomain); + BrandingInformation branding = new BrandingInformation(); + branding.setCompanyName("Best Company"); + IdentityZoneConfiguration config = new IdentityZoneConfiguration(); + config.setBranding(branding); + config.setTokenPolicy(IdentityZoneHolder.getUaaZone().getConfig().getTokenPolicy()); + zoneResult.getIdentityZone().setConfig(config); + identityZoneProvisioning.update(zoneResult.getIdentityZone()); - String code = jdbcTemplate.queryForObject("SELECT code FROM expiring_code_store", String.class); + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton("http://redirect.url"), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + String email = generator.generate().toLowerCase() + "@" + emailDomain; + InvitationsRequest invitations = new InvitationsRequest(new String[]{email}); + MockHttpServletRequestBuilder postReq = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(OAuth2Utils.CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, "example.com") + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(writeValueAsString(invitations)); + MvcResult inviteResult = mockMvc.perform(postReq).andExpect(status().isOk()).andReturn(); + InvitationsResponse inviteResponse = readValue(inviteResult.getResponse().getContentAsString(), InvitationsResponse.class); + assertThat(inviteResponse.getNewInvites()).hasSize(1); + String acceptUrl = inviteResponse.getNewInvites().get(0).getInviteLink().toString(); + String code = acceptUrl.contains("code=") ? acceptUrl.substring(acceptUrl.indexOf("code=") + 5).split("&")[0] : null; + assertThat(code).isNotNull(); + + MockHttpServletRequestBuilder accept = mode.createRequestBuilder(subdomain, HttpMethod.GET, "/invitations/accept") + .param("code", code); + mockMvc.perform(accept) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Best Company"))) + .andExpect(content().string(containsString("Create your account"))); + } + + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void invitationsAcceptGetSecurity(ZoneResolutionMode mode, @Autowired JdbcTemplate jdbcTemplate) throws Exception { + jdbcTemplate.update("DELETE FROM expiring_code_store"); + + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton("http://redirect.url"), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + InvitationsRequest invitations = new InvitationsRequest(new String[]{"user1@" + emailDomain}); + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(OAuth2Utils.CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, "example.com") + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(writeValueAsString(invitations)); + mockMvc.perform(post).andExpect(status().isOk()); + + String code = jdbcTemplate.queryForObject("SELECT code FROM expiring_code_store WHERE identity_zone_id = ?", String.class, zoneResult.getIdentityZone().getId()); assertThat(code).as("Invite Code Must be Present").isNotNull(); - MockHttpServletRequestBuilder accept = get("/invitations/accept") + MockHttpServletRequestBuilder accept = mode.createRequestBuilder(subdomain, HttpMethod.GET, "/invitations/accept") .param("code", code); + String expectedFormAction = mode == ZoneResolutionMode.ZONE_PATH + ? ""))); + .andExpect(content().string(containsString(expectedFormAction))); } private static InvitationsResponse sendRequestWithTokenAndReturnResponse(WebApplicationContext webApplicationContext, @@ -453,6 +765,25 @@ private static void sendRequestWithToken(WebApplicationContext webApplicationCon } private static void assertResponseAndCodeCorrect(ExpiringCodeStore expiringCodeStore, String[] emails, String redirectUrl, IdentityZone zone, InvitationsResponse response, ClientDetails clientDetails) { + String expectedLinkPrefix = (zone != null && StringUtils.hasText(zone.getSubdomain())) + ? "http://" + zone.getSubdomain() + ".localhost/invitations/accept" + : "http://localhost/invitations/accept"; + assertResponseAndCodeCorrect(expiringCodeStore, emails, redirectUrl, expectedLinkPrefix, zone, response, clientDetails); + } + + private static void assertResponseAndCodeCorrect(ExpiringCodeStore expiringCodeStore, String[] emails, String redirectUrl, ZoneResolutionMode mode, IdentityZone zone, InvitationsResponse response, ClientDetails clientDetails) { + String expectedLinkPrefix; + if (zone == null || !StringUtils.hasText(zone.getSubdomain())) { + expectedLinkPrefix = "http://localhost/invitations/accept"; + } else if (mode == ZoneResolutionMode.ZONE_PATH) { + expectedLinkPrefix = "http://localhost/z/" + zone.getSubdomain() + "/invitations/accept"; + } else { + expectedLinkPrefix = "http://" + zone.getSubdomain() + ".localhost/invitations/accept"; + } + assertResponseAndCodeCorrect(expiringCodeStore, emails, redirectUrl, expectedLinkPrefix, zone, response, clientDetails); + } + + private static void assertResponseAndCodeCorrect(ExpiringCodeStore expiringCodeStore, String[] emails, String redirectUrl, String expectedLinkPrefix, IdentityZone zone, InvitationsResponse response, ClientDetails clientDetails) { for (int i = 0; i < emails.length; i++) { assertThat(response.getNewInvites()).hasSameSizeAs(emails); assertThat(response.getNewInvites().get(i).getEmail()).isEqualTo(emails[i]); @@ -463,11 +794,9 @@ private static void assertResponseAndCodeCorrect(ExpiringCodeStore expiringCodeS String link = response.getNewInvites().get(i).getInviteLink().toString(); assertThat(contains(link, "@")).isFalse(); assertThat(contains(link, "%40")).isFalse(); - if (zone != null && StringUtils.hasText(zone.getSubdomain())) { - assertThat(link).startsWith("http://" + zone.getSubdomain() + ".localhost/invitations/accept"); + assertThat(link).startsWith(expectedLinkPrefix); + if (zone != null) { IdentityZoneHolder.set(zone); - } else { - assertThat(link).startsWith("http://localhost/invitations/accept"); } String query = response.getNewInvites().get(i).getInviteLink().getQuery(); 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 73a65337277..44c595dce54 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 @@ -19,7 +19,10 @@ import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.util.SessionUtils; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpMethod; import org.cloudfoundry.identity.uaa.zone.BrandingInformation; import org.cloudfoundry.identity.uaa.zone.Consent; import org.cloudfoundry.identity.uaa.zone.IdentityZone; @@ -60,7 +63,6 @@ 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 class AccountsControllerMockMvcTests { @@ -114,13 +116,13 @@ void createActivationEmailPage() throws Exception { .andExpect(content().string(containsString("Create your account"))); } - @Test - void createActivationEmailPageWithinZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void createActivationEmailPageWithinZone(ZoneResolutionMode mode) throws Exception { String subdomain = generator.generate(); MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, IdentityZoneHolder.getCurrentZoneId()); - mockMvc.perform(get("/create_account") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/create_account")) .andExpect(content().string(containsString("Create your account"))); } @@ -132,13 +134,13 @@ void activationEmailSentPage() throws Exception { .andExpect(xpath("//input[@disabled='disabled']/@value").string("Email successfully sent")); } - @Test - void activationEmailSentPageWithinZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void activationEmailSentPageWithinZone(ZoneResolutionMode mode) throws Exception { String subdomain = generator.generate(); MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, IdentityZoneHolder.getCurrentZoneId()); - mockMvc.perform(get("/accounts/email_sent") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/accounts/email_sent")) .andExpect(status().isOk()) .andExpect(content().string(containsString("Create your account"))) .andExpect(xpath("//input[@disabled='disabled']/@value").string("Email successfully sent")) @@ -151,42 +153,42 @@ void pageTitle() throws Exception { .andExpect(content().string(containsString("Cloud Foundry"))); } - @Test - void pageTitleWithinZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void pageTitleWithinZone(ZoneResolutionMode mode) throws Exception { String subdomain = generator.generate(); IdentityZone zone = MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, IdentityZoneHolder.getCurrentZoneId()); - mockMvc.perform(get("/create_account") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/create_account")) .andExpect(content().string(containsString("" + zone.getName() + ""))); } - @Test - void createAccountWithDisableSelfService() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void createAccountWithDisableSelfService(ZoneResolutionMode mode) throws Exception { String subdomain = generator.generate(); IdentityZone zone = MultitenancyFixture.identityZone(subdomain, subdomain); zone.getConfig().getLinks().getSelfService().setSelfServiceLinksEnabled(false); MockMvcUtils.createOtherIdentityZoneAndReturnResult(mockMvc, webApplicationContext, getUaaBaseClientDetails(), zone, IdentityZoneHolder.getCurrentZoneId()); - mockMvc.perform(get("/create_account") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/create_account")) .andExpect(model().attribute("error_message_code", "self_service_disabled")) .andExpect(view().name("error")) .andExpect(status().isNotFound()); } - @Test - void disableSelfServiceCreateAccountPost() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void disableSelfServiceCreateAccountPost(ZoneResolutionMode mode) throws Exception { String subdomain = generator.generate(); IdentityZone zone = MultitenancyFixture.identityZone(subdomain, subdomain); zone.getConfig().getLinks().getSelfService().setSelfServiceLinksEnabled(false); MockMvcUtils.createOtherIdentityZoneAndReturnResult(mockMvc, webApplicationContext, getUaaBaseClientDetails(), zone, IdentityZoneHolder.getCurrentZoneId()); - mockMvc.perform(post("/create_account.do") + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/create_account.do") .with(cookieCsrf()) - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) .param("email", userEmail) .param("password", "secr3T") .param("password_confirmation", "secr3T")) @@ -201,13 +203,13 @@ void defaultZoneLogoNull_useAssetBaseUrlImage() throws Exception { .andExpect(content().string(containsString("background-image: url(/resources/oss/images/product-logo.png);"))); } - @Test - void zoneLogoNull_doNotDisplayImage() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void zoneLogoNull_doNotDisplayImage(ZoneResolutionMode mode) throws Exception { String subdomain = generator.generate(); MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, IdentityZoneHolder.getCurrentZoneId()); - mockMvc.perform(get("/create_account") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/create_account")) .andExpect(content().string(not(containsString("background-image: url(/resources/oss/images/product-logo.png);")))); } @@ -326,9 +328,10 @@ void creatingAnAccountWithNoClientRedirect() throws Exception { assertThat(principal.getOrigin()).isEqualTo(OriginKeys.UAA); } - @Test - void creatingAnAccountInAnotherZoneWithNoClientRedirect() throws Exception { - String subdomain = "mysubdomain2"; + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void creatingAnAccountInAnotherZoneWithNoClientRedirect(ZoneResolutionMode mode) throws Exception { + String subdomain = "mysubdomain2-" + mode.name().toLowerCase(); PredictableGenerator generator = new PredictableGenerator(); JdbcExpiringCodeStore store = webApplicationContext.getBean(JdbcExpiringCodeStore.class); store.setGenerator(generator); @@ -345,9 +348,8 @@ void creatingAnAccountInAnotherZoneWithNoClientRedirect() throws Exception { .content(JsonUtils.writeValueAsString(identityZone))) .andExpect(status().isCreated()); - mockMvc.perform(post("/create_account.do") + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/create_account.do") .with(cookieCsrf()) - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) .param("email", userEmail) .param("password", USER_PASSWORD) .param("password_confirmation", USER_PASSWORD)) @@ -363,9 +365,8 @@ 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"))) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/verify_user") + .param("code", "test" + generator.counter.get())) .andExpect(status().isFound()) .andExpect(redirectedUrl(LOGIN_REDIRECT)) .andReturn(); @@ -383,9 +384,10 @@ void creatingAnAccountInAnotherZoneWithNoClientRedirect() throws Exception { assertThat(principal.getOrigin()).isEqualTo(OriginKeys.UAA); } - @Test - void creatingAnAccountInAnotherZoneWithClientRedirect() throws Exception { - String subdomain = "mysubdomain1"; + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void creatingAnAccountInAnotherZoneWithClientRedirect(ZoneResolutionMode mode) throws Exception { + String subdomain = "mysubdomain1-" + mode.name().toLowerCase(); PredictableGenerator generator = new PredictableGenerator(); JdbcExpiringCodeStore store = webApplicationContext.getBean(JdbcExpiringCodeStore.class); store.setGenerator(generator); @@ -397,8 +399,7 @@ void creatingAnAccountInAnotherZoneWithClientRedirect() throws Exception { MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, getUaaBaseClientDetails(), IdentityZoneHolder.getCurrentZoneId()); - mockMvc.perform(post("/create_account.do") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/create_account.do") .with(cookieCsrf()) .param("email", userEmail) .param("password", "secr3T") @@ -413,9 +414,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"))) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/verify_user") + .param("code", "test" + generator.counter.get())) .andExpect(redirectedUrl(LOGIN_REDIRECT + "&form_redirect_uri=http://myzoneclient.example.com")) .andReturn(); @@ -490,8 +490,9 @@ void ifInvalidOrExpiredCode_withNonDefaultSignupLinkProperty_goToNonDefaultSignu .andExpect(xpath("//a[text()='here']/@href").string(signUpLink)); } - @Test - void consentIfConfiguredDisplaysConsentTextAndLink() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void consentIfConfiguredDisplaysConsentTextAndLink(ZoneResolutionMode mode) throws Exception { String randomZoneSubdomain = generator.generate(); String consentText = "Terms and Conditions"; String consentLink = "http://google.com"; @@ -504,14 +505,14 @@ void consentIfConfiguredDisplaysConsentTextAndLink() throws Exception { zone.getConfig().getBranding().getConsent().setLink(consentLink); MockMvcUtils.updateZone(mockMvc, zone); - mockMvc.perform(get("/create_account") - .with(new SetServerNameRequestPostProcessor(randomZoneSubdomain + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(randomZoneSubdomain, HttpMethod.GET, "/create_account")) .andExpect(content().string(containsString(consentText))) .andExpect(content().string(containsString(consentLink))); } - @Test - void consentIfConfiguredDisplayConsentTextWhenNoLinkConfigured() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void consentIfConfiguredDisplayConsentTextWhenNoLinkConfigured(ZoneResolutionMode mode) throws Exception { String randomZoneSubdomain = generator.generate(); String consentText = "Terms and Conditions"; IdentityZone zone = MockMvcUtils.createOtherIdentityZone( @@ -522,13 +523,13 @@ void consentIfConfiguredDisplayConsentTextWhenNoLinkConfigured() throws Exceptio zone.getConfig().getBranding().getConsent().setText(consentText); MockMvcUtils.updateZone(mockMvc, zone); - mockMvc.perform(get("/create_account") - .with(new SetServerNameRequestPostProcessor(randomZoneSubdomain + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(randomZoneSubdomain, HttpMethod.GET, "/create_account")) .andExpect(content().string(containsString(consentText))); } - @Test - void consentIfConfiguredDisplaysMeaningfulErrorWhenConsentNotProvided() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void consentIfConfiguredDisplaysMeaningfulErrorWhenConsentNotProvided(ZoneResolutionMode mode) throws Exception { String randomZoneSubdomain = generator.generate(); String consentText = "Terms and Conditions"; IdentityZone zone = MockMvcUtils.createOtherIdentityZone( @@ -539,8 +540,7 @@ void consentIfConfiguredDisplaysMeaningfulErrorWhenConsentNotProvided() throws E zone.getConfig().getBranding().getConsent().setText(consentText); MockMvcUtils.updateZone(mockMvc, zone); - mockMvc.perform(post("/create_account.do") - .with(new SetServerNameRequestPostProcessor(randomZoneSubdomain + ".localhost")) + mockMvc.perform(mode.createRequestBuilder(randomZoneSubdomain, HttpMethod.POST, "/create_account.do") .with(cookieCsrf()) .param("email", userEmail) .param("password", USER_PASSWORD) @@ -602,16 +602,15 @@ private void createAccount(String expectedRedirectUri, String redirectUri) throw } private ResultActions loginWithAccount(String subdomain) throws Exception { + return loginWithAccount(ZoneResolutionMode.SUBDOMAIN, subdomain); + } - MockHttpServletRequestBuilder req = post("/login.do") + private ResultActions loginWithAccount(ZoneResolutionMode mode, String subdomain) throws Exception { + MockHttpServletRequestBuilder req = mode.createRequestBuilder(subdomain != null ? subdomain : "", HttpMethod.POST, "/login.do") .param("username", userEmail) .param("password", USER_PASSWORD) .with(cookieCsrf()); - if (hasText(subdomain)) { - req.with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")); - } - return mockMvc.perform(req) .andExpect(status().isFound()); } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeControllerMockMvcTest.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeControllerMockMvcTest.java index 6cda9b9f762..c2a912e8f51 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeControllerMockMvcTest.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/ForcePasswordChangeControllerMockMvcTest.java @@ -16,11 +16,13 @@ import org.cloudfoundry.identity.uaa.util.SessionUtils; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.junit.jupiter.api.AfterEach; 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.EnumSource; import org.junit.jupiter.params.provider.MethodSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mock.web.MockHttpSession; @@ -35,6 +37,8 @@ import java.util.Date; import java.util.stream.Stream; +import org.junit.jupiter.params.provider.Arguments; + import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.CookieCsrfPostProcessor.cookieCsrf; import static org.springframework.http.MediaType.APPLICATION_JSON; @@ -48,16 +52,36 @@ @DefaultTestContext class ForcePasswordChangeControllerMockMvcTest { + /** Whether the test uses the default path or the zone path prefix {@code /z/{subdomain}/}. */ + enum RequestPathMode { + DEFAULT, + ZONE_PATH + } + + private static final String ZONE_PATH_SUBDOMAIN = "testsubdomain"; + private ScimUser user; private String token; private IdentityProviderProvisioning identityProviderProvisioning; private IdentityZoneConfiguration uaaZoneConfig; + private MockMvcUtils.IdentityZoneCreationResult zonePathZone; @Autowired private WebApplicationContext webApplicationContext; @Autowired private MockMvc mockMvc; + /** Returns path prefix for the mode; for ZONE_PATH ensures the zone exists and returns {@code /z/subdomain}. */ + private String pathPrefixFor(RequestPathMode mode) throws Exception { + if (mode == RequestPathMode.ZONE_PATH) { + if (zonePathZone == null) { + zonePathZone = MockMvcUtils.createOtherIdentityZoneAndReturnResult(ZONE_PATH_SUBDOMAIN, mockMvc, webApplicationContext, null, IdentityZoneHolder.getCurrentZoneId()); + } + return "/z/" + ZONE_PATH_SUBDOMAIN; + } + return ""; + } + @BeforeEach void setup() throws Exception { String username = new AlphanumericRandomValueStringGenerator().generate() + "@test.org"; @@ -73,6 +97,7 @@ void setup() throws Exception { @AfterEach void cleanup() { MockMvcUtils.setZoneConfiguration(webApplicationContext, "uaa", uaaZoneConfig); + IdentityZoneHolder.set(IdentityZone.getUaa()); } @Nested @@ -92,11 +117,14 @@ void setup() throws Exception { .andExpect(status().isOk()); } - @Test - void requires_user_to_change_password() throws Exception { + @ParameterizedTest + @EnumSource(value = RequestPathMode.class, names = {"DEFAULT"}) + void requires_user_to_change_password(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); + MockHttpSession session = new MockHttpSession(); - MockHttpServletRequestBuilder userForcePasswordChangePostLogin = post("/login.do") + MockHttpServletRequestBuilder userForcePasswordChangePostLogin = post(pathPrefix + "/login.do") .param("username", user.getUserName()) .param("password", "secret") .session(session) @@ -104,34 +132,34 @@ void requires_user_to_change_password() throws Exception { .param(CookieBasedCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, "csrf1"); mockMvc.perform(userForcePasswordChangePostLogin) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/")); + .andExpect(redirectedUrl(pathPrefix + "/")); assertThat(((SecurityContext) ((HttpSession) session).getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).getAuthentication().isAuthenticated()).isTrue(); assertThat(SessionUtils.isPasswordChangeRequired(session)).isTrue(); - mockMvc.perform(get("/") + mockMvc.perform(get(pathPrefix + "/") .session(session)) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/force_password_change")); + .andExpect(redirectedUrl(pathPrefix + "/force_password_change")); assertThat(((SecurityContext) ((HttpSession) session).getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).getAuthentication().isAuthenticated()).isTrue(); assertThat(SessionUtils.isPasswordChangeRequired(session)).isTrue(); - MockHttpServletRequestBuilder validPost = post("/force_password_change") + MockHttpServletRequestBuilder validPost = post(pathPrefix + "/force_password_change") .param("password", "test") .param("password_confirmation", "test") .session(session) .with(cookieCsrf()); mockMvc.perform(validPost) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/force_password_change_completed")); + .andExpect(redirectedUrl(pathPrefix + "/force_password_change_completed")); assertThat(((SecurityContext) ((HttpSession) session).getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).getAuthentication().isAuthenticated()).isTrue(); assertThat(SessionUtils.isPasswordChangeRequired(session)).isFalse(); - mockMvc.perform(get("/force_password_change_completed") + mockMvc.perform(get(pathPrefix + "/force_password_change_completed") .session(session)) .andExpect(status().isFound()) - .andExpect(redirectedUrl("http://localhost/")); + .andExpect(redirectedUrl(pathPrefix.isEmpty() ? "http://localhost/" : "http://localhost" + pathPrefix + "/")); assertThat(((SecurityContext) ((HttpSession) session).getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).getAuthentication().isAuthenticated()).isTrue(); assertThat(SessionUtils.isPasswordChangeRequired(session)).isFalse(); } @@ -157,8 +185,11 @@ void cleanup() { } @ParameterizedTest - @MethodSource("org.cloudfoundry.identity.uaa.login.ForcePasswordChangeControllerMockMvcTest#authenticationTestParams") - void force_password_change_with_invalid_password(PasswordPolicyWithInvalidPassword passwordPolicyWithInvalidPassword) throws Exception { + @MethodSource("org.cloudfoundry.identity.uaa.login.ForcePasswordChangeControllerMockMvcTest#authenticationTestParamsWithModeDefaultOnly") + void force_password_change_with_invalid_password(PasswordPolicyWithInvalidPassword passwordPolicyWithInvalidPassword, RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); + identityProvider.setConfig(new UaaIdentityProviderDefinition(passwordPolicyWithInvalidPassword.passwordPolicy, null)); + identityProviderProvisioning.update(identityProvider, identityProvider.getIdentityZoneId()); UserAccountStatus userAccountStatus = new UserAccountStatus(); userAccountStatus.setPasswordChangeRequired(true); String jsonStatus = JsonUtils.writeValueAsString(userAccountStatus); @@ -172,10 +203,7 @@ void force_password_change_with_invalid_password(PasswordPolicyWithInvalidPasswo MockHttpSession session = new MockHttpSession(); Cookie cookie = new Cookie(CookieBasedCsrfTokenRepository.DEFAULT_CSRF_COOKIE_NAME, "csrf1"); - identityProvider.setConfig(new UaaIdentityProviderDefinition(passwordPolicyWithInvalidPassword.passwordPolicy, null)); - identityProviderProvisioning.update(identityProvider, identityProvider.getIdentityZoneId()); - - MockHttpServletRequestBuilder invalidPost = post("/login.do") + MockHttpServletRequestBuilder invalidPost = post(pathPrefix + "/login.do") .param("username", user.getUserName()) .param("password", "secret") .session(session) @@ -184,7 +212,7 @@ void force_password_change_with_invalid_password(PasswordPolicyWithInvalidPasswo mockMvc.perform(invalidPost) .andExpect(status().isFound()); - MockHttpServletRequestBuilder validPost = post("/force_password_change") + MockHttpServletRequestBuilder validPost = post(pathPrefix + "/force_password_change") .param("password", passwordPolicyWithInvalidPassword.password) .param("password_confirmation", passwordPolicyWithInvalidPassword.password) .session(session) @@ -196,16 +224,17 @@ void force_password_change_with_invalid_password(PasswordPolicyWithInvalidPasswo .andExpect(model().attribute("email", user.getPrimaryEmail())); } - @Test - void force_password_when_system_was_configured() throws Exception { + @ParameterizedTest + @EnumSource(value = RequestPathMode.class, names = {"DEFAULT"}) + void force_password_when_system_was_configured(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); PasswordPolicy passwordPolicy = new PasswordPolicy(4, 20, 0, 0, 0, 0, 0); passwordPolicy.setPasswordNewerThan(new Date(System.currentTimeMillis())); identityProvider.setConfig(new UaaIdentityProviderDefinition(passwordPolicy, null)); - identityProviderProvisioning.update(identityProvider, identityProvider.getIdentityZoneId()); MockHttpSession session = new MockHttpSession(); - MockHttpServletRequestBuilder invalidPost = post("/login.do") + MockHttpServletRequestBuilder invalidPost = post(pathPrefix + "/login.do") .param("username", user.getUserName()) .param("password", "secret") .session(session) @@ -214,16 +243,16 @@ void force_password_when_system_was_configured() throws Exception { mockMvc.perform(invalidPost) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/")); + .andExpect(redirectedUrl(pathPrefix + "/")); mockMvc.perform( - get("/") + get(pathPrefix + "/") .session(session) ) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/force_password_change")); + .andExpect(redirectedUrl(pathPrefix + "/force_password_change")); - MockHttpServletRequestBuilder validPost = post("/force_password_change") + MockHttpServletRequestBuilder validPost = post(pathPrefix + "/force_password_change") .param("password", "test") .param("password_confirmation", "test") .session(session) @@ -231,19 +260,21 @@ void force_password_when_system_was_configured() throws Exception { mockMvc.perform(validPost) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/force_password_change_completed")); + .andExpect(redirectedUrl(pathPrefix + "/force_password_change_completed")); - mockMvc.perform(get("/force_password_change_completed") + mockMvc.perform(get(pathPrefix + "/force_password_change_completed") .session(session)) .andExpect(status().isFound()) - .andExpect(redirectedUrl("http://localhost/")); + .andExpect(redirectedUrl(pathPrefix.isEmpty() ? "http://localhost/" : "http://localhost" + pathPrefix + "/")); assertThat(((SecurityContext) ((HttpSession) session).getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).getAuthentication().isAuthenticated()).isTrue(); assertThat(SessionUtils.isPasswordChangeRequired(session)).isFalse(); } } - @Test - void submit_password_change_when_not_authenticated() throws Exception { + @ParameterizedTest + @EnumSource(RequestPathMode.class) + void submit_password_change_when_not_authenticated(RequestPathMode mode) throws Exception { + String pathPrefix = pathPrefixFor(mode); UserAccountStatus userAccountStatus = new UserAccountStatus(); userAccountStatus.setPasswordChangeRequired(true); String jsonStatus = JsonUtils.writeValueAsString(userAccountStatus); @@ -255,12 +286,13 @@ void submit_password_change_when_not_authenticated() throws Exception { .content(jsonStatus)) .andExpect(status().isOk()); - MockHttpServletRequestBuilder validPost = post("/force_password_change") + MockHttpServletRequestBuilder validPost = post(pathPrefix + "/force_password_change") .param("password", "test") .param("password_confirmation", "test"); validPost.with(cookieCsrf()); mockMvc.perform(validPost) .andExpect(status().isFound()) + // Unauthenticated redirect: default zone goes to /login; zone path currently redirects to /login (not zone-prefixed) .andExpect(redirectedUrl("http://localhost/login")); } @@ -285,7 +317,16 @@ static Stream authenticationTestParams() { new PasswordPolicyWithInvalidPassword(new PasswordPolicy(0, 1, 0, 0, 1, 0, 0), "a", "Password must contain at least 1 digit characters."), new PasswordPolicyWithInvalidPassword(new PasswordPolicy(0, 1, 0, 0, 0, 1, 0), "a", "Password must contain at least 1 special characters.") ); + } + + static Stream authenticationTestParamsWithMode() { + return authenticationTestParams().flatMap(pp -> + Stream.of(RequestPathMode.DEFAULT, RequestPathMode.ZONE_PATH).map(mode -> Arguments.of(pp, mode))); + } + /** Same as authenticationTestParamsWithMode but DEFAULT only (avoids zone user creation in test). */ + static Stream authenticationTestParamsWithModeDefaultOnly() { + return authenticationTestParams().map(pp -> Arguments.of(pp, RequestPathMode.DEFAULT)); } } 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 81c43fdbd2c..321585e6852 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 @@ -19,8 +19,11 @@ import org.cloudfoundry.identity.uaa.constants.OriginKeys; import org.cloudfoundry.identity.uaa.message.EmailService; import org.cloudfoundry.identity.uaa.message.util.FakeJavaMailSender; +import org.cloudfoundry.identity.uaa.invitations.InvitationsRequest; +import org.cloudfoundry.identity.uaa.invitations.InvitationsResponse; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.IdentityZoneCreationResult; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneScimInviteData; import org.cloudfoundry.identity.uaa.oauth.common.util.OAuth2Utils; import org.cloudfoundry.identity.uaa.provider.AbstractIdentityProviderDefinition; @@ -33,7 +36,10 @@ import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.mail.javamail.JavaMailSender; @@ -45,10 +51,17 @@ import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; import org.springframework.web.context.WebApplicationContext; -import java.net.URL; import java.util.Arrays; import java.util.Collections; +import org.cloudfoundry.identity.uaa.oauth.provider.ClientDetails; +import org.cloudfoundry.identity.uaa.util.JsonUtils; + +import java.net.URL; + +import static org.cloudfoundry.identity.uaa.oauth.common.util.OAuth2Utils.CLIENT_ID; +import static org.springframework.http.MediaType.APPLICATION_JSON; + import static org.assertj.core.api.Assertions.assertThat; import static org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.CookieCsrfPostProcessor.cookieCsrf; import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_AUTHORIZATION_CODE; @@ -120,6 +133,37 @@ void inviteUserCorrectOriginSet() throws Exception { inviteUser(webApplicationContext, mockMvc, email, userInviteToken, null, clientId, OriginKeys.UAA); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void inviteUserCorrectOriginSetWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton(REDIRECT_URI), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + String email = generator.generate().toLowerCase() + "@test.org"; + InvitationsRequest invitations = new InvitationsRequest(new String[]{email}); + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, REDIRECT_URI) + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(invitations))) + .andExpect(status().isOk()); + + String zoneId = zoneResult.getIdentityZone().getId(); + assertThat(jdbcTemplate.queryForObject("SELECT origin FROM users WHERE email=? AND identity_zone_id=?", String.class, email, zoneId)).isEqualTo(OriginKeys.UAA); + } + @Test void authorizeWithInvitationLogin() throws Exception { String email = new AlphanumericRandomValueStringGenerator().generate().toLowerCase() + "@test.org"; @@ -163,6 +207,69 @@ void authorizeWithInvitationLogin() throws Exception { .doesNotContain("code"); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void authorizeWithInvitationLoginWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton(REDIRECT_URI), zoneResult.getIdentityZone()); + + String userToken = MockMvcUtils.getScimInviteUserToken(mockMvc, zonedClientId, zonedClientSecret, zoneResult.getIdentityZone(), "admin", "admin-secret"); + + String email = generator.generate().toLowerCase() + "@test.org"; + InvitationsRequest invitations = new InvitationsRequest(new String[]{email}); + MockHttpServletRequestBuilder postReq = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, REDIRECT_URI) + .header("Authorization", "Bearer " + userToken) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(invitations)); + MvcResult inviteResult = mockMvc.perform(postReq).andExpect(status().isOk()).andReturn(); + InvitationsResponse response = JsonUtils.readValue(inviteResult.getResponse().getContentAsString(), InvitationsResponse.class); + assertThat(response.getNewInvites()).hasSize(1); + String code = extractInvitationCode(response.getNewInvites().get(0).getInviteLink().toString()); + + MvcResult result = mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/invitations/accept") + .param("code", code) + .accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Email: " + email))) + .andReturn(); + MockHttpSession inviteSession = (MockHttpSession) result.getRequest().getSession(false); + assertThat(inviteSession).isNotNull(); + assertThat(inviteSession.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY)).isNotNull(); + + String redirectUri = "https://example.com/dashboard/?appGuid=app-guid"; + String authClientId = "authclient-" + generator.generate(); + UaaClientDetails client = new UaaClientDetails(authClientId, "", "openid", GRANT_TYPE_AUTHORIZATION_CODE, "", redirectUri); + client.setClientSecret("secret"); + String adminToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mockMvc, "admin", "adminsecret", "", null); + MockMvcUtils.createClient(mockMvc, adminToken, client); + + String state = generator.generate(); + MockHttpServletRequestBuilder authRequest = get("/oauth/authorize") + .session(inviteSession) + .param(OAuth2Utils.RESPONSE_TYPE, "code") + .param(OAuth2Utils.SCOPE, "openid") + .param(OAuth2Utils.STATE, state) + .param(OAuth2Utils.CLIENT_ID, authClientId) + .param(OAuth2Utils.REDIRECT_URI, redirectUri); + + result = mockMvc.perform(authRequest) + .andExpect(status().is3xxRedirection()) + .andReturn(); + String location = result.getResponse().getHeader("Location"); + assertThat(location).contains("/login").doesNotContain("code"); + } + @Test void acceptInvitationShouldNotLogYouIn() throws Exception { String email = new AlphanumericRandomValueStringGenerator().generate().toLowerCase() + "@test.org"; @@ -188,6 +295,48 @@ void acceptInvitationShouldNotLogYouIn() throws Exception { .andExpect(redirectedUrlPattern("**/login")); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void acceptInvitationShouldNotLogYouInWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton(REDIRECT_URI), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + String email = generator.generate().toLowerCase() + "@test.org"; + InvitationsRequest invitations = new InvitationsRequest(new String[]{email}); + MockHttpServletRequestBuilder postReq = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, REDIRECT_URI) + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(invitations)); + MvcResult inviteResult = mockMvc.perform(postReq).andExpect(status().isOk()).andReturn(); + InvitationsResponse response = JsonUtils.readValue(inviteResult.getResponse().getContentAsString(), InvitationsResponse.class); + String code = extractInvitationCode(response.getNewInvites().get(0).getInviteLink().toString()); + + MvcResult result = mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/invitations/accept") + .param("code", code) + .accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Email: " + email))) + .andReturn(); + + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(false); + mockMvc.perform(get("/profile").session(session).accept(MediaType.TEXT_HTML)) + .andExpect(status().isFound()) + .andExpect(redirectedUrlPattern("**/login")); + } + @Test void acceptInvitationForVerifiedUserSendsRedirect() throws Exception { String email = new AlphanumericRandomValueStringGenerator().generate().toLowerCase() + "@test.org"; @@ -207,6 +356,84 @@ void acceptInvitationForVerifiedUserSendsRedirect() throws Exception { .andExpect(redirectedUrl(REDIRECT_URI)); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void acceptInvitationForVerifiedUserSendsRedirectWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton(REDIRECT_URI), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + String email = generator.generate().toLowerCase() + "@test.org"; + InvitationsRequest invitations = new InvitationsRequest(new String[]{email}); + MockHttpServletRequestBuilder postReq = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, REDIRECT_URI) + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(invitations)); + MvcResult inviteResult = mockMvc.perform(postReq).andExpect(status().isOk()).andReturn(); + InvitationsResponse response = JsonUtils.readValue(inviteResult.getResponse().getContentAsString(), InvitationsResponse.class); + String code = extractInvitationCode(response.getNewInvites().get(0).getInviteLink().toString()); + + String zoneId = zoneResult.getIdentityZone().getId(); + jdbcTemplate.update("UPDATE users SET verified=true WHERE email=? AND identity_zone_id=?", email, zoneId); + + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/invitations/accept") + .param("code", code) + .accept(MediaType.TEXT_HTML)) + .andExpect(status().isFound()) + .andExpect(redirectedUrl(REDIRECT_URI)); + } + + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void acceptInvitationPageWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton(REDIRECT_URI), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + String email = generator.generate().toLowerCase() + "@test.org"; + InvitationsRequest invitations = new InvitationsRequest(new String[]{email}); + MockHttpServletRequestBuilder postReq = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, REDIRECT_URI) + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(invitations)); + + MvcResult inviteResult = mockMvc.perform(postReq).andExpect(status().isOk()).andReturn(); + InvitationsResponse response = JsonUtils.readValue(inviteResult.getResponse().getContentAsString(), InvitationsResponse.class); + assertThat(response.getNewInvites()).hasSize(1); + URL inviteLink = response.getNewInvites().get(0).getInviteLink(); + String code = extractInvitationCode(inviteLink.toString()); + + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/invitations/accept") + .param("code", code) + .accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Email: " + email))); + } + @Test void acceptInvitationForUaaUserShouldNotExpireInvitelink() throws Exception { String email = new AlphanumericRandomValueStringGenerator().generate().toLowerCase() + "@test.org"; @@ -225,6 +452,43 @@ void acceptInvitationForUaaUserShouldNotExpireInvitelink() throws Exception { .andExpect(status().isOk()); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void acceptInvitationForUaaUserShouldNotExpireInvitelinkWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton(REDIRECT_URI), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + String email = generator.generate().toLowerCase() + "@test.org"; + InvitationsRequest invitations = new InvitationsRequest(new String[]{email}); + MockHttpServletRequestBuilder postReq = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, REDIRECT_URI) + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(invitations)); + MvcResult inviteResult = mockMvc.perform(postReq).andExpect(status().isOk()).andReturn(); + InvitationsResponse response = JsonUtils.readValue(inviteResult.getResponse().getContentAsString(), InvitationsResponse.class); + String code = extractInvitationCode(response.getNewInvites().get(0).getInviteLink().toString()); + + MockHttpServletRequestBuilder get = mode.createRequestBuilder(subdomain, HttpMethod.GET, "/invitations/accept") + .param("code", code) + .accept(MediaType.TEXT_HTML); + mockMvc.perform(get).andExpect(status().isOk()); + mockMvc.perform(get).andExpect(status().isOk()); + mockMvc.perform(get).andExpect(status().isOk()); + } + @Test void invalid_code() throws Exception { String email = new AlphanumericRandomValueStringGenerator().generate().toLowerCase() + "@test.org"; @@ -274,6 +538,63 @@ void invalid_code() throws Exception { .andExpect(redirectedUrl("http://localhost/login")); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void invalid_codeWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton(REDIRECT_URI), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + String email1 = generator.generate().toLowerCase() + "@test.org"; + String email2 = generator.generate().toLowerCase() + "@test.org"; + MvcResult r1 = mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, REDIRECT_URI) + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(new InvitationsRequest(new String[]{email1})))).andReturn(); + MvcResult r2 = mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, REDIRECT_URI) + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(new InvitationsRequest(new String[]{email2})))).andReturn(); + String code1 = extractInvitationCode(JsonUtils.readValue(r1.getResponse().getContentAsString(), InvitationsResponse.class).getNewInvites().get(0).getInviteLink().toString()); + String invalidCode = extractInvitationCode(JsonUtils.readValue(r2.getResponse().getContentAsString(), InvitationsResponse.class).getNewInvites().get(0).getInviteLink().toString()); + + MvcResult result = mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/invitations/accept") + .param("code", code1) + .accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Email: " + email1))) + .andReturn(); + + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(false); + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invitations/accept.do") + .session(session) + .param("password", "s3cret") + .param("password_confirmation", "s3cret") + .param("code", invalidCode) + .with(cookieCsrf())) + .andExpect(status().isUnprocessableEntity()) + .andExpect(model().attribute("error_message_code", "code_expired")) + .andExpect(view().name("invitations/accept_invite")); + + mockMvc.perform(get("/profile").session(session).accept(MediaType.TEXT_HTML)) + .andExpect(status().isFound()) + .andExpect(redirectedUrlPattern("**/login")); + } + @Test void acceptInvitationSetsYourPassword() throws Exception { String email = new AlphanumericRandomValueStringGenerator().generate().toLowerCase() + "@test.org"; @@ -320,6 +641,65 @@ void acceptInvitationSetsYourPassword() throws Exception { .andExpect(redirectedUrlPattern("**/login")); } + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void acceptInvitationSetsYourPasswordWithinZone(ZoneResolutionMode mode) throws Exception { + String subdomain = generator.generate().toLowerCase(); + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + + String zonedClientId = generator.generate().toLowerCase(); + String zonedClientSecret = generator.generate().toLowerCase(); + MockMvcUtils.createClient(mockMvc, zoneResult.getZoneAdminToken(), zonedClientId, zonedClientSecret, + Collections.singleton("oauth"), Arrays.asList("scim.read", "scim.invite"), Arrays.asList("client_credentials", "password"), + "scim.read,scim.invite", Collections.singleton(REDIRECT_URI), zoneResult.getIdentityZone()); + + String zonedToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mode, mockMvc, zonedClientId, zonedClientSecret, "scim.read scim.invite", subdomain, false); + + String email = generator.generate().toLowerCase() + "@test.org"; + InvitationsRequest invitations = new InvitationsRequest(new String[]{email}); + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invite_users") + .param(CLIENT_ID, zonedClientId) + .param(OAuth2Utils.REDIRECT_URI, REDIRECT_URI) + .header("Authorization", "Bearer " + zonedToken) + .contentType(APPLICATION_JSON) + .content(JsonUtils.writeValueAsString(invitations))).andExpect(status().isOk()); + + String zoneId = zoneResult.getIdentityZone().getId(); + String code = jdbcTemplate.queryForObject("SELECT code FROM expiring_code_store WHERE identity_zone_id=?", String.class, zoneId); + + MvcResult result = mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/invitations/accept") + .param("code", code) + .accept(MediaType.TEXT_HTML)) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("Email: " + email))) + .andReturn(); + + MockHttpSession session = (MockHttpSession) result.getRequest().getSession(false); + MockHttpServletRequestBuilder postReq = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/invitations/accept.do") + .param("password", "s3cret") + .param("password_confirmation", "s3cret") + .param("code", code) + .with(cookieCsrf()); + if (session != null) { + postReq.session(session); + } + result = mockMvc.perform(postReq) + .andExpect(status().isFound()) + .andReturn(); + + String redirectedUrl = result.getResponse().getRedirectedUrl(); + String loginPrefix = mode == ZoneResolutionMode.ZONE_PATH ? "/z/" + subdomain + "/login?" : "/login?"; + assertThat(redirectedUrl).startsWith(loginPrefix).contains("invite_accepted").contains("form_redirect_uri"); + + session = (MockHttpSession) result.getRequest().getSession(false); + mockMvc.perform(get("/profile").session(session).accept(MediaType.TEXT_HTML)) + .andExpect(status().isFound()) + .andExpect(redirectedUrlPattern("**/login")); + } + @Test void inviteLdapUsersVerifiesAndRedirects() throws Exception { ZoneScimInviteData zone = createZoneForInvites(mockMvc, webApplicationContext, clientId); 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 c30d32523fb..86c15e1ff64 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 @@ -33,7 +33,10 @@ import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.util.SessionUtils; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpMethod; import org.cloudfoundry.identity.uaa.web.LimitedModeUaaFilter; import org.cloudfoundry.identity.uaa.zone.BrandingInformation; import org.cloudfoundry.identity.uaa.zone.IdentityZone; @@ -282,7 +285,8 @@ private static MockHttpSession configure_UAA_for_idp_discovery( private void expect_idp_discovery( JdbcIdentityProviderProvisioning identityProviderProvisioning, JdbcIdentityZoneProvisioning identityZoneProvisioning, - List allowedProviders + List allowedProviders, + ZoneResolutionMode mode ) throws Exception { IdentityZoneConfiguration config = new IdentityZoneConfiguration(); config.setIdpDiscoveryEnabled(true); @@ -294,17 +298,17 @@ private void expect_idp_discovery( MockHttpSession session = configure_UAA_for_idp_discovery(webApplicationContext, identityProviderProvisioning, generator, originKey, zone, allowedProviders); - mockMvc.perform(get("/login") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login") .session(session) - .header("Accept", TEXT_HTML) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .header("Accept", TEXT_HTML)) .andExpect(status().isOk()) .andExpect(view().name("idp_discovery/email")) .andExpect(xpath("//input[@name='email']").exists()); } - @Test - void access_discovery_when_expected() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void access_discovery_when_expected(ZoneResolutionMode mode) throws Exception { List> allowedProvidersPermutations = new ArrayList<>(); allowedProvidersPermutations.add(new ArrayList<>(asList(UAA, LDAP, SAML))); // Model should not contain a login hint if we allow both UAA and LDAP @@ -316,12 +320,13 @@ void access_discovery_when_expected() throws Exception { allowedProvidersPermutations.add(new ArrayList<>(singletonList(LDAP))); // Model should contain a login hint if we exclude UAA from allowed providers for (List allowedProviders : allowedProvidersPermutations) { - expect_idp_discovery(identityProviderProvisioning, identityZoneProvisioning, allowedProviders); + expect_idp_discovery(identityProviderProvisioning, identityZoneProvisioning, allowedProviders, mode); } } - @Test - void redirect_when_only_saml_allowed() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void redirect_when_only_saml_allowed(ZoneResolutionMode mode) throws Exception { IdentityZoneConfiguration config = new IdentityZoneConfiguration(); config.setIdpDiscoveryEnabled(true); @@ -337,10 +342,9 @@ void redirect_when_only_saml_allowed() throws Exception { zone, new ArrayList<>(asList(originKey, SAML))); - mockMvc.perform(get("/login") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login") .session(session) - .header("Accept", TEXT_HTML) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .header("Accept", TEXT_HTML)) .andExpect(status().is3xxRedirection()); } @@ -725,8 +729,9 @@ void customCompanyName() throws Exception { .andExpect(content().string(allOf(containsString(expectedFooterText)))); } - @Test - void customCompanyNameInZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void customCompanyNameInZone(ZoneResolutionMode mode) throws Exception { String companyName = "Big Company"; BrandingInformation branding = new BrandingInformation(); branding.setCompanyName(companyName); @@ -743,7 +748,8 @@ void customCompanyNameInZone() throws Exception { String expectedFooterText = DEFAULT_COPYRIGHT_TEMPLATE.formatted(zoneCompanyName); - mockMvc.perform(get("/login").accept(TEXT_HTML).with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") + .accept(TEXT_HTML)) .andExpect(status().isOk()) .andExpect(content().string(allOf(containsString(expectedFooterText)))); } @@ -828,7 +834,7 @@ void logOut() throws Exception { mockMvc.perform(get("/uaa/logout.do").contextPath("/uaa")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/uaa/login")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); } @Test @@ -836,7 +842,7 @@ void logOutIgnoreRedirectParameter() throws Exception { mockMvc.perform(get("/uaa/logout.do").param("redirect", "https://www.google.com").contextPath("/uaa")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/uaa/login")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); } @Test @@ -848,7 +854,7 @@ void logOutAllowInternalRedirect() throws Exception { mockMvc.perform(get("/uaa/logout.do").param("redirect", "http://localhost/uaa/internal-location").contextPath("/uaa")) .andExpect(status().isFound()) .andExpect(redirectedUrl("http://localhost/uaa/internal-location")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); } finally { MockMvcUtils.setLogout(webApplicationContext, IdentityZone.getUaaZoneId(), original); } @@ -865,7 +871,7 @@ void logOutWhitelistedRedirectParameter() throws Exception { mockMvc.perform(get("/uaa/logout.do").param("redirect", "https://www.google.com").contextPath("/uaa")) .andExpect(status().isFound()) .andExpect(redirectedUrl("https://www.google.com")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); } finally { MockMvcUtils.setLogout(webApplicationContext, IdentityZone.getUaaZoneId(), original); } @@ -882,7 +888,7 @@ void logOutNotWhitelistedRedirectParameter() throws Exception { mockMvc.perform(get("/uaa/logout.do").param("redirect", "https://www.google.com").contextPath("/uaa")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/uaa/login")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); } finally { MockMvcUtils.setLogout(webApplicationContext, IdentityZone.getUaaZoneId(), original); } @@ -899,7 +905,7 @@ void logOutNullWhitelistedRedirectParameter() throws Exception { mockMvc.perform(get("/uaa/logout.do").param("redirect", "https://www.google.com").contextPath("/uaa")) .andExpect(status().isFound()) .andExpect(redirectedUrl("https://www.google.com")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); } finally { MockMvcUtils.setLogout(webApplicationContext, IdentityZone.getUaaZoneId(), original); } @@ -916,7 +922,7 @@ void logOutEmptyWhitelistedRedirectParameter() throws Exception { mockMvc.perform(get("/uaa/logout.do").param("redirect", "https://www.google.com").contextPath("/uaa")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/uaa/login")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); } finally { MockMvcUtils.setLogout(webApplicationContext, IdentityZone.getUaaZoneId(), original); } @@ -942,7 +948,7 @@ void logOutChangeUrlValue() throws Exception { mockMvc.perform(get("/uaa/logout.do").contextPath("/uaa")) .andExpect(status().isFound()) .andExpect(redirectedUrl("https://www.google.com")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); } finally { MockMvcUtils.setLogout(webApplicationContext, IdentityZone.getUaaZoneId(), original); } @@ -968,7 +974,7 @@ void logOutWithClientRedirect() throws Exception { ) .andExpect(status().isFound()) .andExpect(redirectedUrl("http://testing.com")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); mockMvc.perform( get("/uaa/logout.do") @@ -978,7 +984,7 @@ void logOutWithClientRedirect() throws Exception { ) .andExpect(status().isFound()) .andExpect(redirectedUrl("http://www.wildcard.testing")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); mockMvc.perform( get("/uaa/logout.do") @@ -988,7 +994,7 @@ void logOutWithClientRedirect() throws Exception { ) .andExpect(status().isFound()) .andExpect(redirectedUrl("/uaa/login")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); } finally { MockMvcUtils.setLogout(webApplicationContext, IdentityZone.getUaaZoneId(), original); } @@ -1010,7 +1016,7 @@ void logOutConfigForZone() throws Exception { mockMvc.perform(get("/uaa/logout.do").contextPath("/uaa")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/uaa/login")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); //other zone mockMvc.perform(get("/uaa/logout.do") @@ -1018,7 +1024,7 @@ void logOutConfigForZone() throws Exception { .header("Host", zoneId + ".localhost")) .andExpect(status().isFound()) .andExpect(redirectedUrl("http://test.redirect.com")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); mockMvc.perform(get("/uaa/logout.do") .contextPath("/uaa") @@ -1027,7 +1033,7 @@ void logOutConfigForZone() throws Exception { ) .andExpect(status().isFound()) .andExpect(redirectedUrl("http://test.redirect.com")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); zone.getConfig().getLinks().getLogout().setDisableRedirectParameter(false); zone = identityZoneProvisioning.update(zone); @@ -1039,7 +1045,7 @@ void logOutConfigForZone() throws Exception { ) .andExpect(status().isFound()) .andExpect(redirectedUrl("http://test.redirect.com")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); zone.getConfig().getLinks().getLogout().setDisableRedirectParameter(false); zone.getConfig().getLinks().getLogout().setWhitelist(singletonList("http://google.com")); @@ -1052,7 +1058,7 @@ void logOutConfigForZone() throws Exception { ) .andExpect(status().isFound()) .andExpect(redirectedUrl("http://google.com")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); zone.getConfig().getLinks().getLogout().setWhitelist(singletonList("http://yahoo.com")); identityZoneProvisioning.update(zone); @@ -1064,7 +1070,7 @@ void logOutConfigForZone() throws Exception { ) .andExpect(status().isFound()) .andExpect(redirectedUrl("http://test.redirect.com")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); mockMvc.perform(get("/uaa/logout.do") .contextPath("/uaa") @@ -1073,7 +1079,7 @@ void logOutConfigForZone() throws Exception { ) .andExpect(status().isFound()) .andExpect(redirectedUrl("http://yahoo.com")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); } @Test @@ -1264,8 +1270,9 @@ void customSignupLinkWithLocalSignupDisabled() throws Exception { .andExpect(model().attribute("createAccountLink", nullValue())); } - @Test - void samlLoginLinksShowActiveProviders() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void samlLoginLinksShowActiveProviders(ZoneResolutionMode mode) throws Exception { String activeAlias = "login-saml-" + generator.generate(); String inactiveAlias = "login-saml-" + generator.generate(); @@ -1304,14 +1311,16 @@ void samlLoginLinksShowActiveProviders() throws Exception { inactiveIdentityProvider.setOriginKey(inactiveAlias); createIdentityProvider(jdbcIdentityProviderProvisioning, identityZone, inactiveIdentityProvider); - mockMvc.perform(get("/login").accept(TEXT_HTML).with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") + .accept(TEXT_HTML)) .andExpect(status().isOk()) .andExpect(xpath("//a[text()='" + activeSamlIdentityProviderDefinition.getLinkText() + "']").exists()) .andExpect(xpath("//a[text()='" + inactiveSamlIdentityProviderDefinition.getLinkText() + "']").doesNotExist()); } - @Test - void samlRedirectWhenTheOnlyProvider() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void samlRedirectWhenTheOnlyProvider(ZoneResolutionMode mode) throws Exception { String alias = "login-saml-" + generator.generate(); final String zoneAdminClientId = "admin"; UaaClientDetails zoneAdminClient = new UaaClientDetails(zoneAdminClientId, null, "openid", "client_credentials,authorization_code", "clients.admin,scim.read,scim.write", "http://test.redirect.com"); @@ -1341,17 +1350,15 @@ void samlRedirectWhenTheOnlyProvider() throws Exception { SavedRequest savedRequest = new MockMvcUtils.MockSavedRequest(); SessionUtils.setSavedRequestSession(session, savedRequest); - mockMvc.perform(get("/login") + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") .accept(TEXT_HTML) - .session(session) - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + .session(session)) .andExpect(status().isFound()) .andExpect(redirectedUrl("/saml2/authenticate/%s".formatted(alias))); - mockMvc.perform(get("/login") + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") .accept(APPLICATION_JSON) - .session(session) - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + .session(session)) .andExpect(status().isOk()); IdentityProvider uaaProvider = jdbcIdentityProviderProvisioning.retrieveByOriginIgnoreActiveFlag(UAA, identityZone.getId()); @@ -1359,10 +1366,9 @@ void samlRedirectWhenTheOnlyProvider() throws Exception { IdentityZoneHolder.set(identityZone); uaaProvider.setActive(false); jdbcIdentityProviderProvisioning.update(uaaProvider, uaaProvider.getIdentityZoneId()); - mockMvc.perform(get("/login") + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") .accept(APPLICATION_JSON) - .session(session) - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + .session(session)) .andExpect(status().isOk()); } finally { IdentityZoneHolder.set(identityZone); @@ -1372,8 +1378,9 @@ void samlRedirectWhenTheOnlyProvider() throws Exception { } } - @Test - void samlRedirect_onlyOneProvider_noClientContext() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void samlRedirect_onlyOneProvider_noClientContext(ZoneResolutionMode mode) throws Exception { String alias = "login-saml-" + generator.generate(); final String zoneAdminClientId = "admin"; UaaClientDetails zoneAdminClient = new UaaClientDetails(zoneAdminClientId, null, "openid", "client_credentials,authorization_code", "clients.admin,scim.read,scim.write", "http://test.redirect.com"); @@ -1401,15 +1408,16 @@ void samlRedirect_onlyOneProvider_noClientContext() throws Exception { uaaIdentityProvider.setActive(false); jdbcIdentityProviderProvisioning.update(uaaIdentityProvider, uaaIdentityProvider.getIdentityZoneId()); - mockMvc.perform(get("/login").accept(TEXT_HTML).with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost")) - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") + .accept(TEXT_HTML)) .andExpect(status().isFound()) .andExpect(redirectedUrl("/saml2/authenticate/%s".formatted(alias))); IdentityZoneHolder.clear(); } - @Test - void externalOauthRedirect_onlyOneProvider_noClientContext_and_ResponseType_Set() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void externalOauthRedirect_onlyOneProvider_noClientContext_and_ResponseType_Set(ZoneResolutionMode mode) throws Exception { final String zoneAdminClientId = "admin"; UaaClientDetails zoneAdminClient = new UaaClientDetails(zoneAdminClientId, null, "openid", "client_credentials,authorization_code", "clients.admin,scim.read,scim.write", "http://test.redirect.com"); zoneAdminClient.setClientSecret("admin-secret"); @@ -1424,27 +1432,30 @@ void externalOauthRedirect_onlyOneProvider_noClientContext_and_ResponseType_Set( uaaIdentityProvider.setActive(false); jdbcIdentityProviderProvisioning.update(uaaIdentityProvider, uaaIdentityProvider.getIdentityZoneId()); - MvcResult mvcResult = mockMvc.perform(get("/login").accept(TEXT_HTML) - .servletPath("/login") - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + MvcResult mvcResult = mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") + .accept(TEXT_HTML) + .servletPath(mode.getServletPath(identityZone.getSubdomain(), "/login"))) .andExpect(status().isFound()) .andReturn(); String location = mvcResult.getResponse().getHeader("Location"); Map queryParams = UriComponentsBuilder.fromUriString(location).build().getQueryParams().toSingleValueMap(); + // For ZONE_PATH mode, the redirect_uri uses localhost; for SUBDOMAIN mode, it uses subdomain.localhost + String expectedHost = mode == ZoneResolutionMode.ZONE_PATH ? "localhost" : identityZone.getSubdomain() + ".localhost"; assertThat(location).startsWith("http://auth.url"); assertThat(queryParams).containsEntry("client_id", "uaa") .containsEntry("response_type", "code+id_token") - .containsEntry("redirect_uri", "http%3A%2F%2F" + identityZone.getSubdomain() + ".localhost%2Flogin%2Fcallback%2F" + oauthAlias) + .containsEntry("redirect_uri", "http%3A%2F%2F" + expectedHost + "%2Flogin%2Fcallback%2F" + oauthAlias) .containsEntry("scope", "openid+roles") .containsKey("nonce"); IdentityZoneHolder.clear(); } - @Test - void ExternalOAuthRedirectOnlyOneProviderWithDiscoveryUrl() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void ExternalOAuthRedirectOnlyOneProviderWithDiscoveryUrl(ZoneResolutionMode mode) throws Exception { final String zoneAdminClientId = "admin"; final String oidcMetaEndpoint = "http://mocked/.well-known/openid-configuration"; final String oidcAuthUrl = "http://againmocked/oauth/auth"; @@ -1467,27 +1478,30 @@ void ExternalOAuthRedirectOnlyOneProviderWithDiscoveryUrl() throws Exception { uaaIdentityProvider.setActive(false); jdbcIdentityProviderProvisioning.update(uaaIdentityProvider, uaaIdentityProvider.getIdentityZoneId()); - MvcResult mvcResult = mockMvc.perform(get("/login").accept(TEXT_HTML) - .servletPath("/login") - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + MvcResult mvcResult = mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") + .accept(TEXT_HTML) + .servletPath(mode.getServletPath(identityZone.getSubdomain(), "/login"))) .andExpect(status().isFound()) .andReturn(); String location = mvcResult.getResponse().getHeader("Location"); Map queryParams = UriComponentsBuilder.fromUriString(location).build().getQueryParams().toSingleValueMap(); + // For ZONE_PATH mode, the redirect_uri uses localhost; for SUBDOMAIN mode, it uses subdomain.localhost + String expectedHost = mode == ZoneResolutionMode.ZONE_PATH ? "localhost" : identityZone.getSubdomain() + ".localhost"; assertThat(location).startsWith(oidcAuthUrl); assertThat(queryParams).containsEntry("client_id", "uaa") .containsEntry("response_type", "code+id_token") - .containsEntry("redirect_uri", "http%3A%2F%2F" + identityZone.getSubdomain() + ".localhost%2Flogin%2Fcallback%2F" + oauthAlias) + .containsEntry("redirect_uri", "http%3A%2F%2F" + expectedHost + "%2Flogin%2Fcallback%2F" + oauthAlias) .containsEntry("scope", "openid+roles") .containsKey("nonce"); IdentityZoneHolder.clear(); } - @Test - void oauthRedirect_stateParameterPassedGetsReturned() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void oauthRedirect_stateParameterPassedGetsReturned(ZoneResolutionMode mode) throws Exception { final String zoneAdminClientId = "admin"; UaaClientDetails zoneAdminClient = new UaaClientDetails(zoneAdminClientId, null, "openid", "client_credentials,authorization_code", "clients.admin,scim.read,scim.write", "http://test.redirect.com"); zoneAdminClient.setClientSecret("admin-secret"); @@ -1502,19 +1516,21 @@ void oauthRedirect_stateParameterPassedGetsReturned() throws Exception { uaaIdentityProvider.setActive(false); jdbcIdentityProviderProvisioning.update(uaaIdentityProvider, uaaIdentityProvider.getIdentityZoneId()); - MvcResult mvcResult = mockMvc.perform(get("/login").accept(TEXT_HTML) - .servletPath("/login") - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + MvcResult mvcResult = mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") + .accept(TEXT_HTML) + .servletPath(mode.getServletPath(identityZone.getSubdomain(), "/login"))) .andExpect(status().isFound()) .andReturn(); String location = mvcResult.getResponse().getHeader("Location"); Map queryParams = UriComponentsBuilder.fromUriString(location).build().getQueryParams().toSingleValueMap(); + // For ZONE_PATH mode, the redirect_uri uses localhost; for SUBDOMAIN mode, it uses subdomain.localhost + String expectedHost = mode == ZoneResolutionMode.ZONE_PATH ? "localhost" : identityZone.getSubdomain() + ".localhost"; assertThat(location).startsWith("http://auth.url"); assertThat(queryParams).containsEntry("client_id", "uaa") .containsEntry("response_type", "code+id_token") - .containsEntry("redirect_uri", "http%3A%2F%2F" + identityZone.getSubdomain() + ".localhost%2Flogin%2Fcallback%2F" + oauthAlias) + .containsEntry("redirect_uri", "http%3A%2F%2F" + expectedHost + "%2Flogin%2Fcallback%2F" + oauthAlias) .containsEntry("scope", "openid+roles") .containsKey("nonce") .extractingByKey("state").isNotNull(); @@ -1522,8 +1538,9 @@ void oauthRedirect_stateParameterPassedGetsReturned() throws Exception { IdentityZoneHolder.clear(); } - @Test - void loginHintRedirect() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void loginHintRedirect(ZoneResolutionMode mode) throws Exception { final String zoneAdminClientId = "admin"; UaaClientDetails zoneAdminClient = new UaaClientDetails(zoneAdminClientId, null, "openid", "client_credentials,authorization_code", "clients.admin,scim.read,scim.write", "http://test.redirect.com"); zoneAdminClient.setClientSecret("admin-secret"); @@ -1556,30 +1573,31 @@ void loginHintRedirect() throws Exception { when(savedRequest.getParameterValues("login_hint")).thenReturn(new String[]{"example.com"}); SessionUtils.setSavedRequestSession(session, savedRequest); - MvcResult mvcResult = mockMvc.perform(get("/login") + MvcResult mvcResult = mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") .accept(TEXT_HTML) .session(session) - .servletPath("/login") - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost")) - ) + .servletPath(mode.getServletPath(identityZone.getSubdomain(), "/login"))) .andExpect(status().isFound()) .andReturn(); String location = mvcResult.getResponse().getHeader("Location"); Map queryParams = UriComponentsBuilder.fromUriString(location).build().getQueryParams().toSingleValueMap(); + // For ZONE_PATH mode, the redirect_uri uses localhost; for SUBDOMAIN mode, it uses subdomain.localhost + String expectedHost = mode == ZoneResolutionMode.ZONE_PATH ? "localhost" : identityZone.getSubdomain() + ".localhost"; assertThat(location).startsWith("http://auth.url"); assertThat(queryParams).containsEntry("client_id", "uaa") .containsEntry("response_type", "code") - .containsEntry("redirect_uri", "http%3A%2F%2F" + identityZone.getSubdomain() + ".localhost%2Flogin%2Fcallback%2F" + oauthAlias) + .containsEntry("redirect_uri", "http%3A%2F%2F" + expectedHost + "%2Flogin%2Fcallback%2F" + oauthAlias) .containsEntry("scope", "openid+roles") .containsKey("nonce"); IdentityZoneHolder.clear(); } - @Test - void noRedirect_ifProvidersOfDifferentTypesPresent() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void noRedirect_ifProvidersOfDifferentTypesPresent(ZoneResolutionMode mode) throws Exception { String alias = "login-saml-" + generator.generate(); final String zoneAdminClientId = "admin"; UaaClientDetails zoneAdminClient = new UaaClientDetails(zoneAdminClientId, null, "openid", "client_credentials,authorization_code", "clients.admin,scim.read,scim.write", "http://test.redirect.com"); @@ -1623,15 +1641,16 @@ void noRedirect_ifProvidersOfDifferentTypesPresent() throws Exception { uaaIdentityProvider.setActive(false); jdbcIdentityProviderProvisioning.update(uaaIdentityProvider, uaaIdentityProvider.getIdentityZoneId()); - mockMvc.perform(get("/login").accept(TEXT_HTML).with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost")) - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") + .accept(TEXT_HTML)) .andExpect(status().isOk()) .andExpect(view().name("login")); IdentityZoneHolder.clear(); } - @Test - void noCreateAccountLinksWhenUAAisNotAllowedProvider() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void noCreateAccountLinksWhenUAAisNotAllowedProvider(ZoneResolutionMode mode) throws Exception { String alias2 = "login-saml-" + generator.generate(); String alias3 = "login-saml-" + generator.generate(); final String zoneAdminClientId = "admin"; @@ -1717,16 +1736,17 @@ public Map getParameterMap() { }; SessionUtils.setSavedRequestSession(session, savedRequest); - mockMvc.perform(get("/login").accept(TEXT_HTML).with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost")) - .session(session) - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") + .accept(TEXT_HTML) + .session(session)) .andExpect(status().isOk()) .andExpect(xpath("//a[text()='Create account']").doesNotExist()) .andExpect(xpath("//a[text()='Reset password']").doesNotExist()); } - @Test - void deactivatedProviderIsRemovedFromSamlLoginLinks() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void deactivatedProviderIsRemovedFromSamlLoginLinks(ZoneResolutionMode mode) throws Exception { assumeFalse(isLimitedMode(limitedModeUaaFilter.getFilter()), "Test only runs in non limited mode."); String alias = "login-saml-" + generator.generate(); UaaClientDetails zoneAdminClient = new UaaClientDetails("admin", null, null, "client_credentials", "clients.admin,scim.read,scim.write"); @@ -1750,14 +1770,16 @@ void deactivatedProviderIsRemovedFromSamlLoginLinks() throws Exception { identityProvider.setOriginKey(alias); identityProvider = createIdentityProvider(jdbcIdentityProviderProvisioning, identityZone, identityProvider); - mockMvc.perform(get("/login").accept(TEXT_HTML).with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") + .accept(TEXT_HTML)) .andExpect(status().isOk()) .andExpect(xpath("//a[text()='" + samlIdentityProviderDefinition.getLinkText() + "']").exists()); identityProvider.setActive(false); jdbcIdentityProviderProvisioning.update(identityProvider, identityZone.getId()); - mockMvc.perform(get("/login").accept(TEXT_HTML).with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, "/login") + .accept(TEXT_HTML)) .andExpect(status().isOk()) .andExpect(xpath("//a[text()='" + samlIdentityProviderDefinition.getLinkText() + "']").doesNotExist()); } @@ -2110,11 +2132,16 @@ void logOutCorsPreflightWithUnallowedOrigin() throws Exception { * the CORS policy of the default zone. * Positive test case that exercises the CORS logic for dealing with the "X-Requested-With" header. */ - @Test - void xhrCorsPreflightForNonDefaultZoneWhenZoneSpecificCorsPolicyIsNull() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void xhrCorsPreflightForNonDefaultZoneWhenZoneSpecificCorsPolicyIsNull(ZoneResolutionMode mode) throws Exception { // setting the default zone CORS policy corsFilter.getFilter().setCorsXhrAllowedOrigins(asList("^localhost$", "^*\\.localhost$")); - corsFilter.getFilter().setCorsXhrAllowedUris(singletonList("^/logout.do$")); + // For ZONE_PATH mode, the request path is /z/{subdomain}/logout.do, so we need to allow that pattern + List allowedUris = mode == ZoneResolutionMode.ZONE_PATH + ? asList("^/logout.do$", "^/z/[^/]+/logout.do$") + : singletonList("^/logout.do$"); + corsFilter.getFilter().setCorsXhrAllowedUris(allowedUris); corsFilter.getFilter().initialize(); // set the non default zone CORS Xhr policy to null @@ -2125,9 +2152,9 @@ void xhrCorsPreflightForNonDefaultZoneWhenZoneSpecificCorsPolicyIsNull() throws HttpHeaders httpHeaders = new HttpHeaders(); httpHeaders.add("Access-Control-Request-Headers", "X-Requested-With"); httpHeaders.add("Access-Control-Request-Method", "GET"); - httpHeaders.add("Origin", "testzone1.localhost"); - mockMvc.perform(options("/logout.do") - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost")) + // For ZONE_PATH mode, use localhost as Origin (no subdomain in host) + httpHeaders.add("Origin", mode == ZoneResolutionMode.ZONE_PATH ? "localhost" : "testzone1.localhost"); + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.OPTIONS, "/logout.do") .headers(httpHeaders)) .andExpect(status().isOk()); } @@ -2137,8 +2164,9 @@ void xhrCorsPreflightForNonDefaultZoneWhenZoneSpecificCorsPolicyIsNull() throws * Positive test case that exercises the CORS logic for dealing with the "X-Requested-With" header. * The access control request method is POST, which is allowed by the zone specific CORS policy in this test case setup */ - @Test - void xhrCorsPreflightForNonDefaultZoneWhenZoneSpecificCorsPolicyExists() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void xhrCorsPreflightForNonDefaultZoneWhenZoneSpecificCorsPolicyExists(ZoneResolutionMode mode) throws Exception { // setting the default zone CORS policy to not allow POST corsFilter.getFilter().setCorsXhrAllowedMethods(List.of(GET.toString(), OPTIONS.toString())); corsFilter.getFilter().initialize(); @@ -2153,8 +2181,7 @@ void xhrCorsPreflightForNonDefaultZoneWhenZoneSpecificCorsPolicyExists() throws httpHeaders.add("Access-Control-Request-Headers", "X-Requested-With"); httpHeaders.add("Access-Control-Request-Method", "POST"); httpHeaders.add("Origin", "testzone1.localhost"); - mockMvc.perform(options("/logout.do") - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost")) + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.OPTIONS, "/logout.do") .headers(httpHeaders)) .andExpect(status().isOk()); } @@ -2162,18 +2189,19 @@ void xhrCorsPreflightForNonDefaultZoneWhenZoneSpecificCorsPolicyExists() throws @Test void login_LockoutPolicySucceeds_ForDefaultZone() throws Exception { ScimUser userToLockout = createUser(scimUserProvisioning, generator, IdentityZone.getUaaZoneId()); - attemptUnsuccessfulLogin(mockMvc, 5, userToLockout.getUserName(), ""); + attemptUnsuccessfulLogin(mockMvc, ZoneResolutionMode.SUBDOMAIN, 5, userToLockout.getUserName(), ""); mockMvc.perform(post("/uaa/login.do") .contextPath("/uaa") .with(cookieCsrf()) .param("username", userToLockout.getUserName()) .param("password", userToLockout.getPassword())) .andExpect(redirectedUrl("/uaa/login?error=account_locked")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(emptyCurrentUserCookie(ZoneResolutionMode.SUBDOMAIN)); } - @Test - void login_LockoutPolicySucceeds_WhenPolicyIsUpdatedByApi() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void login_LockoutPolicySucceeds_WhenPolicyIsUpdatedByApi(ZoneResolutionMode mode) throws Exception { String subdomain = generator.generate(); IdentityZone zone = createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, false, IdentityZoneHolder.getCurrentZoneId()); @@ -2181,16 +2209,20 @@ void login_LockoutPolicySucceeds_WhenPolicyIsUpdatedByApi() throws Exception { ScimUser userToLockout = createUser(scimUserProvisioning, generator, zone.getId()); - attemptUnsuccessfulLogin(mockMvc, 2, userToLockout.getUserName(), subdomain); + attemptUnsuccessfulLogin(mockMvc, mode, 2, userToLockout.getUserName(), subdomain); - mockMvc.perform(post("/uaa/login.do") - .contextPath("/uaa") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) - .with(cookieCsrf()) - .param("username", userToLockout.getUserName()) - .param("password", userToLockout.getPassword())) - .andExpect(redirectedUrl("/uaa/login?error=account_locked")) - .andExpect(emptyCurrentUserCookie()); + // Context path /uaa is independent of zone resolution mode (subdomain vs /z/ path). + // pathSuffix is the path after context (SUBDOMAIN) or after /z/{subdomain} (ZONE_PATH). + String expectedRedirect = mode == ZoneResolutionMode.SUBDOMAIN ? "/uaa/login?error=account_locked" : + "/uaa/z/" + subdomain + "/login?error=account_locked"; + var requestBuilder = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/uaa", "/login.do") + .contextPath("/uaa") + .with(cookieCsrf()) + .param("username", userToLockout.getUserName()) + .param("password", userToLockout.getPassword()); + mockMvc.perform(requestBuilder) + .andExpect(redirectedUrl(expectedRedirect)) + .andExpect(emptyCurrentUserCookie(mode)); } @Test @@ -2253,14 +2285,14 @@ void autologin_with_validCode_and_formencoded_RedirectsToHome() throws Exception .andExpect(redirectedUrl("home")); } - @Test - void idpDiscoveryPageDisplayed_IfFlagIsEnabled() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void idpDiscoveryPageDisplayed_IfFlagIsEnabled(ZoneResolutionMode mode) throws Exception { IdentityZoneConfiguration config = new IdentityZoneConfiguration(); config.setIdpDiscoveryEnabled(true); IdentityZone zone = setupZone(webApplicationContext, mockMvc, identityZoneProvisioning, generator, config); - mockMvc.perform(get("/login") - .header("Accept", TEXT_HTML) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login") + .header("Accept", TEXT_HTML)) .andExpect(status().isOk()) .andExpect(view().name("idp_discovery/email")) .andExpect(content().string(containsString("Sign in"))) @@ -2269,21 +2301,22 @@ void idpDiscoveryPageDisplayed_IfFlagIsEnabled() throws Exception { .andExpect(xpath("//input[@name='commit']/@value").string("Next")); } - @Test - void idpDiscoveryPageNotDisplayed_IfFlagIsEnabledAndDiscoveryUnsuccessfulPreviously() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void idpDiscoveryPageNotDisplayed_IfFlagIsEnabledAndDiscoveryUnsuccessfulPreviously(ZoneResolutionMode mode) throws Exception { IdentityZoneConfiguration config = new IdentityZoneConfiguration(); config.setIdpDiscoveryEnabled(true); IdentityZone zone = setupZone(webApplicationContext, mockMvc, identityZoneProvisioning, generator, config); - mockMvc.perform(get("/login?discoveryPerformed=true") - .header("Accept", TEXT_HTML) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login?discoveryPerformed=true") + .header("Accept", TEXT_HTML)) .andExpect(status().isOk()) .andExpect(view().name("idp_discovery/password")); } - @Test - void idpDiscoveryClientNameDisplayed_WithUTF8Characters() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void idpDiscoveryClientNameDisplayed_WithUTF8Characters(ZoneResolutionMode mode) throws Exception { String utf8String = "\u7433\u8D3A"; String clientName = "woohoo-" + utf8String; IdentityZoneConfiguration config = new IdentityZoneConfiguration(); @@ -2299,10 +2332,9 @@ void idpDiscoveryClientNameDisplayed_WithUTF8Characters() throws Exception { SavedRequest savedRequest = getSavedRequest(client); SessionUtils.setSavedRequestSession(session, savedRequest); - mockMvc.perform(get("/login") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login") .session(session) - .header("Accept", TEXT_HTML) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .header("Accept", TEXT_HTML)) .andExpect(status().isOk()) .andExpect(view().name("idp_discovery/email")) .andExpect(content().string(containsString("Sign in to continue to " + clientName))) @@ -2311,8 +2343,9 @@ void idpDiscoveryClientNameDisplayed_WithUTF8Characters() throws Exception { .andExpect(xpath("//input[@name='commit']/@value").string("Next")); } - @Test - void accountChooserEnabled_NoSaveAccounts() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void accountChooserEnabled_NoSaveAccounts(ZoneResolutionMode mode) throws Exception { String clientName = "woohoo"; IdentityZoneConfiguration config = new IdentityZoneConfiguration(); config.setIdpDiscoveryEnabled(true); @@ -2330,16 +2363,16 @@ void accountChooserEnabled_NoSaveAccounts() throws Exception { savedAccount.setOrigin("uaa"); savedAccount.setUserId("1234-5678"); savedAccount.setUsername("test@example.org"); - mockMvc.perform(get("/login") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login") .session(session) - .header("Accept", TEXT_HTML) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .header("Accept", TEXT_HTML)) .andExpect(status().isOk()) .andExpect(view().name("idp_discovery/email")); } - @Test - void accountChooserEnabled() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void accountChooserEnabled(ZoneResolutionMode mode) throws Exception { String clientName = "woohoo"; IdentityZoneConfiguration config = new IdentityZoneConfiguration(); config.setIdpDiscoveryEnabled(true); @@ -2358,18 +2391,18 @@ void accountChooserEnabled() throws Exception { savedAccount.setOrigin("uaa"); savedAccount.setUserId("1234-5678"); savedAccount.setUsername("test@example.org"); - mockMvc.perform(get("/login") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login") .session(session) .cookie(new Cookie("Saved-Account-12345678", URLEncoder.encode(JsonUtils.writeValueAsString(savedAccount), StandardCharsets.UTF_8))) - .header("Accept", TEXT_HTML) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .header("Accept", TEXT_HTML)) .andDo(print()) .andExpect(status().isOk()) .andExpect(view().name("idp_discovery/account_chooser")); } - @Test - void accountChooserWithoutDiscovery() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void accountChooserWithoutDiscovery(ZoneResolutionMode mode) throws Exception { IdentityZoneConfiguration config = new IdentityZoneConfiguration(); config.setIdpDiscoveryEnabled(false); config.setAccountChooserEnabled(true); @@ -2377,17 +2410,17 @@ void accountChooserWithoutDiscovery() throws Exception { MockHttpSession session = new MockHttpSession(); - mockMvc.perform(get("/login") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login") .session(session) - .header("Accept", TEXT_HTML) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .header("Accept", TEXT_HTML)) .andDo(print()) .andExpect(status().isOk()) .andExpect(view().name("idp_discovery/origin")); } - @Test - void accountChooserWithoutDiscovery_loginWithProvidedLoginHint() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void accountChooserWithoutDiscovery_loginWithProvidedLoginHint(ZoneResolutionMode mode) throws Exception { assumeFalse(isLimitedMode(limitedModeUaaFilter.getFilter()), "Test only runs in non limited mode."); IdentityZoneConfiguration config = new IdentityZoneConfiguration(); config.setIdpDiscoveryEnabled(false); @@ -2397,12 +2430,11 @@ void accountChooserWithoutDiscovery_loginWithProvidedLoginHint() throws Exceptio String originKey = createOIDCProvider(jdbcIdentityProviderProvisioning, generator, zone, "id_token code"); String loginHint = "%7B%22origin%22%3A%22" + originKey + "%22%7D"; - MvcResult mvcResult = mockMvc.perform(post("/origin-chooser") + MvcResult mvcResult = mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.POST, "/origin-chooser") .with(cookieCsrf()) .header("Accept", TEXT_HTML) - .servletPath("/origin-chooser") - .param("login_hint", originKey) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .servletPath(mode.getServletPath(zone.getSubdomain(), "/origin-chooser")) + .param("login_hint", originKey)) .andExpect(status().isFound()) .andReturn(); String location = mvcResult.getResponse().getHeader("Location"); @@ -2414,8 +2446,9 @@ void accountChooserWithoutDiscovery_loginWithProvidedLoginHint() throws Exceptio .containsEntry("discoveryPerformed", "true"); } - @Test - void accountChooserWithoutDiscovery_noDefaultReturnsLoginPage() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void accountChooserWithoutDiscovery_noDefaultReturnsLoginPage(ZoneResolutionMode mode) throws Exception { assumeFalse(isLimitedMode(limitedModeUaaFilter.getFilter()), "Test only runs in non limited mode."); IdentityZoneConfiguration config = new IdentityZoneConfiguration(); config.setIdpDiscoveryEnabled(false); @@ -2424,11 +2457,10 @@ void accountChooserWithoutDiscovery_noDefaultReturnsLoginPage() throws Exception createOIDCProvider(jdbcIdentityProviderProvisioning, generator, zone, "id_token code"); - MvcResult mvcResult = mockMvc.perform(post("/origin-chooser") + MvcResult mvcResult = mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.POST, "/origin-chooser") .with(cookieCsrf()) .header("Accept", TEXT_HTML) - .servletPath("/origin-chooser") - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .servletPath(mode.getServletPath(zone.getSubdomain(), "/origin-chooser"))) .andExpect(status().isFound()) .andReturn(); String location = mvcResult.getResponse().getHeader("Location"); @@ -2441,8 +2473,9 @@ void accountChooserWithoutDiscovery_noDefaultReturnsLoginPage() throws Exception .doesNotContainKey("login_hint"); } - @Test - void emailPageIdpDiscoveryEnabled_SelfServiceLinksDisabled() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void emailPageIdpDiscoveryEnabled_SelfServiceLinksDisabled(ZoneResolutionMode mode) throws Exception { IdentityZoneConfiguration config = new IdentityZoneConfiguration(); config.setIdpDiscoveryEnabled(true); config.setLinks(new Links().setSelfService(new Links.SelfService().setSelfServiceLinksEnabled(false))); @@ -2450,13 +2483,13 @@ void emailPageIdpDiscoveryEnabled_SelfServiceLinksDisabled() throws Exception { MockMvcUtils.setSelfServiceLinksEnabled(webApplicationContext, IdentityZone.getUaaZoneId(), false); - mockMvc.perform(MockMvcRequestBuilders.get("/login") - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login")) .andExpect(xpath("//div[@class='action']//a").doesNotExist()); } - @Test - void idpDiscoveryRedirectsToSamlExternalProvider_withClientContext() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void idpDiscoveryRedirectsToSamlExternalProvider_withClientContext(ZoneResolutionMode mode) throws Exception { String subdomain = "test-zone-" + generator.generate().toLowerCase(); IdentityZone zone = MultitenancyFixture.identityZone(subdomain, subdomain); createOtherIdentityZone(zone.getSubdomain(), mockMvc, webApplicationContext, false, IdentityZoneHolder.getCurrentZoneId()); @@ -2464,45 +2497,47 @@ void idpDiscoveryRedirectsToSamlExternalProvider_withClientContext() throws Exce String originKey = generator.generate(); MockHttpSession session = setUpClientAndProviderForIdpDiscovery(webApplicationContext, jdbcIdentityProviderProvisioning, generator, originKey, zone); - mockMvc.perform(post("/login/idp_discovery") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.POST, "/login/idp_discovery") .with(cookieCsrf()) .header("Accept", TEXT_HTML) .session(session) - .param("email", "marissa@test.org") - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .param("email", "marissa@test.org")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/saml2/authenticate/%s".formatted(originKey))); } - @Test - void idpDiscoveryRedirectsToOIDCProvider() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void idpDiscoveryRedirectsToOIDCProvider(ZoneResolutionMode mode) throws Exception { String subdomain = "oidc-discovery-" + generator.generate().toLowerCase(); IdentityZone zone = MultitenancyFixture.identityZone(subdomain, subdomain); createOtherIdentityZone(zone.getSubdomain(), mockMvc, webApplicationContext, false, IdentityZoneHolder.getCurrentZoneId()); String originKey = createOIDCProvider(jdbcIdentityProviderProvisioning, generator, zone, "id_token code"); - MvcResult mvcResult = mockMvc.perform(post("/login/idp_discovery") + MvcResult mvcResult = mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.POST, "/login/idp_discovery") .with(cookieCsrf()) .header("Accept", TEXT_HTML) - .servletPath("/login/idp_discovery") - .param("email", "marissa@test.org") - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .servletPath(mode.getServletPath(zone.getSubdomain(), "/login/idp_discovery")) + .param("email", "marissa@test.org")) .andExpect(status().isFound()) .andReturn(); String location = mvcResult.getResponse().getHeader("Location"); Map queryParams = UriComponentsBuilder.fromUriString(location).build().getQueryParams().toSingleValueMap(); + // For ZONE_PATH mode, the redirect_uri uses localhost; for SUBDOMAIN mode, it uses subdomain.localhost + String expectedHost = mode == ZoneResolutionMode.ZONE_PATH ? "localhost" : subdomain + ".localhost"; assertThat(location).startsWith("http://myauthurl.com"); assertThat(queryParams).containsEntry("client_id", "id") .containsEntry("response_type", "id_token+code") - .containsEntry("redirect_uri", "http%3A%2F%2F" + subdomain + ".localhost%2Flogin%2Fcallback%2F" + originKey) + .containsEntry("redirect_uri", "http%3A%2F%2F" + expectedHost + "%2Flogin%2Fcallback%2F" + originKey) .containsKey("nonce"); } - @Test - void multiple_oidc_providers_use_response_type_in_url() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void multiple_oidc_providers_use_response_type_in_url(ZoneResolutionMode mode) throws Exception { String subdomain = "oidc-idp-discovery-multi-" + generator.generate().toLowerCase(); IdentityZone zone = MultitenancyFixture.identityZone(subdomain, subdomain); createOtherIdentityZone(zone.getSubdomain(), mockMvc, webApplicationContext, false, IdentityZoneHolder.getCurrentZoneId()); @@ -2510,17 +2545,17 @@ void multiple_oidc_providers_use_response_type_in_url() throws Exception { createOIDCProvider(jdbcIdentityProviderProvisioning, generator, zone, null); createOIDCProvider(jdbcIdentityProviderProvisioning, generator, zone, "code id_token"); - mockMvc.perform(get("/login") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login") .header("Accept", TEXT_HTML) - .servletPath("/login") - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .servletPath(mode.getServletPath(zone.getSubdomain(), "/login"))) .andExpect(status().isOk()) .andExpect(content().string(containsString("http://myauthurl.com?client_id=id&response_type=code&"))) .andExpect(content().string(containsString("http://myauthurl.com?client_id=id&response_type=code+id_token&"))); } - @Test - void idpDiscoveryWithNoEmailDomainMatch_withClientContext() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void idpDiscoveryWithNoEmailDomainMatch_withClientContext(ZoneResolutionMode mode) throws Exception { String subdomain = "test-zone-" + generator.generate().toLowerCase(); IdentityZone zone = MultitenancyFixture.identityZone(subdomain, subdomain); createOtherIdentityZone(zone.getSubdomain(), mockMvc, webApplicationContext, false, IdentityZoneHolder.getCurrentZoneId()); @@ -2534,18 +2569,18 @@ void idpDiscoveryWithNoEmailDomainMatch_withClientContext() throws Exception { MockHttpSession session = setUpClientAndProviderForIdpDiscovery(webApplicationContext, jdbcIdentityProviderProvisioning, generator, originKey, zone); - mockMvc.perform(post("/login/idp_discovery") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.POST, "/login/idp_discovery") .with(cookieCsrf()) .header("Accept", TEXT_HTML) .session(session) - .param("email", "marissa@other.domain") - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .param("email", "marissa@other.domain")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/login?discoveryPerformed=true&email=marissa%40other.domain")); } - @Test - void idpDiscoveryWithMultipleEmailDomainMatches_withClientContext() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void idpDiscoveryWithMultipleEmailDomainMatches_withClientContext(ZoneResolutionMode mode) throws Exception { String subdomain = "test-zone-" + generator.generate().toLowerCase(); IdentityZone zone = MultitenancyFixture.identityZone(subdomain, subdomain); createOtherIdentityZone(zone.getSubdomain(), mockMvc, webApplicationContext, false, IdentityZoneHolder.getCurrentZoneId()); @@ -2559,18 +2594,18 @@ void idpDiscoveryWithMultipleEmailDomainMatches_withClientContext() throws Excep MockHttpSession session = setUpClientAndProviderForIdpDiscovery(webApplicationContext, jdbcIdentityProviderProvisioning, generator, originKey, zone); - mockMvc.perform(post("/login/idp_discovery") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.POST, "/login/idp_discovery") .with(cookieCsrf()) .header("Accept", TEXT_HTML) .session(session) - .param("email", "marissa@test.org") - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .param("email", "marissa@test.org")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/login?discoveryPerformed=true&email=marissa%40test.org")); } - @Test - void idpDiscoveryWithUaaFallBack_withClientContext() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void idpDiscoveryWithUaaFallBack_withClientContext(ZoneResolutionMode mode) throws Exception { String subdomain = "test-zone-" + generator.generate().toLowerCase(); IdentityZone zone = MultitenancyFixture.identityZone(subdomain, subdomain); createOtherIdentityZone(zone.getSubdomain(), mockMvc, webApplicationContext, false, IdentityZoneHolder.getCurrentZoneId()); @@ -2579,26 +2614,25 @@ void idpDiscoveryWithUaaFallBack_withClientContext() throws Exception { MockHttpSession session = setUpClientAndProviderForIdpDiscovery(webApplicationContext, jdbcIdentityProviderProvisioning, generator, originKey, zone); - mockMvc.perform(post("/login/idp_discovery") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.POST, "/login/idp_discovery") .with(cookieCsrf()) .header("Accept", TEXT_HTML) .session(session) - .param("email", "marissa@other.domain") - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .param("email", "marissa@other.domain")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/login?discoveryPerformed=true&email=marissa%40other.domain")); - mockMvc.perform(get("/login?discoveryPerformed=true&email=marissa%40other.domain") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login?discoveryPerformed=true&email=marissa%40other.domain") .with(cookieCsrf()) .header("Accept", TEXT_HTML) - .session(session) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .session(session)) .andExpect(model().attributeExists("zone_name")) .andExpect(view().name("login")); } - @Test - void idpDiscoveryWithLdap_withClientContext() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void idpDiscoveryWithLdap_withClientContext(ZoneResolutionMode mode) throws Exception { String subdomain = "test-zone-" + generator.generate().toLowerCase(); IdentityZone zone = MultitenancyFixture.identityZone(subdomain, subdomain); createOtherIdentityZone(zone.getSubdomain(), mockMvc, webApplicationContext, false, IdentityZoneHolder.getCurrentZoneId()); @@ -2613,32 +2647,30 @@ void idpDiscoveryWithLdap_withClientContext() throws Exception { MockHttpSession session = setUpClientAndProviderForIdpDiscovery(webApplicationContext, jdbcIdentityProviderProvisioning, generator, originKey, zone); - mockMvc.perform(post("/login/idp_discovery") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.POST, "/login/idp_discovery") .with(cookieCsrf()) .header("Accept", TEXT_HTML) .session(session) - .param("email", "marissa@testLdap.org") - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .param("email", "marissa@testLdap.org")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/login?discoveryPerformed=true&email=marissa%40testLdap.org")); } - @Test - void passwordPageDisplayed_ifUaaIsFallbackIDPForEmailDomain() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void passwordPageDisplayed_ifUaaIsFallbackIDPForEmailDomain(ZoneResolutionMode mode) throws Exception { IdentityZoneConfiguration config = new IdentityZoneConfiguration(); config.setIdpDiscoveryEnabled(true); IdentityZone zone = setupZone(webApplicationContext, mockMvc, identityZoneProvisioning, generator, config); - mockMvc.perform(post("/login/idp_discovery") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.POST, "/login/idp_discovery") .header("Accept", TEXT_HTML) .with(cookieCsrf()) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost")) .param("email", "marissa@koala.com")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/login?discoveryPerformed=true&email=marissa%40koala.com")); - mockMvc.perform(get("/login?discoveryPerformed=true&email=marissa@koala.com") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login?discoveryPerformed=true&email=marissa@koala.com") .with(cookieCsrf()) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost")) .header("Accept", TEXT_HTML)) .andExpect(view().name("idp_discovery/password")) .andExpect(xpath("//input[@name='password']").exists()) @@ -2665,26 +2697,26 @@ void passwordPageIdpDiscoveryEnabled_SelfServiceLinksDisabled() throws Exception .andExpect(xpath("//div[@class='action pull-right']//a").doesNotExist()); } - @Test - void userNamePresentInPasswordPage() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void userNamePresentInPasswordPage(ZoneResolutionMode mode) throws Exception { IdentityZoneConfiguration config = new IdentityZoneConfiguration(); config.setIdpDiscoveryEnabled(true); IdentityZone zone = setupZone(webApplicationContext, mockMvc, identityZoneProvisioning, generator, config); - mockMvc.perform(post("/login/idp_discovery") + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.POST, "/login/idp_discovery") .with(cookieCsrf()) - .param("email", "test@email.com") - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .param("email", "test@email.com")) .andExpect(status().isFound()) .andExpect(redirectedUrl("/login?discoveryPerformed=true&email=test%40email.com")); - mockMvc.perform(get("/login?discoveryPerformed=true&email=test@email.com") - .with(cookieCsrf()) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login?discoveryPerformed=true&email=test@email.com") + .with(cookieCsrf())) .andExpect(xpath("//input[@name='username']/@value").string("test@email.com")); } - @Test - void authorizeForClientWithIdpNotAllowed() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void authorizeForClientWithIdpNotAllowed(ZoneResolutionMode mode) throws Exception { String subdomain = "idp-not-allowed-" + generator.generate().toLowerCase(); IdentityZone zone = MultitenancyFixture.identityZone(subdomain, subdomain); zone = createOtherIdentityZone(zone.getSubdomain(), mockMvc, webApplicationContext, false, IdentityZoneHolder.getCurrentZoneId()); @@ -2694,18 +2726,20 @@ void authorizeForClientWithIdpNotAllowed() throws Exception { ScimUser user = createUser(scimUserProvisioning, generator, zone.getId()); MockHttpSession session = new MockHttpSession(); - SetServerNameRequestPostProcessor inZone = new SetServerNameRequestPostProcessor(subdomain + ".localhost"); - - MockHttpServletRequestBuilder post = post("/uaa/login.do") - .with(inZone) + // For ZONE_PATH mode, the zone prefix is in the path, so we don't use a context path + String loginPath = mode == ZoneResolutionMode.ZONE_PATH ? "/login.do" : "/uaa/login.do"; + String expectedRedirect = mode == ZoneResolutionMode.ZONE_PATH ? "/" : "/uaa/"; + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain, HttpMethod.POST, loginPath) .with(cookieCsrf()) - .contextPath("/uaa") .session(session) .param("username", user.getUserName()) .param("password", user.getPassword()); + if (mode == ZoneResolutionMode.SUBDOMAIN) { + post.contextPath("/uaa"); + } mockMvc.perform(post) - .andExpect(redirectedUrl("/uaa/")); + .andExpect(redirectedUrl(expectedRedirect)); // authorize for client that does not allow that idp String clientId = "different-provider-client"; @@ -2719,15 +2753,17 @@ void authorizeForClientWithIdpNotAllowed() throws Exception { client.setRegisteredRedirectUri(registeredRedirectUris); MockMvcUtils.createClient(webApplicationContext, client, zone); - MockHttpServletRequestBuilder authorize = get("/oauth/authorize") - .with(inZone) + MockHttpServletRequestBuilder authorize = mode.createRequestBuilder(subdomain, HttpMethod.GET, "/oauth/authorize") .session(session) .param("client_id", "different-provider-client") .param("response_type", "code") .param("client_secret", "secret") .param("garbage", "this-should-be-preserved"); - String expectedUrl = "http://" + subdomain + ".localhost/oauth/authorize?client_id=different-provider-client&response_type=code&client_secret=secret&garbage=this-should-be-preserved"; + // For ZONE_PATH mode, the URL uses localhost with zone path; for SUBDOMAIN mode, it uses subdomain.localhost + String expectedUrl = mode == ZoneResolutionMode.ZONE_PATH + ? "http://localhost/z/" + subdomain + "/oauth/authorize?client_id=different-provider-client&response_type=code&client_secret=secret&garbage=this-should-be-preserved" + : "http://" + subdomain + ".localhost/oauth/authorize?client_id=different-provider-client&response_type=code&client_secret=secret&garbage=this-should-be-preserved"; String html = mockMvc.perform(authorize) .andDo(print()) .andExpect(status().isUnauthorized()) @@ -2821,26 +2857,34 @@ void hasInvalidSuccess() throws Exception { } } - private static void attemptUnsuccessfulLogin(MockMvc mockMvc, int numberOfAttempts, String username, String subdomain) throws Exception { - String requestDomain = subdomain.isEmpty() ? "localhost" : subdomain + ".localhost"; - MockHttpServletRequestBuilder post = post("/uaa/login.do") - .with(new SetServerNameRequestPostProcessor(requestDomain)) - .with(cookieCsrf()) + private static void attemptUnsuccessfulLogin(MockMvc mockMvc, ZoneResolutionMode mode, int numberOfAttempts, String username, String subdomain) throws Exception { + String expectedRedirect = mode == ZoneResolutionMode.SUBDOMAIN ? "/uaa/login?error=login_failure" : + "/uaa/z/" + subdomain + "/login?error=login_failure"; + + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/uaa", "/login.do") .contextPath("/uaa") + .with(cookieCsrf()) .param("username", username) .param("password", "wrong_password"); for (int i = 0; i < numberOfAttempts; i++) { mockMvc.perform(post) - .andExpect(redirectedUrl("/uaa/login?error=login_failure")) - .andExpect(emptyCurrentUserCookie()); + .andExpect(redirectedUrl(expectedRedirect)) + .andExpect(emptyCurrentUserCookie(mode)); } } - private static ResultMatcher emptyCurrentUserCookie() { + private static ResultMatcher emptyCurrentUserCookie(ZoneResolutionMode mode) { return result -> { cookie().value("Current-User", isEmptyOrNullString()).match(result); cookie().maxAge("Current-User", 0).match(result); - cookie().path("Current-User", "/").match(result); + String expectedPath = mode == ZoneResolutionMode.ZONE_PATH ? null : "/"; + if (expectedPath != null) { + cookie().path("Current-User", expectedPath).match(result); + } else { + Cookie currentUserCookie = result.getResponse().getCookie("Current-User"); + assertThat(currentUserCookie).isNotNull(); + assertThat(currentUserCookie.getPath()).startsWith("/z/"); + } }; } 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 3e13ed25791..9338256e7ef 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 @@ -7,8 +7,11 @@ import org.cloudfoundry.identity.uaa.codestore.JdbcExpiringCodeStore; import org.cloudfoundry.identity.uaa.message.util.FakeJavaMailSender; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.IdentityZoneCreationResult; import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.PredictableGenerator; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; import org.cloudfoundry.identity.uaa.oauth.common.util.RandomValueStringGenerator; +import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; import org.cloudfoundry.identity.uaa.provider.IdentityProvider; import org.cloudfoundry.identity.uaa.provider.JdbcIdentityProviderProvisioning; import org.cloudfoundry.identity.uaa.provider.PasswordPolicy; @@ -18,11 +21,15 @@ import org.cloudfoundry.identity.uaa.scim.endpoints.PasswordChange; import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.util.SessionUtils; +import org.cloudfoundry.identity.uaa.client.UaaClientDetails; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpMethod; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.MediaType; @@ -52,13 +59,19 @@ import static org.hamcrest.Matchers.equalTo; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.forwardedUrl; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.model; 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 { + private final AlphanumericRandomValueStringGenerator subdomainGenerator = new AlphanumericRandomValueStringGenerator(); + @Autowired public WebApplicationContext webApplicationContext; private ExpiringCodeStore codeStore; @@ -291,7 +304,7 @@ public Map getParameterMap() { }; SessionUtils.setSavedRequestSession(session, savedRequest); - PredictableGenerator generator = new PredictableGenerator(); + PredictableGenerator generator = new PredictableGenerator("redirectSaved"); JdbcExpiringCodeStore store = webApplicationContext.getBean(JdbcExpiringCodeStore.class); store.setGenerator(generator); @@ -300,7 +313,7 @@ public Map getParameterMap() { .param("username", user.getUserName())) .andExpect(redirectedUrl("email_sent?code=reset_password")); - mockMvc.perform(createChangePasswordRequest(user, "test" + generator.counter.get(), true, "secret1", "secret1") + mockMvc.perform(createChangePasswordRequest(user, "redirectSaved" + generator.counter.get(), true, "secret1", "secret1") .session(session)) .andExpect(status().isFound()) .andExpect(redirectedUrl("/login?success=password_reset")); @@ -421,7 +434,16 @@ private MockHttpServletRequestBuilder createChangePasswordRequest(ScimUser user, } private MockHttpServletRequestBuilder createChangePasswordRequest(ScimUser user, String code, boolean useCSRF, String password, String passwordConfirmation) { - MockHttpServletRequestBuilder post = post("/reset_password.do"); + return createChangePasswordRequest(user, code, useCSRF, password, passwordConfirmation, null, null); + } + + private MockHttpServletRequestBuilder createChangePasswordRequest(ScimUser user, String code, boolean useCSRF, String password, String passwordConfirmation, ZoneResolutionMode mode, String subdomain) { + MockHttpServletRequestBuilder post; + if (mode != null && subdomain != null) { + post = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/reset_password.do"); + } else { + post = post("/reset_password.do"); + } if (useCSRF) { post.with(cookieCsrf()); } @@ -431,4 +453,126 @@ private MockHttpServletRequestBuilder createChangePasswordRequest(ScimUser user, .param("password_confirmation", passwordConfirmation); return post; } + + /** + * Creates another identity zone (with admin client) and a user in that zone for zone-path tests. + * Returns the created user; the zone is in the DB and request-based resolution will use it. + */ + private ScimUser createUserInOtherZone(String subdomain) throws Exception { + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + String adminToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mockMvc, "admin", "admin-secret", null, subdomain); + String username = new RandomValueStringGenerator().generate() + "@test.org"; + ScimUser user = new ScimUser(null, username, "givenname", "familyname"); + user.setPrimaryEmail(username); + user.setPassword("secret"); + return MockMvcUtils.createUserInZone(mockMvc, adminToken, user, zoneResult.getIdentityZone().getSubdomain()); + } + + /** + * Creates another identity zone (with admin client) and a user in that zone; returns both for tests that need to generate codes in the zone. + */ + private IdentityZoneCreationResult createZoneAndUserInOtherZone(String subdomain, ScimUser[] userOut) throws Exception { + UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", + "clients.admin,scim.read,scim.write,idps.write,uaa.admin", "http://redirect.url"); + adminClient.setClientSecret("admin-secret"); + IdentityZoneCreationResult zoneResult = MockMvcUtils.createOtherIdentityZoneAndReturnResult(subdomain, mockMvc, webApplicationContext, adminClient, IdentityZoneHolder.getCurrentZoneId()); + String adminToken = MockMvcUtils.getClientCredentialsOAuthAccessToken(mockMvc, "admin", "admin-secret", null, subdomain); + String username = new RandomValueStringGenerator().generate() + "@test.org"; + ScimUser user = new ScimUser(null, username, "givenname", "familyname"); + user.setPrimaryEmail(username); + user.setPassword("secret"); + ScimUser created = MockMvcUtils.createUserInZone(mockMvc, adminToken, user, zoneResult.getIdentityZone().getSubdomain()); + userOut[0] = created; + return zoneResult; + } + + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void get_forgot_password_within_zone(ZoneResolutionMode mode) throws Exception { + String subdomain = subdomainGenerator.generate().toLowerCase(); + MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, IdentityZoneHolder.getCurrentZoneId()); + + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/forgot_password") + .param("client_id", "example") + .param("redirect_uri", "http://example.com")) + .andExpect(status().isOk()) + .andExpect(view().name("forgot_password")) + .andExpect(model().attribute("client_id", "example")) + .andExpect(model().attribute("redirect_uri", "http://example.com")); + } + + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void forgot_password_redirects_to_email_sent_within_zone(ZoneResolutionMode mode) throws Exception { + String subdomain = subdomainGenerator.generate().toLowerCase(); + ScimUser user = createUserInOtherZone(subdomain); + + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/forgot_password.do") + .param("username", user.getUserName())) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("email_sent?code=reset_password")); + } + + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void get_email_sent_within_zone(ZoneResolutionMode mode) throws Exception { + String subdomain = subdomainGenerator.generate().toLowerCase(); + MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, IdentityZoneHolder.getCurrentZoneId()); + + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/email_sent") + .param("code", "reset_password")) + .andExpect(status().isOk()) + .andExpect(model().attribute("code", "reset_password")); + } + + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void reset_password_full_flow_within_zone(ZoneResolutionMode mode) throws Exception { + String subdomain = subdomainGenerator.generate().toLowerCase(); + ScimUser user = createUserInOtherZone(subdomain); + + PredictableGenerator generator = new PredictableGenerator("fp-" + subdomain); + JdbcExpiringCodeStore store = webApplicationContext.getBean(JdbcExpiringCodeStore.class); + store.setGenerator(generator); + + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/forgot_password.do") + .param("username", user.getUserName())) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("email_sent?code=reset_password")); + + String code = "fp-" + subdomain + generator.counter.get(); + mockMvc.perform(createChangePasswordRequest(user, code, true, "secret1", "secret1", mode, subdomain)) + .andExpect(status().isFound()) + .andExpect(redirectedUrl("/login?success=password_reset")); + } + + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void get_reset_password_with_code_within_zone(ZoneResolutionMode mode) throws Exception { + String subdomain = subdomainGenerator.generate().toLowerCase(); + ScimUser[] userHolder = new ScimUser[1]; + IdentityZoneCreationResult zoneResult = createZoneAndUserInOtherZone(subdomain, userHolder); + ScimUser user = userHolder[0]; + + IdentityZone zone = zoneResult.getIdentityZone(); + IdentityZone previousZone = IdentityZoneHolder.get(); + try { + IdentityZoneHolder.set(zone); + PasswordChange change = new PasswordChange(user.getId(), user.getUserName(), user.getPasswordLastModified(), "", ""); + ExpiringCode code = codeStore.generateCode(JsonUtils.writeValueAsString(change), new Timestamp(System.currentTimeMillis() + UaaResetPasswordService.PASSWORD_RESET_LIFETIME), FORGOT_PASSWORD_INTENT_PREFIX + user.getId(), zone.getId()); + + MockHttpServletRequestBuilder getRequest = mode.createRequestBuilder(subdomain, HttpMethod.GET, "/reset_password") + .param("code", code.getCode()) + .accept(MediaType.TEXT_HTML); + + mockMvc.perform(getRequest) + .andExpect(status().isOk()) + .andExpect(content().string(containsString("reset_password"))); + } finally { + IdentityZoneHolder.set(previousZone); + } + } } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/SessionControllerMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/SessionControllerMockMvcTests.java index 49396a96482..b05355ebd77 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/SessionControllerMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/login/SessionControllerMockMvcTests.java @@ -1,36 +1,57 @@ package org.cloudfoundry.identity.uaa.login; import org.cloudfoundry.identity.uaa.DefaultTestContext; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; +import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpMethod; import org.springframework.test.web.servlet.MockMvc; +import org.springframework.web.context.WebApplicationContext; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.view; @DefaultTestContext class SessionControllerMockMvcTests { + + private final AlphanumericRandomValueStringGenerator subdomainGenerator = new AlphanumericRandomValueStringGenerator(); + + @Autowired private MockMvc mockMvc; + @Autowired + private WebApplicationContext webApplicationContext; + @BeforeEach - void setUp(@Autowired MockMvc mockMvc) { - this.mockMvc = mockMvc; + void setUp() { + // No per-test setup required; zone is created inside each parameterized test } - @Test - void sessionEndpointWhichSupportsLegacyUaaSingular() throws Exception { - mockMvc.perform(get("/session") + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void sessionEndpointWhichSupportsLegacyUaaSingular(ZoneResolutionMode mode) throws Exception { + String subdomain = subdomainGenerator.generate().toLowerCase(); + MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, IdentityZoneHolder.getCurrentZoneId()); + + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/session") .param("clientId", "1") .param("messageOrigin", "origin")) .andExpect(status().isOk()) .andExpect(view().name("session")); } - @Test - void sessionManagementEndpointWhichSupportsUaaSingular() throws Exception { - mockMvc.perform(get("/session_management") + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void sessionManagementEndpointWhichSupportsUaaSingular(ZoneResolutionMode mode) throws Exception { + String subdomain = subdomainGenerator.generate().toLowerCase(); + MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, IdentityZoneHolder.getCurrentZoneId()); + + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/session_management") .param("clientId", "1") .param("messageOrigin", "origin")) .andExpect(status().isOk()) 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..2a505d9e02f 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 @@ -252,11 +252,24 @@ void codeThatIsExpiredIsDeletedOnCreateOfNewCode(String url) throws Exception { .accept(MediaType.APPLICATION_JSON) .content(requestBody); - mockMvc.perform(post) + MvcResult createResult = mockMvc.perform(post) .andExpect(status().isCreated()) .andReturn(); - assertThat(jdbcTemplate.queryForObject("select count(*) from expiring_code_store", Integer.class)).isOne(); + ExpiringCode newCode = JsonUtils.readValue(createResult.getResponse().getContentAsString(), ExpiringCode.class); + long now = System.currentTimeMillis(); + // Resilient to race: cleanup may not run if another thread won the CAS. Assert exactly one non-expired + // code exists and it is the one we just created (so we pass whether or not the throttled cleanup ran). + Integer nonExpiredCount = jdbcTemplate.queryForObject( + "SELECT count(*) FROM expiring_code_store WHERE expiresat > ?", + Integer.class, + now); + assertThat(nonExpiredCount).isOne(); + String storedCode = jdbcTemplate.queryForObject( + "SELECT code FROM expiring_code_store WHERE expiresat > ?", + String.class, + now); + assertThat(storedCode).isEqualTo(newCode.getCode()); } @Nested diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/ldap/LdapCertificateMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/ldap/LdapCertificateMockMvcTests.java index f68856cbb19..9bd0ba39407 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/ldap/LdapCertificateMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/ldap/LdapCertificateMockMvcTests.java @@ -5,12 +5,14 @@ import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils; import org.cloudfoundry.identity.uaa.provider.LdapIdentityProviderDefinition; import org.cloudfoundry.identity.uaa.test.InMemoryLdapServer; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpMethod; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.cloudfoundry.identity.uaa.oauth.common.util.RandomValueStringGenerator; @@ -25,7 +27,6 @@ import static org.springframework.http.MediaType.TEXT_HTML_VALUE; 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.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -102,11 +103,12 @@ void setUp(@Autowired WebApplicationContext webApplicationContext, @Autowired Mo MockMvcUtils.createIdentityProvider(mockMvc, trustedButExpiredCertZone, OriginKeys.LDAP, definition); } - @Test - void trusted_server_certificate() throws Exception { - mockMvc.perform(post("/login.do").accept(TEXT_HTML_VALUE) + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void trusted_server_certificate(ZoneResolutionMode mode) throws Exception { + mockMvc.perform(mode.createRequestBuilder(trustedCertZone.getIdentityZone().getSubdomain(), HttpMethod.POST, "/login.do") + .accept(TEXT_HTML_VALUE) .with(cookieCsrf()) - .with(new SetServerNameRequestPostProcessor(trustedCertZone.getIdentityZone().getSubdomain() + ".localhost")) .param("username", "marissa2") .param("password", LDAP)) .andExpect(status().isFound()) @@ -114,15 +116,19 @@ void trusted_server_certificate() throws Exception { .andExpect(authenticated()); } - @Test - void trusted_but_expired_server_certificate() throws Exception { - mockMvc.perform(post("/login.do").accept(TEXT_HTML_VALUE) + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void trusted_but_expired_server_certificate(ZoneResolutionMode mode) throws Exception { + String expectedRedirectUrl = mode == ZoneResolutionMode.SUBDOMAIN ? + "/login?error=login_failure" : + "/z/" + trustedButExpiredCertZone.getIdentityZone().getSubdomain() + "/login?error=login_failure"; + mockMvc.perform(mode.createRequestBuilder(trustedButExpiredCertZone.getIdentityZone().getSubdomain(), HttpMethod.POST, "/login.do") + .accept(TEXT_HTML_VALUE) .with(cookieCsrf()) - .with(new SetServerNameRequestPostProcessor(trustedButExpiredCertZone.getIdentityZone().getSubdomain() + ".localhost")) .param("username", "marissa2") .param("password", LDAP)) .andExpect(status().isFound()) - .andExpect(redirectedUrl("/login?error=login_failure")) + .andExpect(redirectedUrl(expectedRedirectUrl)) .andExpect(unauthenticated()); } } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/ldap/LdapSkipCertificateMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/ldap/LdapSkipCertificateMockMvcTests.java index a3d1e96211a..1c263b76173 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/ldap/LdapSkipCertificateMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/ldap/LdapSkipCertificateMockMvcTests.java @@ -6,12 +6,14 @@ import org.cloudfoundry.identity.uaa.oauth.common.util.RandomValueStringGenerator; import org.cloudfoundry.identity.uaa.provider.LdapIdentityProviderDefinition; import org.cloudfoundry.identity.uaa.test.InMemoryLdapServer; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpMethod; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.junit.jupiter.api.AfterAll; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.web.servlet.MockMvc; @@ -25,7 +27,6 @@ import static org.cloudfoundry.identity.uaa.provider.LdapIdentityProviderDefinition.LDAP_TLS_NONE; import static org.springframework.http.MediaType.TEXT_HTML_VALUE; import static org.springframework.security.test.web.servlet.response.SecurityMockMvcResultMatchers.authenticated; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.redirectedUrl; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -95,11 +96,12 @@ void setUp(@Autowired WebApplicationContext webApplicationContext, @Autowired Mo MockMvcUtils.createIdentityProvider(mockMvc, trustedButExpiredCertZone, OriginKeys.LDAP, definition); } - @Test - void ignoreServerCertificate() throws Exception { - mockMvc.perform(post("/login.do").accept(TEXT_HTML_VALUE) + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void ignoreServerCertificate(ZoneResolutionMode mode) throws Exception { + mockMvc.perform(mode.createRequestBuilder(trustedCertZone.getIdentityZone().getSubdomain(), HttpMethod.POST, "/login.do") + .accept(TEXT_HTML_VALUE) .with(cookieCsrf()) - .with(new SetServerNameRequestPostProcessor(trustedCertZone.getIdentityZone().getSubdomain() + ".localhost")) .param("username", "marissa2") .param("password", LDAP)) .andExpect(status().isFound()) @@ -107,11 +109,12 @@ void ignoreServerCertificate() throws Exception { .andExpect(authenticated()); } - @Test - void ignoreExpiredServerCertificate() throws Exception { - mockMvc.perform(post("/login.do").accept(TEXT_HTML_VALUE) + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void ignoreExpiredServerCertificate(ZoneResolutionMode mode) throws Exception { + mockMvc.perform(mode.createRequestBuilder(trustedButExpiredCertZone.getIdentityZone().getSubdomain(), HttpMethod.POST, "/login.do") + .accept(TEXT_HTML_VALUE) .with(cookieCsrf()) - .with(new SetServerNameRequestPostProcessor(trustedButExpiredCertZone.getIdentityZone().getSubdomain() + ".localhost")) .param("username", "marissa2") .param("password", LDAP)) .andExpect(status().isFound()) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenKeyEndpointMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenKeyEndpointMockMvcTests.java index aa6afdcbf20..f0e0a1af5fc 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenKeyEndpointMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenKeyEndpointMockMvcTests.java @@ -7,7 +7,7 @@ import org.cloudfoundry.identity.uaa.oauth.token.VerificationKeyResponse; import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.util.MapCollector; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneConfiguration; import org.cloudfoundry.identity.uaa.zone.IdentityZoneProvisioning; @@ -15,8 +15,10 @@ import org.cloudfoundry.identity.uaa.zone.MultitenantJdbcClientDetailsService; import org.cloudfoundry.identity.uaa.zone.TokenPolicy; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; @@ -87,12 +89,12 @@ void setSigningKeyAndDefaultClient() { setSigningKeyAndDefaultClient(SIGN_KEY); } - @Test - void checkTokenKey() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void checkTokenKey(ZoneResolutionMode mode) throws Exception { MvcResult result = mockMvc .perform( - get("/token_key") - .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + mode.createRequestBuilder(testZone.getSubdomain(), HttpMethod.GET, "/token_key") .accept(MediaType.APPLICATION_JSON) .header("Authorization", getBasicAuth(defaultClient)) ) @@ -103,33 +105,33 @@ void checkTokenKey() throws Exception { validateKey(key); } - @Test - void checkTokenKeyReturnETag() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void checkTokenKeyReturnETag(ZoneResolutionMode mode) throws Exception { mockMvc.perform( - get("/token_key") - .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + mode.createRequestBuilder(testZone.getSubdomain(), HttpMethod.GET, "/token_key") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(header().string("ETag", any(String.class))) .andReturn(); } - @Test - void checkTokenKeyReturns304IfResourceUnchanged() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void checkTokenKeyReturns304IfResourceUnchanged(ZoneResolutionMode mode) throws Exception { mockMvc.perform( - get("/token_key") - .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + mode.createRequestBuilder(testZone.getSubdomain(), HttpMethod.GET, "/token_key") .header("If-None-Match", testZone.getLastModified().getTime())) .andExpect(status().isNotModified()) .andReturn(); } - @Test - void checkTokenKey_IsNotFromDefaultZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void checkTokenKey_IsNotFromDefaultZone(ZoneResolutionMode mode) throws Exception { MvcResult nonDefaultZoneResponse = mockMvc .perform( - get("/token_key") - .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + mode.createRequestBuilder(testZone.getSubdomain(), HttpMethod.GET, "/token_key") .accept(MediaType.APPLICATION_JSON) .header("Authorization", getBasicAuth(defaultClient)) ) @@ -153,8 +155,9 @@ void checkTokenKey_IsNotFromDefaultZone() throws Exception { assertThat(defaultKeyResponse.getValue()).isNotEqualTo(nonDefaultKeyResponse.getValue()); } - @Test - void checkTokenKey_WhenKeysAreAsymmetric_asAuthenticatedUser() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void checkTokenKey_WhenKeysAreAsymmetric_asAuthenticatedUser(ZoneResolutionMode mode) throws Exception { UaaClientDetails client = new UaaClientDetails(new RandomValueStringGenerator().generate(), "", "foo,bar", @@ -164,8 +167,7 @@ void checkTokenKey_WhenKeysAreAsymmetric_asAuthenticatedUser() throws Exception webApplicationContext.getBean(MultitenantClientServices.class).addClientDetails(client, testZone.getSubdomain()); MvcResult result = mockMvc.perform( - get("/token_key") - .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + mode.createRequestBuilder(testZone.getSubdomain(), HttpMethod.GET, "/token_key") .accept(MediaType.APPLICATION_JSON) .header("Authorization", getBasicAuth(client))) .andExpect(status().isOk()) @@ -175,8 +177,9 @@ void checkTokenKey_WhenKeysAreAsymmetric_asAuthenticatedUser() throws Exception validateKey(key); } - @Test - void checkTokenKey_WhenKeysAreAsymmetric_asAuthenticatedUser_withoutCorrectScope() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void checkTokenKey_WhenKeysAreAsymmetric_asAuthenticatedUser_withoutCorrectScope(ZoneResolutionMode mode) throws Exception { setSigningKeyAndDefaultClient("key"); UaaClientDetails client = new UaaClientDetails(new RandomValueStringGenerator().generate(), "", @@ -188,8 +191,7 @@ void checkTokenKey_WhenKeysAreAsymmetric_asAuthenticatedUser_withoutCorrectScope mockMvc .perform( - get("/token_key") - .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + mode.createRequestBuilder(testZone.getSubdomain(), HttpMethod.GET, "/token_key") .accept(MediaType.APPLICATION_JSON) .header("Authorization", getBasicAuth(client)) ) @@ -197,12 +199,12 @@ void checkTokenKey_WhenKeysAreAsymmetric_asAuthenticatedUser_withoutCorrectScope .andReturn(); } - @Test - void checkTokenKey_asUnauthenticatedUser() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void checkTokenKey_asUnauthenticatedUser(ZoneResolutionMode mode) throws Exception { MvcResult result = mockMvc .perform( - get("/token_key") - .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + mode.createRequestBuilder(testZone.getSubdomain(), HttpMethod.GET, "/token_key") .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) @@ -212,12 +214,12 @@ void checkTokenKey_asUnauthenticatedUser() throws Exception { validateKey(key); } - @Test - void checkTokenKeys() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void checkTokenKeys(ZoneResolutionMode mode) throws Exception { MvcResult result = mockMvc .perform( - get("/token_keys") - .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + mode.createRequestBuilder(testZone.getSubdomain(), HttpMethod.GET, "/token_keys") .accept(MediaType.APPLICATION_JSON) .header("Authorization", getBasicAuth(defaultClient)) ) @@ -228,33 +230,33 @@ void checkTokenKeys() throws Exception { validateKeys(keys); } - @Test - void checkTokenKeysReturnETag() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void checkTokenKeysReturnETag(ZoneResolutionMode mode) throws Exception { mockMvc.perform( - get("/token_keys") - .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + mode.createRequestBuilder(testZone.getSubdomain(), HttpMethod.GET, "/token_keys") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(header().string("ETag", any(String.class))) .andReturn(); } - @Test - void checkTokenKeysReturns304IfResourceUnchanged() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void checkTokenKeysReturns304IfResourceUnchanged(ZoneResolutionMode mode) throws Exception { mockMvc.perform( - get("/token_keys") - .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + mode.createRequestBuilder(testZone.getSubdomain(), HttpMethod.GET, "/token_keys") .header("If-None-Match", testZone.getLastModified().getTime())) .andExpect(status().isNotModified()) .andReturn(); } - @Test - void checkTokenKeys_asUnauthenticatedUser() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void checkTokenKeys_asUnauthenticatedUser(ZoneResolutionMode mode) throws Exception { MvcResult result = mockMvc .perform( - get("/token_keys") - .with(new SetServerNameRequestPostProcessor(testZone.getSubdomain() + ".localhost")) + mode.createRequestBuilder(testZone.getSubdomain(), HttpMethod.GET, "/token_keys") .accept(MediaType.APPLICATION_JSON) ) .andExpect(status().isOk()) diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenMvcMockTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenMvcMockTests.java index aa68bcad51e..91a943a8c37 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenMvcMockTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/TokenMvcMockTests.java @@ -47,9 +47,10 @@ import org.cloudfoundry.identity.uaa.user.UaaUser; import org.cloudfoundry.identity.uaa.user.UaaUserDatabase; import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; +import org.cloudfoundry.identity.uaa.util.TimeService; import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.util.SessionUtils; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; import org.cloudfoundry.identity.uaa.util.UaaTokenUtils; import org.cloudfoundry.identity.uaa.util.UaaUrlUtils; import org.cloudfoundry.identity.uaa.zone.IdentityZone; @@ -61,8 +62,11 @@ 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.EnumSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.web.servlet.FilterRegistrationBean; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.mock.web.MockHttpServletResponse; @@ -979,8 +983,9 @@ void getOauthToken_usingClientCredentials_withClientIdAndSecretInRequestBody_sho .andExpect(status().isOk()); } - @Test - void clientIdentityProviderWithoutAllowedProvidersForPasswordGrantWorksInOtherZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void clientIdentityProviderWithoutAllowedProvidersForPasswordGrantWorksInOtherZone(ZoneResolutionMode mode) throws Exception { String scopes = "space.*.developer,space.*.admin,org.*.reader,org.123*.admin,*.*,*,openid"; //a client without allowed providers in non default zone should always be rejected @@ -1000,8 +1005,7 @@ void clientIdentityProviderWithoutAllowedProvidersForPasswordGrantWorksInOtherZo String userScopes = "space.1.developer,space.2.developer,org.1.reader,org.2.reader,org.12345.admin,scope.one,scope.two,scope.three,openid"; setUpUser(jdbcScimUserProvisioning, jdbcScimGroupMembershipManager, jdbcScimGroupProvisioning, username, userScopes, OriginKeys.UAA, testZone.getId()); - mockMvc.perform(post("/oauth/token") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .param("username", username) .param("password", "secret") .with(httpBasic(clientId, SECRET)) @@ -1009,8 +1013,7 @@ void clientIdentityProviderWithoutAllowedProvidersForPasswordGrantWorksInOtherZo .param(OAuth2Utils.CLIENT_ID, clientId)) .andExpect(status().isOk()); - mockMvc.perform(post("/oauth/token") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .param("username", username) .param("password", "secret") .with(httpBasic(clientId2, SECRET)) @@ -1021,18 +1024,22 @@ void clientIdentityProviderWithoutAllowedProvidersForPasswordGrantWorksInOtherZo @Test void getToken_withPasswordGrantType_resultsInUserLastLogonTimestampUpdate() throws Exception { - long delayTime = 15; + TimeService timeService = webApplicationContext.getBean(TimeService.class); + long delayTime = 2; String username = "testuser" + generator.generate(); String userScopes = "uaa.user"; ScimUser user = setUpUser(jdbcScimUserProvisioning, jdbcScimGroupMembershipManager, jdbcScimGroupProvisioning, username, userScopes, OriginKeys.UAA, IdentityZone.getUaaZoneId()); webApplicationContext.getBean(UaaUserDatabase.class).updateLastLogonTime(user.getId()); webApplicationContext.getBean(UaaUserDatabase.class).updateLastLogonTime(user.getId()); - + // On a fast processor there isn't enough granularity in the time; ensure the clock has moved + // before each password grant so we get distinct last-logon timestamps. Only sleep when needed. + // Use the same TimeService as production so we observe the same clock. + long afterSetup = timeService.getCurrentTimeMillis(); + ensureClockMoved(timeService, afterSetup, delayTime); String accessToken = getAccessTokenForPasswordGrant(username); Long firstTimestamp = getPreviousLogonTime(accessToken); - //simulate two sequential tests - //on a fast processor, there isn't enough granularity in the time - Thread.sleep(delayTime); + long afterFirstGrant = timeService.getCurrentTimeMillis(); + ensureClockMoved(timeService, afterFirstGrant, delayTime); String accessToken2 = getAccessTokenForPasswordGrant(username); Long secondTimestamp = getPreviousLogonTime(accessToken2); @@ -1040,6 +1047,17 @@ void getToken_withPasswordGrantType_resultsInUserLastLogonTimestampUpdate() thro assertThat(firstTimestamp).isLessThan(secondTimestamp); } + /** + * Waits until the application's {@link TimeService} clock has advanced past {@code notBefore}. + * Only sleeps when the clock has not yet moved, so fast runs avoid unnecessary delay. + * Uses the same TimeService as production (e.g. last-logon updates) so we observe the same clock. + */ + private void ensureClockMoved(TimeService timeService, long notBefore, long sleepMs) throws InterruptedException { + while (timeService.getCurrentTimeMillis() <= notBefore) { + Thread.sleep(sleepMs); + } + } + private String getAccessTokenForPasswordGrant(String username) throws Exception { String response = mockMvc.perform( post("/oauth/token") @@ -1069,8 +1087,9 @@ private Long getPreviousLogonTime(String accessToken) throws Exception { return userInfo.getPreviousLogonSuccess(); } - @Test - void clientIdentityProviderClientWithoutAllowedProvidersForAuthCodeAlreadyLoggedInWorksInAnotherZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void clientIdentityProviderClientWithoutAllowedProvidersForAuthCodeAlreadyLoggedInWorksInAnotherZone(ZoneResolutionMode mode) throws Exception { //a client without allowed providers in non default zone should always be rejected String subdomain = "testzone" + generator.generate(); IdentityZone testZone = setupIdentityZone(subdomain); @@ -1100,9 +1119,8 @@ void clientIdentityProviderClientWithoutAllowedProvidersForAuthCodeAlreadyLogged IdentityZoneHolder.clear(); //no providers is ok - mockMvc.perform(get("/oauth/authorize") + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/oauth/authorize") .session(session) - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) .param(OAuth2Utils.RESPONSE_TYPE, "code") .param(OAuth2Utils.STATE, state) .param(OAuth2Utils.CLIENT_ID, clientId) @@ -1110,9 +1128,8 @@ void clientIdentityProviderClientWithoutAllowedProvidersForAuthCodeAlreadyLogged .andExpect(status().isFound()); //correct provider is ok - MvcResult result = mockMvc.perform(get("/oauth/authorize") + MvcResult result = mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/oauth/authorize") .session(session) - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) .param(OAuth2Utils.RESPONSE_TYPE, "code") .param(OAuth2Utils.STATE, state) .param(OAuth2Utils.CLIENT_ID, clientId2) @@ -1121,9 +1138,8 @@ void clientIdentityProviderClientWithoutAllowedProvidersForAuthCodeAlreadyLogged .andReturn(); //other provider, not ok - mockMvc.perform(get("/oauth/authorize") + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/oauth/authorize") .session(session) - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) .param(OAuth2Utils.RESPONSE_TYPE, "code") .param(OAuth2Utils.STATE, state) .param(OAuth2Utils.CLIENT_ID, clientId3) @@ -1175,8 +1191,9 @@ void clientIdentityProviderRestrictionForPasswordGrant() throws Exception { .andExpect(status().isOk()); } - @Test - void oauth_authorize_api_endpoint() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void oauth_authorize_api_endpoint(ZoneResolutionMode mode) throws Exception { String subdomain = "testzone" + generator.generate().toLowerCase(); IdentityZone testZone = setupIdentityZone(subdomain, new ArrayList<>(defaultAuthorities)); IdentityZoneHolder.set(testZone); @@ -1203,9 +1220,8 @@ void oauth_authorize_api_endpoint() throws Exception { String state = generator.generate(); - MockHttpServletRequestBuilder oauthAuthorizeGet = get("/oauth/authorize") + MockHttpServletRequestBuilder oauthAuthorizeGet = mode.createRequestBuilder(subdomain, HttpMethod.GET, "/oauth/authorize") .header("Authorization", "Bearer " + uaaUserAccessToken) - .header("Host", subdomain + ".localhost") .param(RESPONSE_TYPE, "code") .param(SCOPE, "") .param(OAuth2Utils.STATE, state) @@ -1222,9 +1238,8 @@ void oauth_authorize_api_endpoint() throws Exception { String code = ((List) query.get("code")).getFirst(); assertThat(code).isNotNull(); - String body = mockMvc.perform(post("/oauth/token") + String body = mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .with(httpBasic(clientId, SECRET)) - .header("Host", subdomain + ".localhost") .accept(APPLICATION_JSON) .param(GRANT_TYPE, GRANT_TYPE_AUTHORIZATION_CODE) .param(OAuth2Utils.CLIENT_ID, clientId) @@ -3189,8 +3204,9 @@ void getClientCredentialsWithAuthoritiesExcludedForDefaultIdentityZone() throws } } - @Test - void getClientCredentialsTokenForOtherIdentityZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void getClientCredentialsTokenForOtherIdentityZone(ZoneResolutionMode mode) throws Exception { String subdomain = "testzone" + generator.generate(); IdentityZone testZone = setupIdentityZone(subdomain); IdentityZoneHolder.set(testZone); @@ -3198,9 +3214,8 @@ void getClientCredentialsTokenForOtherIdentityZone() throws Exception { String scopes = "space.*.developer,space.*.admin,org.*.reader,org.123*.admin,*.*,*"; setUpClients(clientId, scopes, scopes, GRANT_TYPES, true); IdentityZoneHolder.clear(); - mockMvc.perform(post("http://" + subdomain + ".localhost/oauth/token") + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .accept(MediaType.APPLICATION_JSON_VALUE) - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) .with(httpBasic(clientId, SECRET)) .param("grant_type", "client_credentials") .param("client_id", clientId) @@ -3208,8 +3223,9 @@ void getClientCredentialsTokenForOtherIdentityZone() throws Exception { .andExpect(status().isOk()); } - @Test - void misconfigured_jwt_keys_returns_proper_error() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void misconfigured_jwt_keys_returns_proper_error(ZoneResolutionMode mode) throws Exception { String subdomain = "testzone" + generator.generate(); IdentityZone testZone = setupIdentityZone(subdomain); testZone.getConfig().getTokenPolicy().setActiveKeyId("invalid-active-key"); @@ -3220,9 +3236,8 @@ void misconfigured_jwt_keys_returns_proper_error() throws Exception { setUpClients(clientId, scopes, scopes, GRANT_TYPES, true); IdentityZoneHolder.clear(); - mockMvc.perform(post("http://localhost/oauth/token") + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .accept(MediaType.APPLICATION_JSON_VALUE) - .header("Host", subdomain + ".localhost") .with(httpBasic(clientId, SECRET)) .param("grant_type", "client_credentials") .param("client_id", clientId) @@ -3252,16 +3267,16 @@ void getClientCredentialsTokenForOtherIdentityZoneFromDefaultZoneFails() throws .andExpect(status().isUnauthorized()); } - @Test - void getClientCredentialsTokenForDefaultIdentityZoneFromOtherZoneFails() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void getClientCredentialsTokenForDefaultIdentityZoneFromOtherZoneFails(ZoneResolutionMode mode) throws Exception { String clientId = "testclient" + generator.generate(); String scopes = "space.*.developer,space.*.admin,org.*.reader,org.123*.admin,*.*,*"; setUpClients(clientId, scopes, scopes, GRANT_TYPES, true); String subdomain = "testzone" + generator.generate(); setupIdentityZone(subdomain); - mockMvc.perform(post("http://" + subdomain + ".localhost/oauth/token") + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .accept(MediaType.APPLICATION_JSON_VALUE) - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) .with(httpBasic(clientId, SECRET)) .param("grant_type", "client_credentials") .param("client_id", clientId) @@ -3289,8 +3304,9 @@ void getPasswordGrantInvalidPassword() throws Exception { .andExpect(content().string("{\"error\":\"invalid_client\",\"error_description\":\"Bad credentials\"}")); } - @Test - void getPasswordGrantTokenExpiredPasswordForOtherZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void getPasswordGrantTokenExpiredPasswordForOtherZone(ZoneResolutionMode mode) throws Exception { String username = generator.generate() + "@test.org"; String subdomain = "testzone" + generator.generate(); IdentityZone testZone = setupIdentityZone(subdomain); @@ -3311,8 +3327,7 @@ void getPasswordGrantTokenExpiredPasswordForOtherZone() throws Exception { setUpUser(username); IdentityZoneHolder.clear(); - mockMvc.perform(post("/oauth/token") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .param("username", username) .param("password", "secret") .with(httpBasic(clientId, SECRET)) @@ -3325,8 +3340,7 @@ void getPasswordGrantTokenExpiredPasswordForOtherZone() throws Exception { Timestamp t = new Timestamp(cal.getTimeInMillis()); assertThat(webApplicationContext.getBean(JdbcTemplate.class).update("UPDATE users SET passwd_lastmodified = ? WHERE username = ?", t, username)).isOne(); - mockMvc.perform(post("/oauth/token") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .param("username", username) .param("password", "secret") .with(httpBasic(clientId, SECRET)) @@ -3336,8 +3350,9 @@ void getPasswordGrantTokenExpiredPasswordForOtherZone() throws Exception { .andExpect(content().string("{\"error\":\"unauthorized\",\"error_description\":\"password change required\"}")); } - @Test - void password_grant_with_default_user_groups_in_zone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void password_grant_with_default_user_groups_in_zone(ZoneResolutionMode mode) throws Exception { String username = generator.generate() + "@test.org"; String subdomain = "testzone" + generator.generate(); String clientId = "testclient" + generator.generate(); @@ -3345,8 +3360,7 @@ void password_grant_with_default_user_groups_in_zone() throws Exception { defaultGroups.addAll(UserConfig.DEFAULT_ZONE_GROUPS); createNonDefaultZone(username, subdomain, clientId, defaultGroups, "custom.default.group,openid"); - MvcResult result = mockMvc.perform(post("/oauth/token") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) + MvcResult result = mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .param("username", username) .param("password", "secret") .with(httpBasic(clientId, SECRET)) @@ -3359,15 +3373,15 @@ void password_grant_with_default_user_groups_in_zone() throws Exception { assertThat(claims.getScope()).containsExactlyInAnyOrder("openid", "custom.default.group"); } - @Test - void getPasswordGrantTokenForOtherZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void getPasswordGrantTokenForOtherZone(ZoneResolutionMode mode) throws Exception { String username = generator.generate() + "@test.org"; String subdomain = "testzone" + generator.generate(); String clientId = "testclient" + generator.generate(); createNonDefaultZone(username, subdomain, clientId); - MvcResult result = mockMvc.perform(post("/oauth/token") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) + MvcResult result = mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .param("username", username) .param("password", "secret") .with(httpBasic(clientId, SECRET)) @@ -3379,8 +3393,9 @@ void getPasswordGrantTokenForOtherZone() throws Exception { assertThat("http://" + subdomain.toLowerCase() + ".localhost:8080/uaa/oauth/token").isEqualTo(claims.getIss()); } - @Test - void getPasswordGrantForDefaultIdentityZoneFromOtherZoneFails() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void getPasswordGrantForDefaultIdentityZoneFromOtherZoneFails(ZoneResolutionMode mode) throws Exception { String username = generator.generate() + "@test.org"; String clientId = "testclient" + generator.generate(); String scopes = "cloud_controller.read"; @@ -3393,8 +3408,7 @@ void getPasswordGrantForDefaultIdentityZoneFromOtherZoneFails() throws Exception setupIdentityProvider(); IdentityZoneHolder.clear(); - mockMvc.perform(post("/oauth/token") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) + mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .param("username", username) .param("password", "secret") .with(httpBasic(clientId, SECRET)) @@ -3424,8 +3438,9 @@ void getPasswordGrantForOtherIdentityZoneFromDefaultZoneFails() throws Exception .param(OAuth2Utils.CLIENT_ID, clientId)).andExpect(status().isUnauthorized()); } - @Test - void getTokenScopesNotInAuthentication() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void getTokenScopesNotInAuthentication(ZoneResolutionMode mode) throws Exception { String subdomain = "testzone" + generator.generate().toLowerCase(); IdentityZone testZone = setupIdentityZone(subdomain, new ArrayList<>(defaultAuthorities)); IdentityZoneHolder.set(testZone); @@ -3447,8 +3462,7 @@ void getTokenScopesNotInAuthentication() throws Exception { MockHttpSession session = getAuthenticatedSession(user); String state = generator.generate(); - MockHttpServletRequestBuilder authRequest = get("/oauth/authorize") - .header("Host", subdomain + ".localhost") + MockHttpServletRequestBuilder authRequest = mode.createRequestBuilder(subdomain, HttpMethod.GET, "/oauth/authorize") .session(session) .param(RESPONSE_TYPE, "code") .param(OAuth2Utils.STATE, state) @@ -3460,10 +3474,9 @@ void getTokenScopesNotInAuthentication() throws Exception { UriComponentsBuilder builder = UriComponentsBuilder.fromUriString(location); String code = builder.build().getQueryParams().get("code").getFirst(); - authRequest = post("/oauth/token") + authRequest = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .with(httpBasic(clientId, SECRET)) .header("Accept", APPLICATION_JSON_VALUE) - .header("Host", subdomain + ".localhost") .param(GRANT_TYPE, GRANT_TYPE_AUTHORIZATION_CODE) .param("code", code) .param(OAuth2Utils.REDIRECT_URI, "http://localhost/test"); @@ -3606,8 +3619,9 @@ void passwordGrantTokenForDefaultZoneOpaque() throws Exception { assertThat((Boolean) claims.get(ClaimConstants.REVOCABLE)).as("Token revocable claim must be set to true").isTrue(); } - @Test - void nonDefaultZoneJwtRevocable() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void nonDefaultZoneJwtRevocable(ZoneResolutionMode mode) throws Exception { String username = generator.generate() + "@test.org"; String subdomain = "testzone" + generator.generate(); String clientId = "testclient" + generator.generate(); @@ -3618,9 +3632,8 @@ void nonDefaultZoneJwtRevocable() throws Exception { try { defaultZone.getConfig().getTokenPolicy().setJwtRevocable(true); zoneProvisioning.update(defaultZone); - MockHttpServletRequestBuilder post = post("/oauth/token") + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/oauth/token") .with(httpBasic(clientId, SECRET)) - .header("Host", subdomain + ".localhost") .param("username", username) .param("password", "secret") .param(OAuth2Utils.GRANT_TYPE, "password") diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/ZonePathTokenMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/ZonePathTokenMockMvcTests.java new file mode 100644 index 00000000000..20eb750293b --- /dev/null +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/token/ZonePathTokenMockMvcTests.java @@ -0,0 +1,59 @@ +package org.cloudfoundry.identity.uaa.mock.token; + +import org.assertj.core.api.Assertions; +import org.cloudfoundry.identity.uaa.constants.OriginKeys; +import org.cloudfoundry.identity.uaa.oauth.common.util.OAuth2Utils; +import org.cloudfoundry.identity.uaa.oauth.jwt.Jwt; +import org.cloudfoundry.identity.uaa.oauth.jwt.JwtHelper; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; +import org.cloudfoundry.identity.uaa.util.JsonUtils; +import org.cloudfoundry.identity.uaa.zone.IdentityZone; +import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpMethod; + +import java.util.Map; + +import static org.cloudfoundry.identity.uaa.authentication.AbstractClientParametersAuthenticationFilter.CLIENT_SECRET; +import static org.cloudfoundry.identity.uaa.oauth.token.ClaimConstants.ISS; +import static org.cloudfoundry.identity.uaa.oauth.token.TokenConstants.GRANT_TYPE_CLIENT_CREDENTIALS; +import static org.springframework.http.MediaType.APPLICATION_FORM_URLENCODED; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +public class ZonePathTokenMockMvcTests extends AbstractTokenMockMvcTests { + + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void clientCredentialsGrant(ZoneResolutionMode mode) throws Exception { + String subdomain = "testzone" + generator.generate().toLowerCase(); + IdentityZone testZone = setupIdentityZone(subdomain); + IdentityZoneHolder.set(testZone); + setupIdentityProvider(OriginKeys.UAA); + + String scopes = "uaa.admin"; + + String clientId = "testclient" + generator.generate(); + setUpClients(clientId, scopes, scopes, "client_credentials", true, null, null); + + IdentityZoneHolder.clear(); + + String tokenResult = mockMvc.perform(mode.createRequestBuilder(subdomain, HttpMethod.GET, "/oauth/token") + .accept(APPLICATION_JSON) + .contentType(APPLICATION_FORM_URLENCODED) + .param(OAuth2Utils.GRANT_TYPE, GRANT_TYPE_CLIENT_CREDENTIALS) + .param(OAuth2Utils.RESPONSE_TYPE, "token") + .param(OAuth2Utils.CLIENT_ID, clientId) + .param(CLIENT_SECRET, SECRET) + .param(OAuth2Utils.REDIRECT_URI, TEST_REDIRECT_URI)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.access_token").isNotEmpty()) + .andReturn().getResponse().getContentAsString(); + Map response = JsonUtils.readValueAsMap(tokenResult); + Jwt tokenClaims = JwtHelper.decode((String) response.get("access_token")); + Assertions.assertThat(tokenClaims.getClaimSet().getStringClaim(ISS)) + .isEqualTo("http://%s.localhost:8080/uaa/oauth/token".formatted(testZone.getSubdomain())); + } +} diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/util/MockMvcUtils.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/util/MockMvcUtils.java index 2e91c351352..4afe4857c8c 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/util/MockMvcUtils.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/util/MockMvcUtils.java @@ -69,6 +69,7 @@ import org.springframework.context.ApplicationListener; import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.event.ApplicationEventMulticaster; +import org.springframework.http.HttpMethod; import org.springframework.http.MediaType; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.mock.web.MockHttpServletRequest; @@ -122,6 +123,7 @@ import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.httpBasic; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.options; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.put; import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; @@ -134,6 +136,93 @@ private MockMvcUtils() { throw new java.lang.UnsupportedOperationException("This is a utility class and cannot be instantiated"); } + /** + * Mode for resolving identity zone in tests: by host subdomain or by path prefix {@code /z/{subdomain}/}. + * The test passes the path suffix (e.g. {@code "/oauth/token"}); + * the mode constructs the full path. + */ + public enum ZoneResolutionMode { + SUBDOMAIN { + @Override + public MockHttpServletRequestBuilder createRequestBuilder(String subdomain, HttpMethod method, String contextPath, String pathSuffix) { + if (StringUtils.hasText(subdomain)) { + return requestBuilderForMethod(method, contextPath + pathSuffix) + .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")); + } else { + return requestBuilderForMethod(method, contextPath + pathSuffix); + } + } + + @Override + public String getServletPath(String subdomain, String pathSuffix) { + return pathSuffix; + } + }, + ZONE_PATH { + @Override + public MockHttpServletRequestBuilder createRequestBuilder(String subdomain, HttpMethod method, String contextPath, String pathSuffix) { + if (StringUtils.hasText(subdomain)) { + return requestBuilderForMethod(method, contextPath + "/z/{subdomain}" + pathSuffix, subdomain); + } else { + return requestBuilderForMethod(method, contextPath + pathSuffix); + } + } + + @Override + public String getServletPath(String subdomain, String pathSuffix) { + if (StringUtils.hasText(subdomain)) { + return "/z/" + subdomain + pathSuffix; + } else { + return pathSuffix; + } + } + }; + + public MockHttpServletRequestBuilder createRequestBuilder(String subdomain, HttpMethod method, String pathSuffix) { + return this.createRequestBuilder(subdomain, method, "", pathSuffix); + } + public abstract MockHttpServletRequestBuilder createRequestBuilder( + String subdomain, + HttpMethod method, + String contextPath, + String pathSuffix + ); + + /** + * Returns the servlet path for the given subdomain and path suffix. + * For SUBDOMAIN mode, this is just the pathSuffix. + * For ZONE_PATH mode, this includes the /z/{subdomain} prefix. + */ + public abstract String getServletPath(String subdomain, String pathSuffix); + } + + /** + * Builds a MockHttpServletRequestBuilder for the given HTTP method and path. + * + * @param method the HTTP method (GET, POST, PUT, DELETE) + * @param path the path (may contain path variables like {@code /z/{subdomain}/oauth/token}) + * @param pathVars optional path variable values + * @return the request builder + */ + public static MockHttpServletRequestBuilder requestBuilderForMethod(HttpMethod method, String path, Object... pathVars) { + if (method == HttpMethod.GET) { + return get(path, pathVars); + } + if (method == HttpMethod.POST) { + return post(path, pathVars); + } + if (method == HttpMethod.PUT) { + return put(path, pathVars); + } + if (method == HttpMethod.DELETE) { + return delete(path, pathVars); + } + if (method == HttpMethod.OPTIONS) { + return options(path, pathVars); + } + throw new IllegalArgumentException("Unsupported method: " + method); + } + private static final String SIMPLESAMLPHP_UAA_ACCEPTANCE = "http://simplesamlphp.uaa-acceptance.cf-app.com"; public static final String IDP_META_DATA = @@ -588,10 +677,12 @@ public static ScimUser createUserInZone(MockMvc mockMvc, String accessToken, Sci } public static ScimUser createUserInZone(MockMvc mockMvc, String accessToken, ScimUser user, String subdomain, String zoneId) throws Exception { - String requestDomain = subdomain.isEmpty() ? "localhost" : subdomain + ".localhost"; - MockHttpServletRequestBuilder post = post("/Users"); + return createUserInZone(ZoneResolutionMode.SUBDOMAIN, mockMvc, accessToken, user, subdomain, zoneId); + } + + public static ScimUser createUserInZone(ZoneResolutionMode mode, MockMvc mockMvc, String accessToken, ScimUser user, String subdomain, String zoneId) throws Exception { + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/Users"); post.header("Authorization", "Bearer " + accessToken) - .with(new SetServerNameRequestPostProcessor(requestDomain)) .contentType(APPLICATION_JSON) .content(JsonUtils.writeValueAsBytes(user)); if (hasText(zoneId)) { @@ -603,10 +694,12 @@ public static ScimUser createUserInZone(MockMvc mockMvc, String accessToken, Sci } public static ScimUser readUserInZone(MockMvc mockMvc, String accessToken, String userId, String subdomain, String zoneId) throws Exception { - String requestDomain = subdomain.isEmpty() ? "localhost" : subdomain + ".localhost"; - MockHttpServletRequestBuilder get = get("/Users/" + userId); + return readUserInZone(ZoneResolutionMode.SUBDOMAIN, mockMvc, accessToken, userId, subdomain, zoneId); + } + + public static ScimUser readUserInZone(ZoneResolutionMode mode, MockMvc mockMvc, String accessToken, String userId, String subdomain, String zoneId) throws Exception { + MockHttpServletRequestBuilder get = mode.createRequestBuilder(subdomain, HttpMethod.GET, "/Users/" + userId); get.header("Authorization", "Bearer " + accessToken) - .with(new SetServerNameRequestPostProcessor(requestDomain)) .accept(APPLICATION_JSON); if (hasText(zoneId)) { get.header(IdentityZoneSwitchingFilter.HEADER, zoneId); @@ -1063,7 +1156,17 @@ public static String getClientCredentialsOAuthAccessToken(MockMvc mockMvc, String scope, String subdomain, boolean opaque) throws Exception { - MockHttpServletRequestBuilder oauthTokenPost = post("/oauth/token") + return getClientCredentialsOAuthAccessToken(ZoneResolutionMode.SUBDOMAIN, mockMvc, clientId, clientSecret, scope, subdomain, opaque); + } + + public static String getClientCredentialsOAuthAccessToken(ZoneResolutionMode mode, + MockMvc mockMvc, + String clientId, + String clientSecret, + String scope, + String subdomain, + boolean opaque) throws Exception { + MockHttpServletRequestBuilder oauthTokenPost = mode.createRequestBuilder(subdomain != null ? subdomain : "", HttpMethod.POST, "/oauth/token") .with(httpBasic(clientId, clientSecret)) .param("grant_type", "client_credentials") .param("client_id", clientId) @@ -1071,9 +1174,6 @@ public static String getClientCredentialsOAuthAccessToken(MockMvc mockMvc, if (!hasText(scope)) { oauthTokenPost.param("scope", scope); } - if (subdomain != null && !subdomain.isEmpty()) { - oauthTokenPost.with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")); - } if (opaque) { oauthTokenPost.param(TokenConstants.REQUEST_TOKEN_FORMAT, OPAQUE.getStringValue()); } @@ -1336,10 +1436,19 @@ public MockHttpServletRequest postProcessRequest(MockHttpServletRequest request) public static class PredictableGenerator extends RandomValueStringGenerator { public AtomicInteger counter = new AtomicInteger(1); + private final String prefix; + + public PredictableGenerator() { + this("test"); + } + + public PredictableGenerator(String prefix) { + this.prefix = prefix; + } @Override public String generate() { - return "test" + counter.incrementAndGet(); + return prefix + counter.incrementAndGet(); } } } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointsMockMvcTests.java index 77acca6d62f..9f4cf9094f4 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneEndpointsMockMvcTests.java @@ -37,7 +37,10 @@ import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; import org.cloudfoundry.identity.uaa.util.JsonUtils; import org.cloudfoundry.identity.uaa.util.KeyWithCertTest; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpMethod; import org.cloudfoundry.identity.uaa.util.beans.DbUtils; import org.cloudfoundry.identity.uaa.zone.BrandingInformation; import org.cloudfoundry.identity.uaa.zone.BrandingInformation.Banner; @@ -1977,8 +1980,9 @@ void zoneAdminTokenAgainstZoneEndpoints() throws Exception { } - @Test - void successfulUserManagementInZoneUsingAdminClient() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void successfulUserManagementInZoneUsingAdminClient(ZoneResolutionMode mode) throws Exception { String subdomain = generator.generate().toLowerCase(); UaaClientDetails adminClient = new UaaClientDetails("admin", null, null, "client_credentials", "scim.read,scim.write"); adminClient.setClientSecret("admin-secret"); @@ -1990,16 +1994,15 @@ void successfulUserManagementInZoneUsingAdminClient() throws Exception { checkAuditEventListener(1, AuditEventType.ClientCreateSuccess, clientCreateEventListener, identityZone.getId(), "http://localhost:8080/uaa/oauth/token", creationResult.getZoneAdminUser().getId()); String scimAdminToken = testClient.getClientCredentialsOAuthAccessToken("admin", "admin-secret", "scim.write,scim.read", subdomain); - ScimUser user = createUser(scimAdminToken, subdomain); + ScimUser user = createUser(mode, scimAdminToken, subdomain); checkAuditEventListener(1, AuditEventType.UserCreatedEvent, userModifiedEventListener, identityZone.getId(), "http://" + subdomain + ".localhost:8080/uaa/oauth/token", "admin"); user.setUserName("updated-username@test.com"); - MockHttpServletRequestBuilder put = put("/Users/" + user.getId()) + MockHttpServletRequestBuilder put = mode.createRequestBuilder(subdomain, HttpMethod.PUT, "/Users/" + user.getId()) .header("Authorization", "Bearer " + scimAdminToken) .header("If-Match", "\"" + user.getVersion() + "\"") .contentType(APPLICATION_JSON) - .content(JsonUtils.writeValueAsString(user)) - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")); + .content(JsonUtils.writeValueAsString(user)); MvcResult result = mockMvc.perform(put) .andExpect(status().isOk()) @@ -2008,14 +2011,13 @@ void successfulUserManagementInZoneUsingAdminClient() throws Exception { checkAuditEventListener(2, AuditEventType.UserModifiedEvent, userModifiedEventListener, identityZone.getId(), "http://" + subdomain + ".localhost:8080/uaa/oauth/token", "admin"); user = JsonUtils.readValue(result.getResponse().getContentAsString(), ScimUser.class); - List users = getUsersInZone(subdomain, scimAdminToken); + List users = getUsersInZone(mode, subdomain, scimAdminToken); assertThat(users).containsExactly(user); - MockHttpServletRequestBuilder delete = delete("/Users/" + user.getId()) + MockHttpServletRequestBuilder delete = mode.createRequestBuilder(subdomain, HttpMethod.DELETE, "/Users/" + user.getId()) .header("Authorization", "Bearer " + scimAdminToken) .header("If-Match", "\"" + user.getVersion() + "\"") - .contentType(APPLICATION_JSON) - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")); + .contentType(APPLICATION_JSON); mockMvc.perform(delete) .andExpect(status().isOk()) @@ -2023,12 +2025,13 @@ void successfulUserManagementInZoneUsingAdminClient() throws Exception { .andReturn(); checkAuditEventListener(3, AuditEventType.UserDeletedEvent, userModifiedEventListener, identityZone.getId(), "http://" + subdomain + ".localhost:8080/uaa/oauth/token", "admin"); - users = getUsersInZone(subdomain, scimAdminToken); + users = getUsersInZone(mode, subdomain, scimAdminToken); assertThat(users).isEmpty(); } - @Test - void createAndListUsersInOtherZoneIsUnauthorized() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void createAndListUsersInOtherZoneIsUnauthorized(ZoneResolutionMode mode) throws Exception { String subdomain = generator.generate(); MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, IdentityZoneHolder.getCurrentZoneId()); @@ -2039,18 +2042,15 @@ void createAndListUsersInOtherZoneIsUnauthorized() throws Exception { ScimUser user = getScimUser(); byte[] requestBody = JsonUtils.writeValueAsBytes(user); - MockHttpServletRequestBuilder post = post("/Users") - .with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")) + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/Users") .header("Authorization", "Bearer " + defaultZoneAdminToken) .contentType(APPLICATION_JSON) .content(requestBody); mockMvc.perform(post).andExpect(status().isUnauthorized()); - MockHttpServletRequestBuilder get = get("/Users").header("Authorization", "Bearer " + defaultZoneAdminToken); - if (subdomain != null && !"".equals(subdomain)) { - get.with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")); - } + MockHttpServletRequestBuilder get = mode.createRequestBuilder(subdomain, HttpMethod.GET, "/Users") + .header("Authorization", "Bearer " + defaultZoneAdminToken); mockMvc.perform(get).andExpect(status().isUnauthorized()).andReturn(); } @@ -2277,16 +2277,17 @@ private IdentityZone createZoneReturn() throws Exception { } private ScimUser createUser(String token, String subdomain) throws Exception { + return createUser(ZoneResolutionMode.SUBDOMAIN, token, subdomain); + } + + private ScimUser createUser(ZoneResolutionMode mode, String token, String subdomain) throws Exception { ScimUser user = getScimUser(); byte[] requestBody = JsonUtils.writeValueAsBytes(user); - MockHttpServletRequestBuilder post = post("/Users") + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain != null ? subdomain : "", HttpMethod.POST, "/Users") .header("Authorization", "Bearer " + token) .contentType(APPLICATION_JSON) .content(requestBody); - if (subdomain != null && !subdomain.isEmpty()) { - post.with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")); - } MvcResult result = mockMvc.perform(post) .andExpect(status().isCreated()) @@ -2413,10 +2414,12 @@ private IdentityZone createSimpleIdentityZone(String id) { } private List getUsersInZone(String subdomain, String token) throws Exception { - MockHttpServletRequestBuilder get = get("/Users").header("Authorization", "Bearer " + token); - if (subdomain != null && !subdomain.isEmpty()) { - get.with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")); - } + return getUsersInZone(ZoneResolutionMode.SUBDOMAIN, subdomain, token); + } + + private List getUsersInZone(ZoneResolutionMode mode, String subdomain, String token) throws Exception { + MockHttpServletRequestBuilder get = mode.createRequestBuilder(subdomain != null ? subdomain : "", HttpMethod.GET, "/Users") + .header("Authorization", "Bearer " + token); MvcResult mvcResult = mockMvc.perform(get).andExpect(status().isOk()).andReturn(); JsonNode root = JsonUtils.readTree(mvcResult.getResponse().getContentAsString()); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneSwitchingFilterMockMvcTest.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneSwitchingFilterMockMvcTest.java index 6b40127ff20..bbd1e9418fd 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneSwitchingFilterMockMvcTest.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/mock/zones/IdentityZoneSwitchingFilterMockMvcTest.java @@ -10,7 +10,10 @@ import org.cloudfoundry.identity.uaa.test.TestClient; import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; import org.cloudfoundry.identity.uaa.util.JsonUtils; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpMethod; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -59,8 +62,9 @@ void setUp() throws Exception { generator = new AlphanumericRandomValueStringGenerator(); } - @Test - void switchingZones() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void switchingZones(ZoneResolutionMode mode) throws Exception { IdentityZone identityZone = createZone(mockMvc, identityToken); String zoneId = identityZone.getId(); String zoneAdminToken = MockMvcUtils.getZoneAdminToken(mockMvc, adminToken, zoneId); @@ -69,24 +73,22 @@ void switchingZones() throws Exception { ClientDetails client = createClientInOtherZone(mockMvc, generator, zoneAdminToken, status().isCreated(), HEADER, zoneId); // Authenticate with new Client in new Zone - mockMvc.perform(post("/oauth/token") + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.POST, "/oauth/token") .param("grant_type", "client_credentials") - .with(httpBasic(client.getClientId(), client.getClientSecret())) - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + .with(httpBasic(client.getClientId(), client.getClientSecret()))) .andExpect(status().isOk()); } - @Test - void switchingZoneWithSubdomain() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void switchingZoneWithSubdomain(ZoneResolutionMode mode) throws Exception { IdentityZone identityZone = createZone(mockMvc, identityToken); String zoneAdminToken = MockMvcUtils.getZoneAdminToken(mockMvc, adminToken, identityZone.getId()); ClientDetails client = createClientInOtherZone(mockMvc, generator, zoneAdminToken, status().isCreated(), SUBDOMAIN_HEADER, identityZone.getSubdomain()); - mockMvc.perform( - post("/oauth/token") - .param("grant_type", "client_credentials") - .with(httpBasic(client.getClientId(), client.getClientSecret())) - .with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost"))) + mockMvc.perform(mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.POST, "/oauth/token") + .param("grant_type", "client_credentials") + .with(httpBasic(client.getClientId(), client.getClientSecret()))) .andExpect(status().isOk()); } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/performance/LoginPagePerformanceMockMvcTest.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/performance/LoginPagePerformanceMockMvcTest.java index 6754ac1aa33..17ba48a9742 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/performance/LoginPagePerformanceMockMvcTest.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/performance/LoginPagePerformanceMockMvcTest.java @@ -13,14 +13,16 @@ import org.cloudfoundry.identity.uaa.provider.OIDCIdentityProviderDefinition; import org.cloudfoundry.identity.uaa.provider.oauth.OidcMetadataFetcher; import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpMethod; import org.cloudfoundry.identity.uaa.web.LimitedModeUaaFilter; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.cloudfoundry.identity.uaa.zone.MultitenancyFixture; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.mock.mockito.MockBean; import org.springframework.boot.web.servlet.FilterRegistrationBean; @@ -41,7 +43,6 @@ import static org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.CookieCsrfPostProcessor.cookieCsrf; import static org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.createOtherIdentityZoneAndReturnResult; import static org.springframework.http.MediaType.TEXT_HTML; -import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @DefaultTestContext @@ -92,8 +93,10 @@ void tearDown(@Autowired IdentityZoneConfigurationBootstrap identityZoneConfigur MockMvcUtils.resetLimitedModeStatusFile(webApplicationContext, originalLimitedModeStatusFile); } - @Test + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) void idpDiscoveryRedirectsToOIDCProvider( + ZoneResolutionMode mode, @Autowired JdbcIdentityProviderProvisioning jdbcIdentityProviderProvisioning ) throws Exception { String subdomain = "oidc-discovery-" + generator.generate().toLowerCase(); @@ -121,10 +124,9 @@ void idpDiscoveryRedirectsToOIDCProvider( StopWatch stopWatch = new StopWatch(); stopWatch.start(); for (int i = 0; i < 1000; i++) { - MvcResult mvcResult = mockMvc.perform(get("/login") + MvcResult mvcResult = mockMvc.perform(mode.createRequestBuilder(zone.getSubdomain(), HttpMethod.GET, "/login") .with(cookieCsrf()) - .header("Accept", TEXT_HTML) - .with(new SetServerNameRequestPostProcessor(zone.getSubdomain() + ".localhost"))) + .header("Accept", TEXT_HTML)) .andExpect(status().isOk()) .andReturn(); MockHttpServletResponse response = mvcResult.getResponse(); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java index f90f8dcb374..eade36e3f8e 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/OpenIdConnectEndpointsMockMvcTests.java @@ -3,12 +3,14 @@ import org.cloudfoundry.identity.uaa.DefaultTestContext; import org.cloudfoundry.identity.uaa.account.OpenIdConfiguration; import org.cloudfoundry.identity.uaa.util.JsonUtils; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpMethod; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.mock.web.MockHttpServletResponse; import org.springframework.test.web.servlet.MockMvc; @@ -44,58 +46,70 @@ void tearDown() throws Exception { deleteIdentityZone(identityZone.getId(), mockMvc); } - @Test - void wellKnownEndpoint() throws Exception { - for (String host : Arrays.asList("localhost", "subdomain.localhost")) { - for (String url : Arrays.asList("/.well-known/openid-configuration", "/oauth/token/.well-known/openid-configuration")) { - MockHttpServletResponse response = mockMvc.perform( - get(url) - .header("Host", host) - .servletPath(url) - .with(new SetServerNameRequestPostProcessor(host)) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andReturn().getResponse(); + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void wellKnownEndpoint(ZoneResolutionMode mode) throws Exception { + for (String url : Arrays.asList("/.well-known/openid-configuration", "/oauth/token/.well-known/openid-configuration")) { + String servletPath = mode == ZoneResolutionMode.ZONE_PATH + ? "/z/" + identityZone.getSubdomain() + url + : url; + // For ZONE_PATH mode, use localhost (no subdomain); for SUBDOMAIN mode, use subdomain.localhost + String host = mode == ZoneResolutionMode.ZONE_PATH + ? "localhost" + : identityZone.getSubdomain() + ".localhost"; + MockHttpServletResponse response = mockMvc.perform( + mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, url) + .header("Host", host) + .servletPath(servletPath) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn().getResponse(); - OpenIdConfiguration openIdConfiguration = JsonUtils.readValue(response.getContentAsString(), OpenIdConfiguration.class); - assertThat(openIdConfiguration).isNotNull(); - assertThat(openIdConfiguration.getIssuer()).isEqualTo("http://" + host + ":8080/uaa/oauth/token"); - assertThat(openIdConfiguration.getAuthUrl()).isEqualTo("http://" + host + "/oauth/authorize"); - assertThat(openIdConfiguration.getTokenUrl()).isEqualTo("http://" + host + "/oauth/token"); - assertThat(openIdConfiguration.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt"}); - assertThat(openIdConfiguration.getTokenEndpointAuthSigningValues()).containsExactly(new String[]{"RS256", "HS256"}); - assertThat(openIdConfiguration.getUserInfoUrl()).isEqualTo("http://" + host + "/userinfo"); - assertThat(openIdConfiguration.getScopes()).containsExactly(new String[]{"openid", "profile", "email", "phone", ROLES, USER_ATTRIBUTES}); - assertThat(openIdConfiguration.getResponseTypes()).containsExactly(new String[]{"code", "code id_token", "id_token", "token id_token"}); - assertThat(openIdConfiguration.getIdTokenSigningAlgValues()).containsExactly(new String[]{"RS256", "HS256"}); - assertThat(openIdConfiguration.getClaimTypesSupported()).containsExactly(new String[]{"normal"}); - assertThat(openIdConfiguration.getClaimsSupported()).containsExactly(new String[]{"sub", "user_name", "origin", "iss", "auth_time", "amr", "acr", "client_id", - "aud", "zid", "grant_type", "user_id", "azp", "scope", "exp", "iat", "jti", "rev_sig", "cid", "given_name", "family_name", "phone_number", "email"}); - assertThat(openIdConfiguration.isClaimsParameterSupported()).isFalse(); - assertThat(openIdConfiguration.getServiceDocumentation()).isEqualTo("http://docs.cloudfoundry.org/api/uaa/"); - assertThat(openIdConfiguration.getUiLocalesSupported()).containsExactly(new String[]{"en-US"}); - } + OpenIdConfiguration openIdConfiguration = JsonUtils.readValue(response.getContentAsString(), OpenIdConfiguration.class); + assertThat(openIdConfiguration).isNotNull(); + // Note: The issuer URL is constructed from the identity zone's subdomain, not the request host + // So even in ZONE_PATH mode, the issuer URL contains the subdomain + assertThat(openIdConfiguration.getIssuer()).isEqualTo("http://" + identityZone.getSubdomain() + ".localhost:8080/uaa/oauth/token"); + assertThat(openIdConfiguration.getAuthUrl()).isEqualTo("http://" + host + "/oauth/authorize"); + assertThat(openIdConfiguration.getTokenUrl()).isEqualTo("http://" + host + "/oauth/token"); + assertThat(openIdConfiguration.getTokenAMR()).containsExactly(new String[]{"client_secret_basic", "client_secret_post", "private_key_jwt"}); + assertThat(openIdConfiguration.getTokenEndpointAuthSigningValues()).containsExactly(new String[]{"RS256", "HS256"}); + assertThat(openIdConfiguration.getUserInfoUrl()).isEqualTo("http://" + host + "/userinfo"); + assertThat(openIdConfiguration.getScopes()).containsExactly(new String[]{"openid", "profile", "email", "phone", ROLES, USER_ATTRIBUTES}); + assertThat(openIdConfiguration.getResponseTypes()).containsExactly(new String[]{"code", "code id_token", "id_token", "token id_token"}); + assertThat(openIdConfiguration.getIdTokenSigningAlgValues()).containsExactly(new String[]{"RS256", "HS256"}); + assertThat(openIdConfiguration.getClaimTypesSupported()).containsExactly(new String[]{"normal"}); + assertThat(openIdConfiguration.getClaimsSupported()).containsExactly(new String[]{"sub", "user_name", "origin", "iss", "auth_time", "amr", "acr", "client_id", + "aud", "zid", "grant_type", "user_id", "azp", "scope", "exp", "iat", "jti", "rev_sig", "cid", "given_name", "family_name", "phone_number", "email"}); + assertThat(openIdConfiguration.isClaimsParameterSupported()).isFalse(); + assertThat(openIdConfiguration.getServiceDocumentation()).isEqualTo("http://docs.cloudfoundry.org/api/uaa/"); + assertThat(openIdConfiguration.getUiLocalesSupported()).containsExactly(new String[]{"en-US"}); } } - @Test - void userInfoEndpointIsCorrect() throws Exception { - for (String host : Arrays.asList("localhost", "subdomain.localhost")) { - for (String url : Arrays.asList("/.well-known/openid-configuration", "/oauth/token/.well-known/openid-configuration")) { - MockHttpServletResponse response = mockMvc.perform( - get(url) - .header("Host", host) - .servletPath(url) - .with(new SetServerNameRequestPostProcessor(host)) - .accept(APPLICATION_JSON)) - .andExpect(status().isOk()) - .andReturn().getResponse(); + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void userInfoEndpointIsCorrect(ZoneResolutionMode mode) throws Exception { + for (String url : Arrays.asList("/.well-known/openid-configuration", "/oauth/token/.well-known/openid-configuration")) { + String servletPath = mode == ZoneResolutionMode.ZONE_PATH + ? "/z/" + identityZone.getSubdomain() + url + : url; + // For ZONE_PATH mode, use localhost (no subdomain); for SUBDOMAIN mode, use subdomain.localhost + String host = mode == ZoneResolutionMode.ZONE_PATH + ? "localhost" + : identityZone.getSubdomain() + ".localhost"; + MockHttpServletResponse response = mockMvc.perform( + mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.GET, url) + .header("Host", host) + .servletPath(servletPath) + .accept(APPLICATION_JSON)) + .andExpect(status().isOk()) + .andReturn().getResponse(); - OpenIdConfiguration openIdConfiguration = JsonUtils.readValue(response.getContentAsString(), OpenIdConfiguration.class); + OpenIdConfiguration openIdConfiguration = JsonUtils.readValue(response.getContentAsString(), OpenIdConfiguration.class); - mockMvc.perform(get(openIdConfiguration.getUserInfoUrl())) - .andExpect(status().isUnauthorized()); - } + mockMvc.perform(get(openIdConfiguration.getUserInfoUrl())) + .andExpect(status().isUnauthorized()); } } } diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpointsMockMvcTests.java index 448258b74ef..aec5b27814d 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimGroupEndpointsMockMvcTests.java @@ -25,7 +25,10 @@ import org.cloudfoundry.identity.uaa.scim.jdbc.JdbcScimGroupProvisioning; import org.cloudfoundry.identity.uaa.test.TestClient; import org.cloudfoundry.identity.uaa.util.JsonUtils; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.http.HttpMethod; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.cloudfoundry.identity.uaa.zone.IdentityZoneSwitchingFilter; @@ -35,7 +38,6 @@ 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.ValueSource; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; @@ -458,8 +460,9 @@ void getGroupsInOtherZone_withZoneAdminToken_returnsOkWithResults() throws Excep getSystemScopes(null).size() + 2 - 1).isEqualTo(searchResults.getResources().size()); } - @Test - void getGroupsInOtherZone_withZoneUserToken_returnsOkWithResults() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void getGroupsInOtherZone_withZoneUserToken_returnsOkWithResults(ZoneResolutionMode mode) throws Exception { String subdomain = new RandomValueStringGenerator(8).generate(); UaaClientDetails bootstrapClient = null; MockMvcUtils.IdentityZoneCreationResult result = MockMvcUtils.createOtherIdentityZoneAndReturnResult( @@ -475,8 +478,7 @@ void getGroupsInOtherZone_withZoneUserToken_returnsOkWithResults() throws Except ScimUser zoneUser = createUserAndAddToGroups(result.getIdentityZone(), Sets.newHashSet(Collections.singletonList("scim.read"))); String basicDigestHeaderValue = "Basic " + new String(Base64.encodeBase64((zonedClientId + ":" + zonedClientSecret).getBytes())); - MockHttpServletRequestBuilder oauthTokenPost = post("/oauth/token") - .with(new SetServerNameRequestPostProcessor(result.getIdentityZone().getSubdomain() + ".localhost")) + MockHttpServletRequestBuilder oauthTokenPost = mode.createRequestBuilder(result.getIdentityZone().getSubdomain(), HttpMethod.POST, "/oauth/token") .header("Authorization", basicDigestHeaderValue) .param("grant_type", "password") .param("client_id", zonedClientId) @@ -487,8 +489,7 @@ void getGroupsInOtherZone_withZoneUserToken_returnsOkWithResults() throws Except OAuthToken oauthToken = JsonUtils.readValue(tokenResult.getResponse().getContentAsString(), OAuthToken.class); String zoneUserToken = oauthToken.accessToken; - MockHttpServletRequestBuilder get = get("/Groups") - .with(new SetServerNameRequestPostProcessor(result.getIdentityZone().getSubdomain() + ".localhost")) + MockHttpServletRequestBuilder get = mode.createRequestBuilder(result.getIdentityZone().getSubdomain(), HttpMethod.GET, "/Groups") .header("Authorization", "Bearer " + zoneUserToken) .param("attributes", "displayName") .param("filter", "displayName co \"scim\"") @@ -501,8 +502,7 @@ void getGroupsInOtherZone_withZoneUserToken_returnsOkWithResults() throws Except SearchResults searchResults = JsonUtils.readValue(mvcResult.getResponse().getContentAsString(), SearchResults.class); assertThat(searchResults.getResources()).hasSameSizeAs(getSystemScopes("scim")); - get = get("/Groups") - .with(new SetServerNameRequestPostProcessor(result.getIdentityZone().getSubdomain() + ".localhost")) + get = mode.createRequestBuilder(result.getIdentityZone().getSubdomain(), HttpMethod.GET, "/Groups") .header("Authorization", "Bearer " + zoneUserToken) .contentType(MediaType.APPLICATION_JSON) .accept(APPLICATION_JSON); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java index 8a1a40b27bf..3e8df4b1fc8 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/scim/endpoints/ScimUserEndpointsMockMvcTests.java @@ -30,7 +30,8 @@ import org.cloudfoundry.identity.uaa.test.ZoneSeederExtension; import org.cloudfoundry.identity.uaa.util.AlphanumericRandomValueStringGenerator; import org.cloudfoundry.identity.uaa.util.JsonUtils; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; +import org.springframework.http.HttpMethod; import org.cloudfoundry.identity.uaa.zone.IdentityZone; import org.cloudfoundry.identity.uaa.zone.IdentityZoneHolder; import org.cloudfoundry.identity.uaa.zone.IdentityZoneSwitchingFilter; @@ -41,7 +42,12 @@ 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.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; + +import java.util.stream.Stream; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpStatus; @@ -231,8 +237,9 @@ void default_password_policy_does_not_allow_empty_passwords() throws Exception { .andExpect(jsonPath("$.message").value("Password must be at least 1 characters in length.")); } - @Test - void createUserInOtherZoneWithUaaAdminTokenFromNonDefaultZone() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void createUserInOtherZoneWithUaaAdminTokenFromNonDefaultZone(ZoneResolutionMode mode) throws Exception { IdentityZone identityZone = getIdentityZone(); String authorities = "uaa.admin"; @@ -240,11 +247,10 @@ void createUserInOtherZoneWithUaaAdminTokenFromNonDefaultZone() throws Exception String uaaAdminTokenFromOtherZone = testClient.getClientCredentialsOAuthAccessToken("testClientId", "testClientSecret", "uaa.admin", identityZone.getSubdomain()); byte[] requestBody = JsonUtils.writeValueAsBytes(getScimUser()); - MockHttpServletRequestBuilder post = post("/Users") + MockHttpServletRequestBuilder post = mode.createRequestBuilder(identityZone.getSubdomain(), HttpMethod.POST, "/Users") .header("Authorization", "Bearer " + uaaAdminTokenFromOtherZone) .contentType(APPLICATION_JSON) .content(requestBody); - post.with(new SetServerNameRequestPostProcessor(identityZone.getSubdomain() + ".localhost")); post.header(HEADER, IdentityZone.getUaaZoneId()); mockMvc.perform(post).andExpect(status().isForbidden()); @@ -596,9 +602,18 @@ void userSelfAccessGetAndPost() throws Exception { getAndReturnUser(HttpStatus.OK.value(), updatedUser, selfToken); } + static Stream urlAndZoneModeProvider() { + return Stream.of( + Arguments.of("/Users", ZoneResolutionMode.SUBDOMAIN), + Arguments.of("/Users", ZoneResolutionMode.ZONE_PATH), + Arguments.of("/Users/", ZoneResolutionMode.SUBDOMAIN), + Arguments.of("/Users/", ZoneResolutionMode.ZONE_PATH) + ); + } + @ParameterizedTest - @ValueSource(strings = {"/Users", "/Users/"}) - void createUserInOtherZoneIsUnauthorized(String url) throws Exception { + @MethodSource("urlAndZoneModeProvider") + void createUserInOtherZoneIsUnauthorized(String url, ZoneResolutionMode mode) throws Exception { String subdomain = generator.generate(); MockMvcUtils.createOtherIdentityZone(subdomain, mockMvc, webApplicationContext, IdentityZoneHolder.getCurrentZoneId()); @@ -610,8 +625,7 @@ void createUserInOtherZoneIsUnauthorized(String url) throws Exception { ScimUser user = getScimUser(); byte[] requestBody = JsonUtils.writeValueAsBytes(user); - MockHttpServletRequestBuilder post = post(url) - .with(new SetServerNameRequestPostProcessor(otherSubdomain + ".localhost")) + MockHttpServletRequestBuilder post = mode.createRequestBuilder(otherSubdomain, HttpMethod.POST, url) .header("Authorization", "Bearer " + zoneAdminToken) .contentType(APPLICATION_JSON) .content(requestBody); @@ -619,10 +633,11 @@ void createUserInOtherZoneIsUnauthorized(String url) throws Exception { mockMvc.perform(post).andExpect(status().isUnauthorized()); } - @Test - void unlockAccount() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void unlockAccount(ZoneResolutionMode mode) throws Exception { ScimUser userToLockout = createUser(uaaAdminToken); - attemptUnsuccessfulLogin(5, userToLockout.getUserName(), ""); + attemptUnsuccessfulLogin(mode, 5, userToLockout.getUserName(), ""); UserAccountStatus alteredAccountStatus = new UserAccountStatus(); alteredAccountStatus.setLocked(false); @@ -635,10 +650,11 @@ void unlockAccount() throws Exception { .andExpect(redirectedUrl("/")); } - @Test - void accountStatusEmptyPatchDoesNotUnlock() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void accountStatusEmptyPatchDoesNotUnlock(ZoneResolutionMode mode) throws Exception { ScimUser userToLockout = createUser(uaaAdminToken); - attemptUnsuccessfulLogin(5, userToLockout.getUserName(), ""); + attemptUnsuccessfulLogin(mode, 5, userToLockout.getUserName(), ""); updateAccountStatus(userToLockout, new UserAccountStatus()) .andExpect(status().isOk()) @@ -737,10 +753,11 @@ void tryMultipleStatusUpdatesWithInvalidLock() throws Exception { .andExpect(redirectedUrl("/")); } - @Test - void tryMultipleStatusUpdatesWithInvalidRemovalOfPasswordChange() throws Exception { + @ParameterizedTest + @EnumSource(ZoneResolutionMode.class) + void tryMultipleStatusUpdatesWithInvalidRemovalOfPasswordChange(ZoneResolutionMode mode) throws Exception { ScimUser user = createUser(uaaAdminToken); - attemptUnsuccessfulLogin(5, user.getUserName(), ""); + attemptUnsuccessfulLogin(mode, 5, user.getUserName(), ""); UserAccountStatus alteredAccountStatus = new UserAccountStatus(); alteredAccountStatus.setPasswordChangeRequired(false); @@ -1289,18 +1306,23 @@ private ScimUser createUser(ScimUser user, String token, String subdomain, Strin } private ResultActions createUserAndReturnResult(ScimUser user, String token, String subdomain, String switchZone) throws Exception { - return createUserAndReturnResult("/Users", user, token, subdomain, switchZone); + return createUserAndReturnResult(ZoneResolutionMode.SUBDOMAIN, "/Users", user, token, subdomain, switchZone); } private ResultActions createUserAndReturnResult(String url, ScimUser user, String token, String subdomain, String switchZone) throws Exception { + return createUserAndReturnResult(ZoneResolutionMode.SUBDOMAIN, url, user, token, subdomain, switchZone); + } + + private ResultActions createUserAndReturnResult(ZoneResolutionMode mode, ScimUser user, String token, String subdomain, String switchZone) throws Exception { + return createUserAndReturnResult(mode, "/Users", user, token, subdomain, switchZone); + } + + private ResultActions createUserAndReturnResult(ZoneResolutionMode mode, String url, ScimUser user, String token, String subdomain, String switchZone) throws Exception { byte[] requestBody = JsonUtils.writeValueAsBytes(user); - MockHttpServletRequestBuilder post = post(url) + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain != null ? subdomain : "", HttpMethod.POST, url) .header("Authorization", "Bearer " + token) .contentType(APPLICATION_JSON) .content(requestBody); - if (subdomain != null && !"".equals(subdomain)) { - post.with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")); - } if (switchZone != null) { post.header(HEADER, switchZone); } @@ -1390,10 +1412,8 @@ private ResultActions attemptLogin(ScimUser user) throws Exception { .param("password", user.getPassword())); } - private void attemptUnsuccessfulLogin(int numberOfAttempts, String username, String subdomain) throws Exception { - String requestDomain = "".equals(subdomain) ? "localhost" : subdomain + ".localhost"; - MockHttpServletRequestBuilder post = post("/login.do") - .with(new SetServerNameRequestPostProcessor(requestDomain)) + private void attemptUnsuccessfulLogin(ZoneResolutionMode mode, int numberOfAttempts, String username, String subdomain) throws Exception { + MockHttpServletRequestBuilder post = mode.createRequestBuilder(subdomain, HttpMethod.POST, "/login.do") .with(cookieCsrf()) .param("username", username) .param("password", "wrong_password"); diff --git a/uaa/src/test/java/org/cloudfoundry/identity/uaa/test/TestClient.java b/uaa/src/test/java/org/cloudfoundry/identity/uaa/test/TestClient.java index d907acc1cf9..53de6f733ac 100644 --- a/uaa/src/test/java/org/cloudfoundry/identity/uaa/test/TestClient.java +++ b/uaa/src/test/java/org/cloudfoundry/identity/uaa/test/TestClient.java @@ -1,10 +1,11 @@ package org.cloudfoundry.identity.uaa.test; import org.apache.commons.codec.binary.Base64; +import org.cloudfoundry.identity.uaa.mock.util.MockMvcUtils.ZoneResolutionMode; import org.cloudfoundry.identity.uaa.mock.util.OAuthToken; import org.cloudfoundry.identity.uaa.oauth.token.TokenConstants; import org.cloudfoundry.identity.uaa.util.JsonUtils; -import org.cloudfoundry.identity.uaa.util.SetServerNameRequestPostProcessor; +import org.springframework.http.HttpMethod; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.MvcResult; import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; @@ -27,17 +28,19 @@ public String getClientCredentialsOAuthAccessToken(String clientId, String clien public String getClientCredentialsOAuthAccessToken(String clientId, String clientSecret, String scope, String subdomain) throws Exception { + return getClientCredentialsOAuthAccessToken(ZoneResolutionMode.SUBDOMAIN, clientId, clientSecret, scope, subdomain); + } + + public String getClientCredentialsOAuthAccessToken(ZoneResolutionMode mode, String clientId, String clientSecret, String scope, String subdomain) + throws Exception { String basicDigestHeaderValue = "Basic " + new String(Base64.encodeBase64((clientId + ":" + clientSecret).getBytes())); - MockHttpServletRequestBuilder oauthTokenPost = post("/oauth/token") + MockHttpServletRequestBuilder oauthTokenPost = mode.createRequestBuilder(subdomain != null ? subdomain : "", HttpMethod.POST, "/oauth/token") .header("Authorization", basicDigestHeaderValue) .param("grant_type", "client_credentials") .param("client_id", clientId) .param(TokenConstants.REQUEST_TOKEN_FORMAT, OPAQUE.getStringValue()) .param("scope", scope); - if (subdomain != null && !"".equals(subdomain)) { - oauthTokenPost.with(new SetServerNameRequestPostProcessor(subdomain + ".localhost")); - } MvcResult result = mockMvc.perform(oauthTokenPost) .andExpect(status().isOk()) .andReturn(); diff --git a/uaa/src/test/resources/integration_test_properties.yml b/uaa/src/test/resources/integration_test_properties.yml index 4f8fb45050d..5d5348cc16e 100644 --- a/uaa/src/test/resources/integration_test_properties.yml +++ b/uaa/src/test/resources/integration_test_properties.yml @@ -423,16 +423,27 @@ uaa: whitelist: endpoints: - /oauth/authorize/** + - /z/*/oauth/authorize/** - /oauth/token/** + - /z/*/oauth/token/** - /check_token/** + - /z/*/check_token/** - /login/** + - /z/*/login/** - /login.do + - /z/*/login.do - /logout/** + - /z/*/logout/** - /logout.do + - /z/*/logout.do - /saml/** + - /z/*/saml/** - /autologin/** + - /z/*/autologin/** - /authenticate/** + - /z/*/authenticate/** - /idp_discovery/** + - /z/*/idp_discovery/** methods: - GET - HEAD