User security
This commit is contained in:
commit
d73c1acbe4
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal 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
1
application.properties
Normal file
@ -0,0 +1 @@
|
||||
logging.level.de.ph87=DEBUG
|
||||
63
pom.xml
Normal file
63
pom.xml
Normal 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>
|
||||
21
src/main/java/de/ph87/data/Backend.java
Normal file
21
src/main/java/de/ph87/data/Backend.java
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
@ -0,0 +1,8 @@
|
||||
package de.ph87.data.user;
|
||||
|
||||
@FunctionalInterface
|
||||
public interface ConsumerWithException<T, E extends Exception> {
|
||||
|
||||
void accept(final T t) throws E;
|
||||
|
||||
}
|
||||
18
src/main/java/de/ph87/data/user/Helpers.java
Normal file
18
src/main/java/de/ph87/data/user/Helpers.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
23
src/main/java/de/ph87/data/user/Principal.java
Normal file
23
src/main/java/de/ph87/data/user/Principal.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
}
|
||||
@ -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 {
|
||||
|
||||
}
|
||||
62
src/main/java/de/ph87/data/user/User.java
Normal file
62
src/main/java/de/ph87/data/user/User.java
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
76
src/main/java/de/ph87/data/user/UserController.java
Normal file
76
src/main/java/de/ph87/data/user/UserController.java
Normal 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());
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
19
src/main/java/de/ph87/data/user/UserCreate.java
Normal file
19
src/main/java/de/ph87/data/user/UserCreate.java
Normal 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;
|
||||
|
||||
}
|
||||
30
src/main/java/de/ph87/data/user/UserDto.java
Normal file
30
src/main/java/de/ph87/data/user/UserDto.java
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
5
src/main/java/de/ph87/data/user/UserDuplicateError.java
Normal file
5
src/main/java/de/ph87/data/user/UserDuplicateError.java
Normal file
@ -0,0 +1,5 @@
|
||||
package de.ph87.data.user;
|
||||
|
||||
public class UserDuplicateError extends Exception {
|
||||
|
||||
}
|
||||
@ -0,0 +1,5 @@
|
||||
package de.ph87.data.user;
|
||||
|
||||
public class UserInsecurePassword extends Exception {
|
||||
|
||||
}
|
||||
20
src/main/java/de/ph87/data/user/UserLogin.java
Normal file
20
src/main/java/de/ph87/data/user/UserLogin.java
Normal 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;
|
||||
}
|
||||
|
||||
}
|
||||
5
src/main/java/de/ph87/data/user/UserNotFound.java
Normal file
5
src/main/java/de/ph87/data/user/UserNotFound.java
Normal file
@ -0,0 +1,5 @@
|
||||
package de.ph87.data.user;
|
||||
|
||||
public class UserNotFound extends Exception {
|
||||
|
||||
}
|
||||
16
src/main/java/de/ph87/data/user/UserRepository.java
Normal file
16
src/main/java/de/ph87/data/user/UserRepository.java
Normal 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);
|
||||
|
||||
}
|
||||
119
src/main/java/de/ph87/data/user/UserService.java
Normal file
119
src/main/java/de/ph87/data/user/UserService.java
Normal 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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
5
src/main/java/de/ph87/data/user/UserWrongPassword.java
Normal file
5
src/main/java/de/ph87/data/user/UserWrongPassword.java
Normal file
@ -0,0 +1,5 @@
|
||||
package de.ph87.data.user;
|
||||
|
||||
public class UserWrongPassword extends Exception {
|
||||
|
||||
}
|
||||
22
src/main/java/de/ph87/data/user/WebConfig.java
Normal file
22
src/main/java/de/ph87/data/user/WebConfig.java
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
99
src/test/java/de/ph87/data/user/UserControllerTest.java
Normal file
99
src/test/java/de/ph87/data/user/UserControllerTest.java
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user