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