commit d73c1acbe46b09b957c7db2d661a91015c55289f Author: Patrick Haßel Date: Sun Sep 14 22:37:26 2025 +0200 User security diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5ff6309 --- /dev/null +++ b/.gitignore @@ -0,0 +1,38 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ + +### IntelliJ IDEA ### +.idea/modules.xml +.idea/jarRepositories.xml +.idea/compiler.xml +.idea/libraries/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/application.properties b/application.properties new file mode 100644 index 0000000..360b82a --- /dev/null +++ b/application.properties @@ -0,0 +1 @@ +logging.level.de.ph87=DEBUG \ No newline at end of file diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..78fdd02 --- /dev/null +++ b/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + + de.ph87 + DataMulti + 1.0-SNAPSHOT + + + 21 + 21 + 21 + UTF-8 + + + + org.springframework.boot + spring-boot-starter-parent + 3.5.5 + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-websocket + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.security + spring-security-core + + + + + + + org.springframework.boot + spring-boot-starter-test + + + org.projectlombok + lombok + + + com.h2database + h2 + + + org.postgresql + postgresql + + + + \ No newline at end of file diff --git a/src/main/java/de/ph87/data/Backend.java b/src/main/java/de/ph87/data/Backend.java new file mode 100644 index 0000000..45f009b --- /dev/null +++ b/src/main/java/de/ph87/data/Backend.java @@ -0,0 +1,21 @@ +package de.ph87.data; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.context.annotation.Bean; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +@SpringBootApplication +public class Backend { + + public static void main(String[] args) { + SpringApplication.run(Backend.class, args); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + +} diff --git a/src/main/java/de/ph87/data/user/ConsumerWithException.java b/src/main/java/de/ph87/data/user/ConsumerWithException.java new file mode 100644 index 0000000..eefb0d4 --- /dev/null +++ b/src/main/java/de/ph87/data/user/ConsumerWithException.java @@ -0,0 +1,8 @@ +package de.ph87.data.user; + +@FunctionalInterface +public interface ConsumerWithException { + + void accept(final T t) throws E; + +} diff --git a/src/main/java/de/ph87/data/user/Helpers.java b/src/main/java/de/ph87/data/user/Helpers.java new file mode 100644 index 0000000..5a0160c --- /dev/null +++ b/src/main/java/de/ph87/data/user/Helpers.java @@ -0,0 +1,18 @@ +package de.ph87.data.user; + +import jakarta.annotation.Nullable; +import lombok.NonNull; + +import java.util.function.Function; + +public class Helpers { + + @Nullable + public static R map(@Nullable T t, @NonNull final Function map) { + if (t == null) { + return null; + } + return map.apply(t); + } + +} diff --git a/src/main/java/de/ph87/data/user/Principal.java b/src/main/java/de/ph87/data/user/Principal.java new file mode 100644 index 0000000..5755c77 --- /dev/null +++ b/src/main/java/de/ph87/data/user/Principal.java @@ -0,0 +1,23 @@ +package de.ph87.data.user; + +import lombok.Data; +import lombok.NonNull; + +@Data +public class Principal { + + public final long id; + + @NonNull + public final String token; + + @NonNull + public final UserDto user; + + public Principal(@NonNull final String token, @NonNull final User user) { + this.id = user.getId(); + this.token = token; + this.user = new UserDto(user); + } + +} diff --git a/src/main/java/de/ph87/data/user/PrincipalArgumentResolver.java b/src/main/java/de/ph87/data/user/PrincipalArgumentResolver.java new file mode 100644 index 0000000..176fbb1 --- /dev/null +++ b/src/main/java/de/ph87/data/user/PrincipalArgumentResolver.java @@ -0,0 +1,65 @@ +package de.ph87.data.user; + +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.MethodParameter; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Component; +import org.springframework.web.bind.support.WebDataBinderFactory; +import org.springframework.web.context.request.NativeWebRequest; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.method.support.ModelAndViewContainer; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Arrays; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PrincipalArgumentResolver implements HandlerMethodArgumentResolver { + + public static final String AUTH_TOKEN_COOKIE_NAME = "PATRIX-DATA-MULTI-AUTH-TOKEN"; + + private final UserService userService; + + @Override + public boolean supportsParameter(final MethodParameter parameter) { + return parameter.getParameterType() == Principal.class; + } + + @Override + public Principal resolveArgument(final MethodParameter parameter, final ModelAndViewContainer mavContainer, final NativeWebRequest webRequest, final WebDataBinderFactory binderFactory) { + final boolean required = !parameter.hasParameterAnnotation(PrincipalNotRequired.class); + final HttpServletRequest request = webRequest.getNativeRequest(HttpServletRequest.class); + if (request == null) { + throw new RuntimeException("HttpServletRequest is required"); + } + log.debug("Principal: path={}", request.getRequestURL().toString()); + + final Principal principal; + if (request.getCookies() == null) { + log.debug("Principal: No cookies received."); + principal = null; + } else { + final String token = Arrays.stream(request.getCookies()).filter(c -> AUTH_TOKEN_COOKIE_NAME.equals(c.getName())).findFirst().map(Cookie::getValue).orElse(""); + if (token.isEmpty()) { + log.debug("Principal: Token not set."); + principal = null; + } else { + principal = userService.findPrincipalByToken(token).orElse(null); + } + } + if (principal == null) { + if (required) { + log.warn("Principal: Not set but REQUIRED"); + throw new ResponseStatusException(HttpStatus.FORBIDDEN); + } else { + log.debug("Principal: Not set but NOT required"); + } + } + return principal; + } + +} diff --git a/src/main/java/de/ph87/data/user/PrincipalNotRequired.java b/src/main/java/de/ph87/data/user/PrincipalNotRequired.java new file mode 100644 index 0000000..22b4321 --- /dev/null +++ b/src/main/java/de/ph87/data/user/PrincipalNotRequired.java @@ -0,0 +1,9 @@ +package de.ph87.data.user; + +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; + +@Retention(RetentionPolicy.RUNTIME) +public @interface PrincipalNotRequired { + +} diff --git a/src/main/java/de/ph87/data/user/User.java b/src/main/java/de/ph87/data/user/User.java new file mode 100644 index 0000000..1f2d39c --- /dev/null +++ b/src/main/java/de/ph87/data/user/User.java @@ -0,0 +1,62 @@ +package de.ph87.data.user; + +import jakarta.persistence.Column; +import jakarta.persistence.ElementCollection; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.Version; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.NonNull; +import lombok.Setter; +import lombok.ToString; + +import java.time.ZonedDateTime; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +@Entity +@Getter +@Table(name = "`user`") +@ToString(onlyExplicitlyIncluded = true) +@NoArgsConstructor +public class User { + + @Id + @ToString.Include + @GeneratedValue(strategy = GenerationType.IDENTITY) + private long id; + + @Version + @ToString.Include + private long version; + + @NonNull + @ToString.Include + @Column(nullable = false, unique = true) + private String uuid = UUID.randomUUID().toString(); + + @NonNull + @ToString.Include + @Column(nullable = false, unique = true) + private String username; + + @Setter + @NonNull + @Column(nullable = false) + private String password; + + @NonNull + @ElementCollection + private Map tokens = new HashMap<>(); + + public User(final @NonNull UserCreate create, final @NonNull String password) { + this.username = create.getUsername(); + this.password = password; + } + +} diff --git a/src/main/java/de/ph87/data/user/UserController.java b/src/main/java/de/ph87/data/user/UserController.java new file mode 100644 index 0000000..0e5b245 --- /dev/null +++ b/src/main/java/de/ph87/data/user/UserController.java @@ -0,0 +1,76 @@ +package de.ph87.data.user; + +import jakarta.annotation.Nullable; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.CrossOrigin; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +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.server.ResponseStatusException; + +import static de.ph87.data.user.PrincipalArgumentResolver.AUTH_TOKEN_COOKIE_NAME; +import static de.ph87.data.user.Helpers.map; + +@CrossOrigin +@RestController +@RequiredArgsConstructor +@RequestMapping("User") +public class UserController { + + private final UserService userService; + + @NonNull + @PostMapping("create") + public UserDto create(@RequestBody final UserCreate create, final HttpServletResponse response) { + try { + final Principal principal = userService.create(create); + response.addCookie(new Cookie(AUTH_TOKEN_COOKIE_NAME, principal.token)); + return principal.user; + } catch (UserDuplicateError | UserInsecurePassword e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + + @Nullable + @GetMapping("whoAmI") + public UserDto whoAmI(@PrincipalNotRequired @Nullable final Principal principal) { + return map(principal, Principal::getUser); + } + + @NonNull + @PostMapping("login") + public UserDto login(@PrincipalNotRequired @Nullable final Principal oldPrincipal, @RequestBody final UserLogin login, final HttpServletResponse response) { + if (oldPrincipal != null) { + userService.logout(oldPrincipal); + } + try { + final Principal principal = userService.login(login); + response.addCookie(new Cookie(AUTH_TOKEN_COOKIE_NAME, principal.token)); + return principal.user; + } catch (UserNotFound | UserWrongPassword e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + + @GetMapping("logout") + public void logout(final Principal principal) { + userService.logout(principal); + } + + @NonNull + @PostMapping("setPassword") + public UserDto setPassword(@NonNull final Principal principal, @RequestBody final String password) { + try { + return userService.setPassword(principal, password); + } catch (UserInsecurePassword e) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, e.getMessage()); + } + } + +} diff --git a/src/main/java/de/ph87/data/user/UserCreate.java b/src/main/java/de/ph87/data/user/UserCreate.java new file mode 100644 index 0000000..2be6f04 --- /dev/null +++ b/src/main/java/de/ph87/data/user/UserCreate.java @@ -0,0 +1,19 @@ +package de.ph87.data.user; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NonNull; +import lombok.ToString; + +@Getter +@ToString +@AllArgsConstructor +public class UserCreate { + + @NonNull + private String username; + + @NonNull + private String password; + +} diff --git a/src/main/java/de/ph87/data/user/UserDto.java b/src/main/java/de/ph87/data/user/UserDto.java new file mode 100644 index 0000000..e6d011a --- /dev/null +++ b/src/main/java/de/ph87/data/user/UserDto.java @@ -0,0 +1,30 @@ +package de.ph87.data.user; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Data; +import lombok.NonNull; + +@Data +public class UserDto { + + @NonNull + public final String uuid; + + @NonNull + public final String username; + + public UserDto(@NonNull final User user) { + this.uuid = user.getUuid(); + this.username = user.getUsername(); + } + + @SuppressWarnings("unused") // used in tests + private UserDto( + @JsonProperty("uuid") @NonNull final String uuid, + @JsonProperty("username") @NonNull final String username + ) { + this.uuid = uuid; + this.username = username; + } + +} diff --git a/src/main/java/de/ph87/data/user/UserDuplicateError.java b/src/main/java/de/ph87/data/user/UserDuplicateError.java new file mode 100644 index 0000000..7e29d9d --- /dev/null +++ b/src/main/java/de/ph87/data/user/UserDuplicateError.java @@ -0,0 +1,5 @@ +package de.ph87.data.user; + +public class UserDuplicateError extends Exception { + +} diff --git a/src/main/java/de/ph87/data/user/UserInsecurePassword.java b/src/main/java/de/ph87/data/user/UserInsecurePassword.java new file mode 100644 index 0000000..04390cb --- /dev/null +++ b/src/main/java/de/ph87/data/user/UserInsecurePassword.java @@ -0,0 +1,5 @@ +package de.ph87.data.user; + +public class UserInsecurePassword extends Exception { + +} diff --git a/src/main/java/de/ph87/data/user/UserLogin.java b/src/main/java/de/ph87/data/user/UserLogin.java new file mode 100644 index 0000000..d537df9 --- /dev/null +++ b/src/main/java/de/ph87/data/user/UserLogin.java @@ -0,0 +1,20 @@ +package de.ph87.data.user; + +import lombok.Data; +import lombok.NonNull; + +@Data +public class UserLogin { + + @NonNull + public final String username; + + @NonNull + public final String password; + + public UserLogin(@NonNull final String username, @NonNull final String password) { + this.username = username; + this.password = password; + } + +} diff --git a/src/main/java/de/ph87/data/user/UserNotFound.java b/src/main/java/de/ph87/data/user/UserNotFound.java new file mode 100644 index 0000000..1c54274 --- /dev/null +++ b/src/main/java/de/ph87/data/user/UserNotFound.java @@ -0,0 +1,5 @@ +package de.ph87.data.user; + +public class UserNotFound extends Exception { + +} diff --git a/src/main/java/de/ph87/data/user/UserRepository.java b/src/main/java/de/ph87/data/user/UserRepository.java new file mode 100644 index 0000000..d422bd5 --- /dev/null +++ b/src/main/java/de/ph87/data/user/UserRepository.java @@ -0,0 +1,16 @@ +package de.ph87.data.user; + +import lombok.NonNull; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.ListCrudRepository; + +import java.util.Optional; + +public interface UserRepository extends ListCrudRepository { + + Optional findByUsername(@NonNull String username); + + @Query("select u from User u join u.tokens t where key(t) = :token") + Optional findByToken(@NonNull String token); + +} diff --git a/src/main/java/de/ph87/data/user/UserService.java b/src/main/java/de/ph87/data/user/UserService.java new file mode 100644 index 0000000..b1a2b42 --- /dev/null +++ b/src/main/java/de/ph87/data/user/UserService.java @@ -0,0 +1,119 @@ +package de.ph87.data.user; + +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Duration; +import java.time.ZonedDateTime; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class UserService { + + private static final Duration TOKEN_TIMEOUT = Duration.ofDays(3); + + private final UserRepository userRepository; + + private final PasswordEncoder passwordEncoder; + + @NonNull + @Transactional + public Principal create(final @NonNull UserCreate create) throws UserDuplicateError, UserInsecurePassword { + final Optional existing = userRepository.findByUsername(create.getUsername()); + if (existing.isPresent()) { + log.warn("create: Duplicate username: existing={}", existing.get()); + throw new UserDuplicateError(); + } + final User user = userRepository.save(new User(create, encodePassword(create.getPassword()))); + log.info("create: User created: user={}", user); + return createToken(user); + } + + @NonNull + @Transactional + public Principal login(@NonNull final UserLogin login) throws UserNotFound, UserWrongPassword { + final User user = userRepository.findByUsername(login.username).orElse(null); + if (user == null) { + passwordEncoder.matches(login.password, ""); // make a dummy check to fake runtime for bruteforce attacks + log.warn("login: User not found: username=\"{}\"", login.username); + throw new UserNotFound(); + } + if (!passwordEncoder.matches(login.password, user.getPassword())) { + log.warn("login: Wrong password: user={}", user); + throw new UserWrongPassword(); + } + log.info("login: User logged in: user={}", user); + return createToken(user); + } + + @NonNull + private static Principal createToken(@NonNull final User user) { + final String token = UUID.randomUUID().toString(); + user.getTokens().put(token, ZonedDateTime.now()); + return new Principal(token, user); + } + + @NonNull + @Transactional + public UserDto setPassword(@NonNull final Principal principal, @NonNull final String password) throws UserInsecurePassword { + return set(principal, u -> u.setPassword(encodePassword(password))); + } + + @NonNull + private UserDto set(@NonNull final Principal principal, @NonNull final ConsumerWithException modifier) throws E { + final User user = userRepository.findById(principal.id).orElseThrow(); + modifier.accept(user); + return new UserDto(user); + } + + @NonNull + private String encodePassword(@NonNull final String password) throws UserInsecurePassword { + if (password.length() < 12) { + throw new UserInsecurePassword(); + } + return passwordEncoder.encode(password); + } + + @NonNull + @Transactional + public Optional findPrincipalByToken(@NonNull final String token) { + final Optional userOptional = userRepository.findByToken(token); + if (userOptional.isEmpty()) { + log.debug("findByToken: No user found by given token!"); + return Optional.empty(); + } + + final User user = userOptional.get(); + final ZonedDateTime last = user.getTokens().get(token); + if (last == null) { + throw new RuntimeException("Fetched User by token, but token isn't present in users tokens!"); + } + + final ZonedDateTime earliest = ZonedDateTime.now().minus(TOKEN_TIMEOUT); + if (last.isBefore(earliest)) { + user.getTokens().remove(token); + log.debug("findByToken: Token expired: user={}", user); + return Optional.empty(); + } + + log.info("findByToken: Renewed: user={}", user); + user.getTokens().put(token, ZonedDateTime.now()); + return Optional.of(new Principal(token, user)); + } + + @Transactional + public void logout(@NonNull final Principal principal) { + set(principal, user -> { + user.getTokens().remove(principal.token); + log.info("logout: user={}", user); + }); + } + +} diff --git a/src/main/java/de/ph87/data/user/UserWrongPassword.java b/src/main/java/de/ph87/data/user/UserWrongPassword.java new file mode 100644 index 0000000..2c85829 --- /dev/null +++ b/src/main/java/de/ph87/data/user/UserWrongPassword.java @@ -0,0 +1,5 @@ +package de.ph87.data.user; + +public class UserWrongPassword extends Exception { + +} diff --git a/src/main/java/de/ph87/data/user/WebConfig.java b/src/main/java/de/ph87/data/user/WebConfig.java new file mode 100644 index 0000000..2a74a7c --- /dev/null +++ b/src/main/java/de/ph87/data/user/WebConfig.java @@ -0,0 +1,22 @@ +package de.ph87.data.user; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + + private final PrincipalArgumentResolver principalArgumentResolver; + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(principalArgumentResolver); + } + +} + diff --git a/src/test/java/de/ph87/data/user/UserControllerTest.java b/src/test/java/de/ph87/data/user/UserControllerTest.java new file mode 100644 index 0000000..a652064 --- /dev/null +++ b/src/test/java/de/ph87/data/user/UserControllerTest.java @@ -0,0 +1,99 @@ +package de.ph87.data.user; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.http.Cookie; +import lombok.NonNull; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.request.MockHttpServletRequestBuilder; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SuppressWarnings("SameParameterValue") +@SpringBootTest +@AutoConfigureMockMvc +public class UserControllerTest { + + private static final String USERNAME = "test-username"; + + private static final String PASSWORD = "test-password"; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void crud() throws Exception { + Cookie[] cookies = new Cookie[0]; + anonymous(cookies, "/User/whoAmI"); + cookies = create(cookies); + online(cookies, USERNAME); + anonymous(cookies, "/User/logout"); + anonymous(cookies, "/User/whoAmI"); + cookies = login(cookies, USERNAME, PASSWORD); + online(cookies, USERNAME); + } + + private void anonymous(final Cookie[] cookies, final String path) throws Exception { + final MockHttpServletRequestBuilder request = get(path); + if (cookies.length > 0) { + request.cookie(cookies); + } + final MockHttpServletResponse response = mockMvc.perform(request).andExpect(status().isOk()).andReturn().getResponse(); + assertEquals("", response.getContentAsString()); + response.getCookies(); + } + + private Cookie[] create(final Cookie[] cookies) throws Exception { + final MockHttpServletRequestBuilder create = post("/User/create"); + if (cookies.length > 0) { + create.cookie(cookies); + } + create.contentType(MediaType.APPLICATION_JSON); + create.content(objectMapper.writeValueAsString(new UserCreate(USERNAME, PASSWORD))); + return assertUser(create, USERNAME); + } + + private Cookie[] login(final Cookie[] cookies, final String username, final String password) throws Exception { + final MockHttpServletRequestBuilder request = post("/User/login"); + if (cookies.length > 0) { + request.cookie(cookies); + } + request.contentType(MediaType.APPLICATION_JSON); + request.content(objectMapper.writeValueAsString(new UserLogin(username, password))); + return assertUser(request, username); + } + + private void online(final Cookie[] cookies, final String username) throws Exception { + final MockHttpServletRequestBuilder request = get("/User/whoAmI"); + if (cookies.length > 0) { + request.cookie(cookies); + } + assertUser(request, username); + } + + @NonNull + private Cookie[] assertUser(final MockHttpServletRequestBuilder request, @NonNull final String username) throws Exception { + final MockHttpServletResponse response = mockMvc.perform(request).andExpect(status().isOk()).andReturn().getResponse(); + final String payload = response.getContentAsString(); + assertNotNull(payload); + assertNotEquals("", payload); + final UserDto userDto = objectMapper.readValue(payload, UserDto.class); + assertEquals(36, userDto.uuid.length()); + assertEquals(username, userDto.username); + return response.getCookies(); + } + +} \ No newline at end of file