User security

This commit is contained in:
Patrick Haßel 2025-09-14 22:37:26 +02:00
commit d73c1acbe4
22 changed files with 729 additions and 0 deletions

38
.gitignore vendored Normal file
View File

@ -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

1
application.properties Normal file
View File

@ -0,0 +1 @@
logging.level.de.ph87=DEBUG

63
pom.xml Normal file
View File

@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>de.ph87</groupId>
<artifactId>DataMulti</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.5</version>
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-core</artifactId>
</dependency>
<!-- <dependency>-->
<!-- <groupId>org.springframework.boot</groupId>-->
<!-- <artifactId>spring-boot-starter-security</artifactId>-->
<!-- </dependency>-->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
</dependency>
</dependencies>
</project>

View File

@ -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();
}
}

View File

@ -0,0 +1,8 @@
package de.ph87.data.user;
@FunctionalInterface
public interface ConsumerWithException<T, E extends Exception> {
void accept(final T t) throws E;
}

View File

@ -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 <T, R> R map(@Nullable T t, @NonNull final Function<T, R> map) {
if (t == null) {
return null;
}
return map.apply(t);
}
}

View File

@ -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);
}
}

View File

@ -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;
}
}

View File

@ -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 {
}

View File

@ -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<String, ZonedDateTime> tokens = new HashMap<>();
public User(final @NonNull UserCreate create, final @NonNull String password) {
this.username = create.getUsername();
this.password = password;
}
}

View File

@ -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());
}
}
}

View File

@ -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;
}

View File

@ -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;
}
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.user;
public class UserDuplicateError extends Exception {
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.user;
public class UserInsecurePassword extends Exception {
}

View File

@ -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;
}
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.user;
public class UserNotFound extends Exception {
}

View File

@ -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<User, Long> {
Optional<User> findByUsername(@NonNull String username);
@Query("select u from User u join u.tokens t where key(t) = :token")
Optional<User> findByToken(@NonNull String token);
}

View File

@ -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<User> 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 <E extends Exception> UserDto set(@NonNull final Principal principal, @NonNull final ConsumerWithException<User, E> 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<Principal> findPrincipalByToken(@NonNull final String token) {
final Optional<User> 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);
});
}
}

View File

@ -0,0 +1,5 @@
package de.ph87.data.user;
public class UserWrongPassword extends Exception {
}

View File

@ -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<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(principalArgumentResolver);
}
}

View File

@ -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();
}
}