Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion account-gui/vite.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,16 @@ 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,
secure: false
}
}
},
});
});
10 changes: 6 additions & 4 deletions myconext-gui/src/api/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -294,5 +298,3 @@ export function reValidatePhoneCode(phoneVerification) {
export function reportError(error) {
return postPutJson("/myconext/api/sp/error", error, "post");
}


8 changes: 6 additions & 2 deletions myconext-gui/src/routes/AwaitLinkFromInstitutionMail.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -123,4 +127,4 @@
</div>

</Modal>
</div>
</div>
3 changes: 2 additions & 1 deletion myconext-gui/src/routes/CreateFromInstitution.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -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;
})

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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;

Expand Down Expand Up @@ -100,6 +103,7 @@ public class AccountLinkerController implements UserAuthentication {
private final String mijnEduIDEntityId;
private final String schacHomeOrganization;
private final boolean createEduIDInstitutionEnabled;
private final List<String> createFromInstitutionAllowedReturnDomains;

private final BCryptPasswordEncoder passwordEncoder = new BCryptPasswordEncoder(4);
private final RestTemplate restTemplate = new RestTemplate();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -213,9 +219,15 @@ public ResponseEntity startIdPLinkAccountFlow(@PathVariable("id") String id,
@GetMapping("/sp/create-from-institution")
@Hidden
public ResponseEntity<Map<String, String>> 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()));
}
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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<Object> responseEntity = saveOrUpdateLinkedAccountToUser(
user,
this.idpBaseRedirectUrl + "/create-from-institution-login?key=" + user.getCreateFromInstitutionKey(),
Expand All @@ -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")
Expand Down Expand Up @@ -976,6 +994,31 @@ private Optional<ResponseEntity<Object>> 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<String, Object> requestUserInfo(String code, String oidcRedirectUri) {
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED);
Expand Down
18 changes: 16 additions & 2 deletions myconext-server/src/main/java/myconext/api/LoginController.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -45,6 +48,7 @@ public class LoginController {
private final UserRepository userRepository;
private final AuthenticationRequestRepository authenticationRequestRepository;
private final SecurityContextRepository securityContextRepository;
private final List<String> createFromInstitutionAllowedReturnDomains;

public LoginController(UserRepository userRepository,
AuthenticationRequestRepository authenticationRequestRepository,
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -111,6 +116,7 @@ public LoginController(UserRepository userRepository,
this.userRepository = userRepository;
this.authenticationRequestRepository = authenticationRequestRepository;
this.securityContextRepository = securityContextRepository;
this.createFromInstitutionAllowedReturnDomains = createFromInstitutionProperties.getReturnUrlAllowedDomains();
}

@GetMapping("/config")
Expand Down Expand Up @@ -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());
Expand All @@ -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));

Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> returnUrlAllowedDomains = new ArrayList<>();
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,9 @@ public class RequestInstitutionEduID implements Serializable {

private Map<String, Object> userInfo;

@Setter
private String returnUrl;

@Setter
private CreateInstitutionEduID createInstitutionEduID;

Expand Down
2 changes: 2 additions & 0 deletions myconext-server/src/main/java/myconext/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, List<String>> attributes = new HashMap<>();
private Map<String, Object> surfSecureId = new HashMap<>();
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -52,7 +53,7 @@
import static org.springframework.security.config.Customizer.withDefaults;

@EnableWebSecurity
@EnableConfigurationProperties
@EnableConfigurationProperties(CreateFromInstitutionProperties.class)
@EnableMethodSecurity
@Configuration
public class SecurityConfiguration {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String> validateAndNormalize(String returnUrl, List<String> 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();
}
}
}
4 changes: 4 additions & 0 deletions myconext-server/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading