From 98d5406feca6fb4f83202088d06ad2c872e9f9fc Mon Sep 17 00:00:00 2001 From: Okke Harsta Date: Wed, 15 Jan 2025 14:02:30 +0100 Subject: [PATCH] WIP for ServiceDeskController #587 --- .../myconext/api/ServiceDeskController.java | 65 ++++++++-- .../java/myconext/api/UserController.java | 10 +- .../main/java/myconext/model/ControlCode.java | 4 +- .../java/myconext/verify/AttributeMapper.java | 63 ++++++++-- .../api/ServiceDeskControllerTest.java | 114 +++++++++++++++++- ...ServiceDeskControllerUnauthorizedTest.java | 16 +-- .../java/myconext/api/UserControllerTest.java | 21 ++++ myconext-server/src/test/resources/users.json | 14 ++- 8 files changed, 268 insertions(+), 39 deletions(-) diff --git a/myconext-server/src/main/java/myconext/api/ServiceDeskController.java b/myconext-server/src/main/java/myconext/api/ServiceDeskController.java index 425f08fc..ab0a078c 100644 --- a/myconext-server/src/main/java/myconext/api/ServiceDeskController.java +++ b/myconext-server/src/main/java/myconext/api/ServiceDeskController.java @@ -1,18 +1,23 @@ package myconext.api; import lombok.Getter; +import myconext.exceptions.ForbiddenException; +import myconext.exceptions.UserNotFoundException; import myconext.model.ControlCode; +import myconext.model.ExternalLinkedAccount; import myconext.model.User; import myconext.repository.UserRepository; import myconext.security.UserAuthentication; +import myconext.verify.AttributeMapper; import org.apache.commons.logging.Log; import org.apache.commons.logging.LogFactory; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.core.Authentication; -import org.springframework.web.bind.annotation.PutMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; + +import java.util.Date; + @RestController @RequestMapping(value = {"/myconext/api/servicedesk"}) @@ -22,16 +27,58 @@ public class ServiceDeskController implements UserAuthentication { @Getter private final UserRepository userRepository; + private final AttributeMapper attributeMapper; - public ServiceDeskController(UserRepository userRepository) { + public ServiceDeskController(UserRepository userRepository, + AttributeMapper attributeMapper) { this.userRepository = userRepository; + this.attributeMapper = attributeMapper; } - @PutMapping("") - public ResponseEntity convertUserControlCode(Authentication authentication, - @RequestBody ControlCode controlCode) { - User serviceDeskMember = userFromAuthentication(authentication); + @GetMapping("/user/{code}") + public ResponseEntity getUserControlCode(@PathVariable("code") String code) { + LOG.debug("Fetching user with controlCode: " + code); + + User user = userRepository.findByControlCode_Code(code) + .orElseThrow(() -> new UserNotFoundException(String.format("No user found with controlCode %s", code))); + ControlCode controlCode = user.getControlCode(); + controlCode.setUserUid(user.getUid()); + return ResponseEntity.ok(controlCode); } + @GetMapping("/validate") + public ResponseEntity validateDate(@RequestParam("dayOfBirth") String dayOfBirth) { + LOG.debug("Validate date: " + dayOfBirth); + + Date date = AttributeMapper.parseDate(dayOfBirth); + return ResponseEntity.ok(date != null); + } + + @PutMapping("/approve") + public ResponseEntity convertUserControlCode(Authentication authentication, + @RequestBody ControlCode controlCode) { + String code = controlCode.getCode(); + User user = userRepository.findByControlCode_Code(code) + .orElseThrow(() -> new UserNotFoundException(String.format("No user found with controlCode %s", code))); + + if (!user.getExternalLinkedAccounts().isEmpty() || !user.getLinkedAccounts().isEmpty()) { + throw new ForbiddenException("User has already linked-accounts: " + user.getEmail()); + } + if (!user.getUid().equals(controlCode.getUserUid())) { + throw new ForbiddenException("User UID's do not match"); + } + + User serviceDeskMember = userFromAuthentication(authentication); + + LOG.info(String.format("Adding external linked account for service desk for user %s by user %s", + user.getEmail(), serviceDeskMember.getEmail())); + + ExternalLinkedAccount externalLinkedAccount = attributeMapper.createFromControlCode(controlCode); + user.getExternalLinkedAccounts().add(externalLinkedAccount); + userRepository.save(user); + + return ResponseEntity.status(HttpStatus.CREATED).build(); + } + } diff --git a/myconext-server/src/main/java/myconext/api/UserController.java b/myconext-server/src/main/java/myconext/api/UserController.java index 9ce74b44..3951c08d 100644 --- a/myconext-server/src/main/java/myconext/api/UserController.java +++ b/myconext-server/src/main/java/myconext/api/UserController.java @@ -18,6 +18,8 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpSession; import jakarta.validation.Valid; import lombok.SneakyThrows; import myconext.cron.DisposableEmailProviders; @@ -48,9 +50,6 @@ import org.springframework.web.bind.annotation.*; import tiqr.org.model.Registration; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpSession; - import java.io.IOException; import java.io.UnsupportedEncodingException; import java.lang.reflect.Field; @@ -982,12 +981,15 @@ public ResponseEntity deleteUser(Authentication authentication, @PostMapping("sp/control-code") public ResponseEntity createUserControlCode(Authentication authentication, @RequestBody ControlCode controlCode) { + User user = userFromAuthentication(authentication); + if (!user.getExternalLinkedAccounts().isEmpty() || !user.getLinkedAccounts().isEmpty()) { + throw new ForbiddenException("User has already linked-accounts: " + user.getEmail()); + } String code = VerificationCodeGenerator.generateControlCode(); //Very small chance, but better be safe than sorry (User#ControlCode#code is indexed) while (userRepository.findByControlCode_Code(code).isPresent()) { code = VerificationCodeGenerator.generateControlCode(); } - User user = userFromAuthentication(authentication); controlCode.setCode(code); controlCode.setCreatedAt(System.currentTimeMillis()); diff --git a/myconext-server/src/main/java/myconext/model/ControlCode.java b/myconext-server/src/main/java/myconext/model/ControlCode.java index 1f44415d..9b18ece0 100644 --- a/myconext-server/src/main/java/myconext/model/ControlCode.java +++ b/myconext-server/src/main/java/myconext/model/ControlCode.java @@ -5,7 +5,6 @@ import org.springframework.data.mongodb.core.index.Indexed; import java.io.Serializable; -import java.time.Instant; @Getter @NoArgsConstructor @@ -24,6 +23,9 @@ public class ControlCode implements Serializable { @Setter private long createdAt; + @Setter + private String userUid; + public ControlCode(String firstName, String lastName, String dayOfBirth) { this.firstName = firstName; this.lastName = lastName; diff --git a/myconext-server/src/main/java/myconext/verify/AttributeMapper.java b/myconext-server/src/main/java/myconext/verify/AttributeMapper.java index 0b48638a..b74ce25b 100644 --- a/myconext-server/src/main/java/myconext/verify/AttributeMapper.java +++ b/myconext-server/src/main/java/myconext/verify/AttributeMapper.java @@ -3,10 +3,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import lombok.SneakyThrows; import myconext.manage.Manage; -import myconext.model.ExternalLinkedAccount; -import myconext.model.IdpScoping; -import myconext.model.Verification; -import myconext.model.VerifyIssuer; +import myconext.model.*; import myconext.remotecreation.NewExternalEduID; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.stereotype.Service; @@ -22,11 +19,7 @@ import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatterBuilder; import java.time.temporal.ChronoUnit; -import java.time.temporal.TemporalQueries; import java.util.*; -import java.util.concurrent.atomic.AtomicReference; -import java.util.regex.Pattern; -import java.util.stream.Collectors; import java.util.zip.GZIPInputStream; import java.util.zip.GZIPOutputStream; @@ -213,6 +206,57 @@ public ExternalLinkedAccount createExternalLinkedAccount(NewExternalEduID eduID, return externalLinkedAccount; } + public ExternalLinkedAccount createFromControlCode(ControlCode controlCode) { + String uuid = UUID.randomUUID().toString(); + ExternalLinkedAccount externalLinkedAccount = + new ExternalLinkedAccount( + //String subjectId + uuid, + //IdpScoping idpScoping + IdpScoping.serviceDesk, + //VerifyIssuer issuer + new VerifyIssuer(IdpScoping.serviceDesk.name(), IdpScoping.serviceDesk.name(), null), + //Verification + Verification.Geverifieerd, + //String serviceUUID + uuid, + //String serviceID + uuid, + //String subjectIssuer + uuid, + //String brinCode + null, + //String initials + null, + //String chosenName + controlCode.getFirstName(), + //String firstName + controlCode.getFirstName(), + //String preferredLastName + controlCode.getLastName(), + //String legalLastName; + controlCode.getLastName(), + //String partnerLastNamePrefix + null, + //String legalLastNamePrefix + null, + //String preferredLastNamePrefix + null, + //String partnerLastName + null, + //Date dateOfBirth + parseDate(controlCode.getDayOfBirth()), + //Date createdAt + new Date(), + //Date expiresAt + Date.from(Instant.now().plus(DEFAULT_EXPIRATION_YEARS * 365, ChronoUnit.DAYS)), + //boolean external + true + ); + externalLinkedAccount.setPreferred(true); + return externalLinkedAccount; + } + @SneakyThrows public String serializeToBase64(VerifyState verifyState) { Map result = new HashMap<>(); @@ -258,7 +302,7 @@ public VerifyState serializeFromBase64(String base64) { public static Date parseDate(String dateString) { if (StringUtils.hasText(dateString)) { - for (DateTimeFormatter dateTimeFormatter: dateTimeFormatters) { + for (DateTimeFormatter dateTimeFormatter : dateTimeFormatters) { try { LocalDate localDate = LocalDate.parse(dateString, dateTimeFormatter); return Date.from(localDate.atStartOfDay(ZoneOffset.UTC).toInstant()); @@ -281,7 +325,6 @@ public static List externalAffiliations(List brinCodes, Manage m .toList(); } - private String getAttribute(Map attributes, String key) { return (String) attributes.get(key); } diff --git a/myconext-server/src/test/java/myconext/api/ServiceDeskControllerTest.java b/myconext-server/src/test/java/myconext/api/ServiceDeskControllerTest.java index e041ce1a..67a757db 100644 --- a/myconext-server/src/test/java/myconext/api/ServiceDeskControllerTest.java +++ b/myconext-server/src/test/java/myconext/api/ServiceDeskControllerTest.java @@ -1,14 +1,22 @@ package myconext.api; -import io.restassured.filter.Filter; -import io.restassured.filter.cookie.CookieFilter; import myconext.AbstractIntegrationTest; import myconext.model.ControlCode; +import myconext.model.ExternalLinkedAccount; +import myconext.model.User; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; + +import java.time.Instant; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeFormatterBuilder; import static io.restassured.RestAssured.given; -import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.*; @SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, properties = { @@ -16,4 +24,104 @@ }) class ServiceDeskControllerTest extends AbstractIntegrationTest { + @Test + void getUserControlCode() { + ControlCode controlCode = given() + .when() + .pathParam("code", "12345") + .get("/myconext/api/servicedesk/user/{code}") + .as(ControlCode.class); + assertEquals("12345", controlCode.getCode()); + assertEquals("mdoe", controlCode.getUserUid()); + } + + @Test + void getUserControlCodeNotFound() { + given() + .when() + .pathParam("code", "Nope") + .get("/myconext/api/servicedesk/user/{code}") + .then() + .statusCode(404); + } + + @Test + void validateDate() { + Boolean valid = given() + .when() + .queryParam("dayOfBirth", "16 Mar 1981") + .get("/myconext/api/servicedesk/validate") + .as(Boolean.class); + assertTrue(valid); + } + + @Test + void validateInvalidDate() { + Boolean valid = given() + .when() + .queryParam("dayOfBirth", "Nope") + .get("/myconext/api/servicedesk/validate") + .as(Boolean.class); + assertFalse(valid); + } + + + @Test + void convertUserControlCode() { + ControlCode controlCode = given() + .when() + .pathParam("code", "12345") + .get("/myconext/api/servicedesk/user/{code}") + .as(ControlCode.class); + + given() + .when() + .body(controlCode) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .put("/myconext/api/servicedesk/approve") + .then() + .statusCode(201); + + User user = userRepository.findOneUserByEmail("mdoe@example.com"); + assertEquals(1, user.getExternalLinkedAccounts().size()); + + ExternalLinkedAccount externalLinkedAccount = user.getExternalLinkedAccounts().getFirst(); + DateTimeFormatter dateTimeFormatter = new DateTimeFormatterBuilder().appendPattern("dd-MM-yyyy").toFormatter(); + ZonedDateTime dateOfBirth = externalLinkedAccount.getDateOfBirth().toInstant().atZone(ZoneId.systemDefault()); + //See myconext-server/src/test/resources/users.json + assertEquals("11-03-1987", dateTimeFormatter.format(dateOfBirth)); + } + + @Test + void convertUserControlCodeForbidden() { + ControlCode controlCode = new ControlCode("firstName", "lastName", "dayOfBirth"); + controlCode.setCode("12345"); + controlCode.setUserUid("bogus"); + + given() + .when() + .body(controlCode) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .put("/myconext/api/servicedesk/approve") + .then() + .statusCode(403); + } + + @Test + void convertUserControlCodeLinkedAccountsPresent() { + ControlCode controlCode = new ControlCode("firstName", "lastName", "dayOfBirth"); + controlCode.setCode("54321"); + + User user = userRepository.findOneUserByEmail("jdoe@example.com"); + user.setControlCode(controlCode); + userRepository.save(user); + + given() + .when() + .body(controlCode) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .put("/myconext/api/servicedesk/approve") + .then() + .statusCode(403); + } } \ No newline at end of file diff --git a/myconext-server/src/test/java/myconext/api/ServiceDeskControllerUnauthorizedTest.java b/myconext-server/src/test/java/myconext/api/ServiceDeskControllerUnauthorizedTest.java index b78d1720..8bae67f0 100644 --- a/myconext-server/src/test/java/myconext/api/ServiceDeskControllerUnauthorizedTest.java +++ b/myconext-server/src/test/java/myconext/api/ServiceDeskControllerUnauthorizedTest.java @@ -12,12 +12,12 @@ }) class ServiceDeskControllerUnauthorizedTest extends AbstractIntegrationTest { -// @Test -// void createUserControlCode() { -// given() -// .when() -// .get("/myconext/api/servicedesk") -// .then() -// .statusCode(403); -// } + @Test + void getUserControlCode() { + given() + .when() + .get("/myconext/api/servicedesk/user/12345") + .then() + .statusCode(403); + } } \ No newline at end of file diff --git a/myconext-server/src/test/java/myconext/api/UserControllerTest.java b/myconext-server/src/test/java/myconext/api/UserControllerTest.java index 9976d03e..75fbb073 100644 --- a/myconext-server/src/test/java/myconext/api/UserControllerTest.java +++ b/myconext-server/src/test/java/myconext/api/UserControllerTest.java @@ -1409,6 +1409,7 @@ public void metaData() { @Test public void createUserControlCode() { + clearExternalAccounts("jdoe@example.com"); ControlCode controlCode = new ControlCode("Lee", "Harpers", "01 Mar 1977"); ControlCode responseControlCode =given() .body(controlCode) @@ -1432,10 +1433,30 @@ public void createUserControlCode() { assertNull(userFromDB.getControlCode()); } + @Test + public void createUserControlCodeForbidden() { + ControlCode controlCode = new ControlCode("Lee", "Harpers", "01 Mar 1977"); + given() + .body(controlCode) + .header(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .when() + .post("/myconext/api/sp/control-code") + .then() + .statusCode(403); + } + private String hash() { byte[] bytes = new byte[64]; random.nextBytes(bytes); return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); } + private void clearExternalAccounts(String email) { + User user = userRepository.findOneUserByEmail(email); + user.getLinkedAccounts().clear(); + user.getExternalLinkedAccounts().clear(); + userRepository.save(user); + + } + } diff --git a/myconext-server/src/test/resources/users.json b/myconext-server/src/test/resources/users.json index afaff262..10ffacae 100644 --- a/myconext-server/src/test/resources/users.json +++ b/myconext-server/src/test/resources/users.json @@ -29,8 +29,8 @@ } ], "attributes": {}, - "surfSecureId" : { - "recovery-code" : "93132464" + "surfSecureId": { + "recovery-code": "93132464" }, "publicKeyCredentials": [ { @@ -55,7 +55,6 @@ "institutionGuid": "ad93daef-0911-e511-80d0-005056956c1a" } ] - }, { "value": "fc75dcc7-6def-4054-b8ba-3c3cc504dd4b", @@ -78,6 +77,13 @@ "familyName": "Doe", "password": "$2a$10$kUSu/Or0IX.E1WRaJtpUk.NWd83yV0mnt8CRnkbcQhvz6Qe3ntcAK", "schacHomeOrganization": "surfguest.nl", - "created": 1583337392 + "created": 1583337392, + "controlCode": { + "firstName": "Marianne", + "lastName": "DoeDoe", + "dayOfBirth": "11 Mar 1987", + "code": "12345", + "createdAt": 7979472000000 + } } ] \ No newline at end of file