diff --git a/account-gui/vite.config.js b/account-gui/vite.config.js index 0aa30677..8e6ba475 100644 --- a/account-gui/vite.config.js +++ b/account-gui/vite.config.js @@ -26,6 +26,11 @@ export default defineConfig({ changeOrigin: false, secure: false }, + '/create-from-institution-login': { + target: 'http://localhost:8081', + changeOrigin: false, + secure: false + }, '/tiqr': { target: 'http://localhost:8081', changeOrigin: false, @@ -33,4 +38,4 @@ export default defineConfig({ } } }, -}); \ No newline at end of file +}); diff --git a/myconext-gui/src/api/index.js b/myconext-gui/src/api/index.js index c66e6de2..a70e89ec 100644 --- a/myconext-gui/src/api/index.js +++ b/myconext-gui/src/api/index.js @@ -236,8 +236,12 @@ export function sendDeactivationPhoneCode() { } // Create from Institution -export function startCreateFromInstitutionFlow(forceAuth = false) { - return fetchJson("/myconext/api/sp/create-from-institution?forceAuth=" + forceAuth); +export function startCreateFromInstitutionFlow(forceAuth = false, returnTo = null) { + const params = new URLSearchParams({forceAuth: `${forceAuth}`}); + if (returnTo) { + params.set("return_to", returnTo); + } + return fetchJson(`/myconext/api/sp/create-from-institution?${params.toString()}`); } export function createInstitutionEduID(email, hash, newUser) { @@ -294,5 +298,3 @@ export function reValidatePhoneCode(phoneVerification) { export function reportError(error) { return postPutJson("/myconext/api/sp/error", error, "post"); } - - diff --git a/myconext-gui/src/routes/AwaitLinkFromInstitutionMail.svelte b/myconext-gui/src/routes/AwaitLinkFromInstitutionMail.svelte index b47bd45a..07840067 100644 --- a/myconext-gui/src/routes/AwaitLinkFromInstitutionMail.svelte +++ b/myconext-gui/src/routes/AwaitLinkFromInstitutionMail.svelte @@ -35,7 +35,11 @@ const verifyCode = code => { createFromInstitutionVerify(hash, code) - .then(() => { + .then(res => { + if (res.location) { + window.location.href = res.location; + return; + } navigate("/security?new=true") }).catch(e => { if (e.status === 403 || e.status === 400) { @@ -123,4 +127,4 @@ - \ No newline at end of file + diff --git a/myconext-gui/src/routes/CreateFromInstitution.svelte b/myconext-gui/src/routes/CreateFromInstitution.svelte index f290a801..90d464ba 100644 --- a/myconext-gui/src/routes/CreateFromInstitution.svelte +++ b/myconext-gui/src/routes/CreateFromInstitution.svelte @@ -16,7 +16,8 @@ const startFlow = () => { busy = true; - startCreateFromInstitutionFlow().then(res => { + const returnTo = new URLSearchParams(window.location.search).get("return_to"); + startCreateFromInstitutionFlow(false, returnTo).then(res => { window.location.href = res.url; }) diff --git a/myconext-server/src/main/java/myconext/api/AccountLinkerController.java b/myconext-server/src/main/java/myconext/api/AccountLinkerController.java index c86c6540..07a7c6c9 100644 --- a/myconext-server/src/main/java/myconext/api/AccountLinkerController.java +++ b/myconext-server/src/main/java/myconext/api/AccountLinkerController.java @@ -13,6 +13,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; +import myconext.config.CreateFromInstitutionProperties; import myconext.cron.DisposableEmailProviders; import myconext.exceptions.DuplicateUserEmailException; import myconext.exceptions.ForbiddenException; @@ -29,6 +30,7 @@ import myconext.security.EmailGuessingPrevention; import myconext.security.UserAuthentication; import myconext.security.VerificationCodeGenerator; +import myconext.util.CreateFromInstitutionReturnUrlSupport; import myconext.verify.AttributeMapper; import myconext.verify.VerifyState; import org.apache.commons.logging.Log; @@ -48,6 +50,7 @@ import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.*; import org.springframework.web.client.RestTemplate; +import org.springframework.web.server.ResponseStatusException; import org.springframework.web.util.UriComponents; import org.springframework.web.util.UriComponentsBuilder; @@ -100,6 +103,7 @@ public class AccountLinkerController implements UserAuthentication { private final String mijnEduIDEntityId; private final String schacHomeOrganization; private final boolean createEduIDInstitutionEnabled; + private final List createFromInstitutionAllowedReturnDomains; private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4); private final RestTemplate restTemplate = new RestTemplate(); @@ -141,6 +145,7 @@ public AccountLinkerController( @Value("${linked_accounts.removal-duration-days-validated}") long removalValidatedDurationDays, @Value("${account_linking.myconext_sp_entity_id}") String myConextSpEntityId, @Value("${feature.create_eduid_institution_enabled}") boolean createEduIDInstitutionEnabled, + CreateFromInstitutionProperties createFromInstitutionProperties, @Value("${email_guessing_sleep_millis}") int emailGuessingSleepMillis, @Value("${verify.client_id}") String verifyClientId, @Value("${verify.secret}") String verifySecret, @@ -174,6 +179,7 @@ public AccountLinkerController( this.removalValidatedDurationDays = removalValidatedDurationDays; this.myConextSpEntityId = myConextSpEntityId; this.createEduIDInstitutionEnabled = createEduIDInstitutionEnabled; + this.createFromInstitutionAllowedReturnDomains = createFromInstitutionProperties.getReturnUrlAllowedDomains(); this.emailGuessingPreventor = new EmailGuessingPrevention(emailGuessingSleepMillis); this.verifyClientId = verifyClientId; this.verifySecret = verifySecret; @@ -213,9 +219,15 @@ public ResponseEntity startIdPLinkAccountFlow(@PathVariable("id") String id, @GetMapping("/sp/create-from-institution") @Hidden public ResponseEntity> createFromInstitution(HttpServletRequest request, - @RequestParam(value = "forceAuth", required = false, defaultValue = "false") boolean forceAuth) throws UnsupportedEncodingException { + @RequestParam(value = "forceAuth", required = false, defaultValue = "false") boolean forceAuth, + @RequestParam(value = "return_to", required = false) String returnTo) throws UnsupportedEncodingException { LOG.info("Start create from institution"); - String state = request.getSession(true).getId(); + HttpSession session = request.getSession(true); + String state = session.getId(); + String validatedReturnUrl = validateReturnUrl(returnTo); + session.setAttribute("create_from_institution_return_url", validatedReturnUrl); + LOG.info(String.format("Create-from-institution start state=%s, raw return_to=%s, validated return_to=%s", + state, returnTo, validatedReturnUrl)); UriComponents uriComponents = doStartLinkAccountFlow(state, spCreateFromInstitutionRedirectUri, forceAuth, myConextSpEntityId); return ResponseEntity.ok(Collections.singletonMap("url", uriComponents.toUriString())); } @@ -251,6 +263,9 @@ public ResponseEntity spCreateFromInstitutionRedirect(HttpServletRequest request return eppnAlreadyLinkedOptional.get(); } RequestInstitutionEduID requestInstitutionEduID = new RequestInstitutionEduID(hash(), userInfo); + requestInstitutionEduID.setReturnUrl((String) session.getAttribute("create_from_institution_return_url")); + LOG.info(String.format("Create-from-institution oidc redirect state=%s, request hash=%s, stored return_to=%s", + storedState, requestInstitutionEduID.getHash(), requestInstitutionEduID.getReturnUrl())); requestInstitutionEduIDRepository.save(requestInstitutionEduID); //Now the user needs to enter email and validate this email to finish up the registration String returnUri = this.spRedirectUrl + "/create-from-institution/link/" + requestInstitutionEduID.getHash(); @@ -367,6 +382,9 @@ public ResponseEntity createFromInstitutionFinish(HttpServletRequest request, manage); } user.setCreateFromInstitutionKey(hash()); + user.setCreateFromInstitutionReturnUrl(requestInstitutionEduID.getReturnUrl()); + LOG.info(String.format("Create-from-institution verify email=%s, newUser=%s, userId=%s, createFromInstitutionKey=%s, stored return_to=%s", + email, user.isNewUser(), user.getId(), user.getCreateFromInstitutionKey(), user.getCreateFromInstitutionReturnUrl())); ResponseEntity responseEntity = saveOrUpdateLinkedAccountToUser( user, this.idpBaseRedirectUrl + "/create-from-institution-login?key=" + user.getCreateFromInstitutionKey(), @@ -388,7 +406,7 @@ public ResponseEntity createFromInstitutionFinish(HttpServletRequest request, HttpSession session = request.getSession(); session.setAttribute(SPRING_SECURITY_CONTEXT_KEY, context); - return responseEntity; + return responseWithJsonLocationForSpa(request, responseEntity); } @GetMapping("/sp/oidc/link") @@ -976,6 +994,31 @@ private Optional> checkEppnAlreadyLinked(String eppnAlrea return Optional.empty(); } + private String validateReturnUrl(String returnTo) { + if (!StringUtils.hasText(returnTo)) { + return null; + } + return CreateFromInstitutionReturnUrlSupport + .validateAndNormalize(returnTo, this.createFromInstitutionAllowedReturnDomains) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, "Invalid return_to parameter")); + } + + private ResponseEntity responseWithJsonLocationForSpa(HttpServletRequest request, ResponseEntity responseEntity) { + if (!acceptsJson(request) || !responseEntity.getStatusCode().is3xxRedirection()) { + return responseEntity; + } + URI location = responseEntity.getHeaders().getLocation(); + if (location == null) { + return responseEntity; + } + return ResponseEntity.status(HttpStatus.CREATED).body(Collections.singletonMap("location", location.toString())); + } + + private boolean acceptsJson(HttpServletRequest request) { + String accept = request.getHeader(HttpHeaders.ACCEPT); + return StringUtils.hasText(accept) && accept.contains(MediaType.APPLICATION_JSON_VALUE); + } + private Map requestUserInfo(String code, String oidcRedirectUri) { HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); diff --git a/myconext-server/src/main/java/myconext/api/LoginController.java b/myconext-server/src/main/java/myconext/api/LoginController.java index 05708243..d87bbcef 100644 --- a/myconext-server/src/main/java/myconext/api/LoginController.java +++ b/myconext-server/src/main/java/myconext/api/LoginController.java @@ -4,11 +4,13 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import myconext.config.CreateFromInstitutionProperties; import myconext.exceptions.UserNotFoundException; import myconext.model.SamlAuthenticationRequest; import myconext.model.User; import myconext.repository.AuthenticationRequestRepository; import myconext.repository.UserRepository; +import myconext.util.CreateFromInstitutionReturnUrlSupport; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; import org.springframework.beans.factory.annotation.Value; @@ -28,6 +30,7 @@ import java.net.URI; import java.util.Arrays; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Optional; @@ -45,6 +48,7 @@ public class LoginController { private final UserRepository userRepository; private final AuthenticationRequestRepository authenticationRequestRepository; private final SecurityContextRepository securityContextRepository; + private final List createFromInstitutionAllowedReturnDomains; public LoginController(UserRepository userRepository, AuthenticationRequestRepository authenticationRequestRepository, @@ -76,7 +80,8 @@ public LoginController(UserRepository userRepository, @Value("${feature.service_desk_active}") boolean serviceDeskActive, @Value("${feature.use_remote_creation_for_affiliation}") boolean useRemoteCreationForAffiliation, @Value("${feature.enable_account_linking}") boolean enableAccountLinking, - @Value("${feature.use_app}") boolean useApp + @Value("${feature.use_app}") boolean useApp, + CreateFromInstitutionProperties createFromInstitutionProperties ) { this.config.put("basePath", basePath); this.config.put("loginUrl", basePath + "/login"); @@ -111,6 +116,7 @@ public LoginController(UserRepository userRepository, this.userRepository = userRepository; this.authenticationRequestRepository = authenticationRequestRepository; this.securityContextRepository = securityContextRepository; + this.createFromInstitutionAllowedReturnDomains = createFromInstitutionProperties.getReturnUrlAllowedDomains(); } @GetMapping("/config") @@ -242,11 +248,16 @@ private void doCreateUserFromInstitutionKey(HttpServletRequest request, HttpServletResponse response, String key, String redirectUrl) throws IOException { + LOG.info(String.format("Create-from-institution-login called with key=%s, fallback redirectUrl=%s", key, redirectUrl)); User user = userRepository.findUserByCreateFromInstitutionKey(key) .orElseThrow(() -> new UserNotFoundException("User by createFromInstitutionKey not found")); boolean newUser = user.isNewUser(); + String createFromInstitutionReturnUrl = user.getCreateFromInstitutionReturnUrl(); + LOG.info(String.format("Create-from-institution-login user=%s, newUser=%s, stored return_to=%s, allowed domains=%s", + user.getEmail(), newUser, createFromInstitutionReturnUrl, createFromInstitutionAllowedReturnDomains)); user.setNewUser(false); user.setCreateFromInstitutionKey(null); + user.setCreateFromInstitutionReturnUrl(null); userRepository.save(user); Cookie usernameCookie = new Cookie("username", user.getEmail()); @@ -260,7 +271,10 @@ private void doCreateUserFromInstitutionKey(HttpServletRequest request, SecurityContextHolder.getContext().setAuthentication(authentication); this.securityContextRepository.saveContext(SecurityContextHolder.getContext(), request, response); - String redirectLocation = redirectUrl + String.format("?new=%s", newUser ? "true" : "false"); + String baseRedirectUrl = CreateFromInstitutionReturnUrlSupport + .validateAndNormalize(createFromInstitutionReturnUrl, createFromInstitutionAllowedReturnDomains) + .orElse(redirectUrl); + String redirectLocation = baseRedirectUrl + String.format(baseRedirectUrl.contains("?") ? "&new=%s" : "?new=%s", newUser ? "true" : "false"); LOG.info(String.format("User %s create from institutionKey. Redirecting to %s", user.getEmail(), redirectLocation)); diff --git a/myconext-server/src/main/java/myconext/config/CreateFromInstitutionProperties.java b/myconext-server/src/main/java/myconext/config/CreateFromInstitutionProperties.java new file mode 100644 index 00000000..e798e13e --- /dev/null +++ b/myconext-server/src/main/java/myconext/config/CreateFromInstitutionProperties.java @@ -0,0 +1,16 @@ +package myconext.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +@Getter +@Setter +@ConfigurationProperties(prefix = "create-from-institution") +public class CreateFromInstitutionProperties { + + private List returnUrlAllowedDomains = new ArrayList<>(); +} diff --git a/myconext-server/src/main/java/myconext/model/RequestInstitutionEduID.java b/myconext-server/src/main/java/myconext/model/RequestInstitutionEduID.java index 5e062ff8..9d5d372a 100644 --- a/myconext-server/src/main/java/myconext/model/RequestInstitutionEduID.java +++ b/myconext-server/src/main/java/myconext/model/RequestInstitutionEduID.java @@ -28,6 +28,9 @@ public class RequestInstitutionEduID implements Serializable { private Map userInfo; + @Setter + private String returnUrl; + @Setter private CreateInstitutionEduID createInstitutionEduID; diff --git a/myconext-server/src/main/java/myconext/model/User.java b/myconext-server/src/main/java/myconext/model/User.java index 9667e38f..cd89c61c 100644 --- a/myconext-server/src/main/java/myconext/model/User.java +++ b/myconext-server/src/main/java/myconext/model/User.java @@ -75,6 +75,8 @@ public class User implements Serializable, UserDetails { @Setter @Indexed private String createFromInstitutionKey; + @Setter + private String createFromInstitutionReturnUrl; //Attributes and surfSecureId can't be final because of Jackson serialization (despite what your IDE tells tou) private Map> attributes = new HashMap<>(); private Map surfSecureId = new HashMap<>(); diff --git a/myconext-server/src/main/java/myconext/security/SecurityConfiguration.java b/myconext-server/src/main/java/myconext/security/SecurityConfiguration.java index 878fa71e..1ddebdfa 100644 --- a/myconext-server/src/main/java/myconext/security/SecurityConfiguration.java +++ b/myconext-server/src/main/java/myconext/security/SecurityConfiguration.java @@ -1,6 +1,7 @@ package myconext.security; import lombok.SneakyThrows; +import myconext.config.CreateFromInstitutionProperties; import myconext.crypto.KeyGenerator; import myconext.geo.GeoLocation; import myconext.mail.MailBox; @@ -52,7 +53,7 @@ import static org.springframework.security.config.Customizer.withDefaults; @EnableWebSecurity -@EnableConfigurationProperties +@EnableConfigurationProperties(CreateFromInstitutionProperties.class) @EnableMethodSecurity @Configuration public class SecurityConfiguration { diff --git a/myconext-server/src/main/java/myconext/util/CreateFromInstitutionReturnUrlSupport.java b/myconext-server/src/main/java/myconext/util/CreateFromInstitutionReturnUrlSupport.java new file mode 100644 index 00000000..0360dd39 --- /dev/null +++ b/myconext-server/src/main/java/myconext/util/CreateFromInstitutionReturnUrlSupport.java @@ -0,0 +1,38 @@ +package myconext.util; + +import org.springframework.util.StringUtils; + +import java.net.URI; +import java.util.List; +import java.util.Locale; +import java.util.Optional; + +public final class CreateFromInstitutionReturnUrlSupport { + + private CreateFromInstitutionReturnUrlSupport() { + } + + public static Optional validateAndNormalize(String returnUrl, List allowedDomains) { + if (!StringUtils.hasText(returnUrl)) { + return Optional.empty(); + } + if (allowedDomains == null || allowedDomains.isEmpty()) { + return Optional.empty(); + } + try { + URI uri = URI.create(returnUrl.trim()); + String scheme = Optional.ofNullable(uri.getScheme()).orElse("").toLowerCase(Locale.ROOT); + String host = Optional.ofNullable(uri.getHost()).orElse("").toLowerCase(Locale.ROOT); + if (!("http".equals(scheme) || "https".equals(scheme)) || !StringUtils.hasText(host)) { + return Optional.empty(); + } + boolean allowed = allowedDomains.stream() + .filter(StringUtils::hasText) + .map(domain -> domain.trim().toLowerCase(Locale.ROOT)) + .anyMatch(domain -> host.equals(domain) || host.endsWith("." + domain)); + return allowed ? Optional.of(uri.toString()) : Optional.empty(); + } catch (IllegalArgumentException e) { + return Optional.empty(); + } + } +} diff --git a/myconext-server/src/main/resources/application.yml b/myconext-server/src/main/resources/application.yml index c77ab0a3..a2a2a17f 100644 --- a/myconext-server/src/main/resources/application.yml +++ b/myconext-server/src/main/resources/application.yml @@ -98,6 +98,10 @@ mijn_eduid_service_name: "Mijn eduID" mobile_app_redirect: eduid:///client/mobile mobile_app_rp_entity_id: mobile_app_rp_entity_id +create-from-institution: + return-url-allowed-domains: + - app.localhost + # The host headers to identify the service the user is logged in host_headers: service_desk: servicedesk.test2.eduid.nl diff --git a/myconext-server/src/test/java/myconext/api/AccountLinkerControllerTest.java b/myconext-server/src/test/java/myconext/api/AccountLinkerControllerTest.java index 5d1152ee..debd9780 100644 --- a/myconext-server/src/test/java/myconext/api/AccountLinkerControllerTest.java +++ b/myconext-server/src/test/java/myconext/api/AccountLinkerControllerTest.java @@ -11,10 +11,13 @@ import myconext.model.*; import org.junit.Rule; import org.junit.Test; +import org.junit.Before; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpMethod; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; +import org.springframework.test.util.ReflectionTestUtils; import org.springframework.util.MultiValueMap; import org.springframework.web.util.UriComponentsBuilder; @@ -34,9 +37,21 @@ public class AccountLinkerControllerTest extends AbstractIntegrationTest { + @Autowired + private AccountLinkerController accountLinkerController; + + @Autowired + private LoginController loginController; + @Rule public WireMockRule wireMockRule = new WireMockRule(8098); + @Before + public void resetCreateFromInstitutionReturnUrlAllowList() { + ReflectionTestUtils.setField(accountLinkerController, "createFromInstitutionAllowedReturnDomains", List.of()); + ReflectionTestUtils.setField(loginController, "createFromInstitutionAllowedReturnDomains", List.of()); + } + @Test public void linkAccountRedirect() throws IOException { Response response = samlAuthnRequestResponseWithLoa(null, null, ""); @@ -351,6 +366,32 @@ public void createFromInstitution() { "redirect_uri=http://localhost:8081/myconext/api/sp/create-from-institution/oidc-redirect&state=")); } + @Test + public void createFromInstitutionRejectsReturnToWhenFeatureDisabled() { + given().redirects().follow(false) + .when() + .contentType(ContentType.JSON) + .queryParam("return_to", "https://sitte.myuniversity.nl/landing") + .get("/myconext/api/sp/create-from-institution") + .then() + .statusCode(400); + } + + @Test + public void createFromInstitutionAcceptsAllowedReturnToSubdomain() { + ReflectionTestUtils.setField(accountLinkerController, "createFromInstitutionAllowedReturnDomains", List.of("myuniversity.nl")); + + Map results = given().redirects().follow(false) + .when() + .contentType(ContentType.JSON) + .queryParam("return_to", "https://sitte.myuniversity.nl/landing?true") + .get("/myconext/api/sp/create-from-institution") + .as(new TypeRef<>() { + }); + + assertTrue(results.get("url").contains("redirect_uri=http://localhost:8081/myconext/api/sp/create-from-institution/oidc-redirect")); + } + @Test public void spCreateFromInstitutionRedirectNoSession() { given().redirects().follow(false) @@ -639,6 +680,94 @@ public void spCreateFromInstitutionLinkFromInstitutionFinishExistingUser() throw assertEquals("http://localhost:3000/create-from-institution-login?key=" + user.getCreateFromInstitutionKey(), location); } + @SneakyThrows + @Test + public void spCreateFromInstitutionLinkFromInstitutionStoresAllowedReturnUrl() { + ReflectionTestUtils.setField(accountLinkerController, "createFromInstitutionAllowedReturnDomains", List.of("myuniversity.nl")); + + Map userInfo = userInfoMap("new-user@qwerty.com"); + CookieFilter cookieFilter = new CookieFilter(); + Map results = given() + .filter(cookieFilter) + .when() + .contentType(ContentType.JSON) + .queryParam("return_to", "https://sitte.myuniversity.nl/landing") + .get("/myconext/api/sp/create-from-institution") + .as(new TypeRef<>() { + }); + String state = UriComponentsBuilder.fromUriString(results.get("url")).build().getQueryParams().getFirst("state"); + + stubForTokenUserInfo(userInfo); + + String location = given().redirects().follow(false) + .filter(cookieFilter) + .when() + .queryParam("code", "123456") + .queryParam("state", state) + .contentType(ContentType.JSON) + .get("/myconext/api/sp/create-from-institution/oidc-redirect") + .getHeader("Location"); + String hash = location.substring(location.indexOf("link/") + "link/".length()); + + CreateInstitutionEduID createInstitutionEduID = new CreateInstitutionEduID(hash, "new@example.com", true); + given() + .when() + .contentType(ContentType.JSON) + .body(createInstitutionEduID) + .post("/myconext/api/sp/create-from-institution/email") + .then() + .statusCode(200); + + RequestInstitutionEduID requestInstitutionEduID = requestInstitutionEduIDRepository.findByHash(hash).get(); + VerifyOneTimeLoginCode verifyOneTimeLoginCode = new VerifyOneTimeLoginCode(requestInstitutionEduID.getOneTimeLoginCode().getCode(), null, requestInstitutionEduID.getHash()); + Thread.sleep(1000); + + given().redirects().follow(false) + .when() + .contentType(ContentType.JSON) + .body(verifyOneTimeLoginCode) + .put("/myconext/api/sp/create-from-institution/verify") + .then() + .statusCode(302); + + User user = userRepository.findUserByEmailAndRateLimitedFalse("new@example.com").get(); + assertEquals("https://sitte.myuniversity.nl/landing", user.getCreateFromInstitutionReturnUrl()); + } + + @SneakyThrows + @Test + public void spCreateFromInstitutionVerifyReturnsJsonLocationForSpaClient() { + Map userInfo = userInfoMap("new-user@qwerty.com"); + String hash = getHashFromCreateInstitutionFlow(userInfo); + CreateInstitutionEduID createInstitutionEduID = new CreateInstitutionEduID(hash, "new@example.com", true); + given() + .when() + .contentType(ContentType.JSON) + .body(createInstitutionEduID) + .post("/myconext/api/sp/create-from-institution/email") + .then() + .statusCode(200); + + RequestInstitutionEduID requestInstitutionEduID = requestInstitutionEduIDRepository.findByHash(hash).get(); + VerifyOneTimeLoginCode verifyOneTimeLoginCode = new VerifyOneTimeLoginCode(requestInstitutionEduID.getOneTimeLoginCode().getCode(), null, requestInstitutionEduID.getHash()); + Thread.sleep(1000); + + Map response = given() + .when() + .accept(ContentType.JSON) + .contentType(ContentType.JSON) + .body(verifyOneTimeLoginCode) + .put("/myconext/api/sp/create-from-institution/verify") + .then() + .statusCode(201) + .extract() + .as(new TypeRef<>() { + }); + + User user = userRepository.findUserByEmailAndRateLimitedFalse("new@example.com").get(); + assertEquals("http://localhost:3000/create-from-institution-login?key=" + user.getCreateFromInstitutionKey(), response.get("location")); + } + @Test public void spCreateFromInstitutionLinkFromInstitutionFinishUserNotFound() throws JsonProcessingException, InterruptedException { Map userInfo = userInfoMap("new-user@qwerty.com"); @@ -888,4 +1017,4 @@ private String stateParameterSp() { private String stateParameterIdP(String authenticationRequestId) { return String.format("id=%s&user_uid=%s", authenticationRequestId, stateParameterSp()); } -} \ No newline at end of file +} diff --git a/myconext-server/src/test/java/myconext/api/LoginControllerTest.java b/myconext-server/src/test/java/myconext/api/LoginControllerTest.java index 9077abd6..a1b18f5c 100644 --- a/myconext-server/src/test/java/myconext/api/LoginControllerTest.java +++ b/myconext-server/src/test/java/myconext/api/LoginControllerTest.java @@ -6,10 +6,14 @@ import myconext.model.ClientAuthenticationRequest; import myconext.model.User; import org.junit.Test; +import org.junit.Before; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpMethod; import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.util.ReflectionTestUtils; import java.io.IOException; +import java.util.List; import java.util.UUID; import static io.restassured.RestAssured.given; @@ -21,6 +25,14 @@ @ActiveProfiles(value = "dev", inheritProfiles = false) public class LoginControllerTest extends AbstractIntegrationTest { + @Autowired + private LoginController loginController; + + @Before + public void resetCreateFromInstitutionReturnUrlAllowList() { + ReflectionTestUtils.setField(loginController, "createFromInstitutionAllowedReturnDomains", List.of()); + } + @Test public void config() { given() @@ -141,6 +153,31 @@ public void testCreateFromInstitutionLoginNewUser() { "http://localhost:3001/security?new=true"); } + @Test + public void testCreateFromInstitutionLoginRedirectsToAllowedReturnUrl() { + ReflectionTestUtils.setField(loginController, "createFromInstitutionAllowedReturnDomains", List.of("myuniversity.nl")); + + User user = userRepository.findUserByEmailAndRateLimitedFalse("jdoe@example.com").get(); + String createFromInstitutionKey = UUID.randomUUID().toString(); + user.setCreateFromInstitutionKey(createFromInstitutionKey); + user.setCreateFromInstitutionReturnUrl("https://sitte.myuniversity.nl/landing"); + user.setNewUser(false); + userRepository.save(user); + + given() + .redirects().follow(false) + .when() + .queryParam("key", createFromInstitutionKey) + .get("/create-from-institution-login") + .then() + .statusCode(302) + .header("Location", "https://sitte.myuniversity.nl/landing?new=false"); + + user = userRepository.findUserByEmailAndRateLimitedFalse("jdoe@example.com").get(); + assertNull(user.getCreateFromInstitutionKey()); + assertNull(user.getCreateFromInstitutionReturnUrl()); + } + @Test public void redirectToSPServiceDeskHook() throws IOException { String authenticationRequestId = samlAuthnRequest(); @@ -172,4 +209,4 @@ public void redirectToSPServiceDeskHookNoAuthenticationRequest() throws IOExcept assertEquals("http://localhost:3000/expired", location); } -} \ No newline at end of file +}