User as Entity
This commit is contained in:
parent
4104c46f3d
commit
d59ad4177f
5
pom.xml
5
pom.xml
@ -32,6 +32,11 @@
|
||||
<artifactId>spring-boot-starter-websocket</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.security</groupId>
|
||||
<artifactId>spring-security-core</artifactId>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-data-jpa</artifactId>
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
import {validateDate, validateList, validateString} from "../common/validators";
|
||||
import {validateBoolean, validateDate, validateList, validateString} from "../common/validators";
|
||||
import {Group} from "../group/Group";
|
||||
|
||||
export class UserPrivate {
|
||||
@ -7,8 +7,9 @@ export class UserPrivate {
|
||||
readonly privateUuid: string,
|
||||
readonly publicUuid: string,
|
||||
readonly created: Date,
|
||||
readonly groups: Group[],
|
||||
readonly name: string,
|
||||
readonly password: boolean,
|
||||
readonly groups: Group[],
|
||||
) {
|
||||
// -
|
||||
}
|
||||
@ -18,8 +19,9 @@ export class UserPrivate {
|
||||
validateString(json['privateUuid']),
|
||||
validateString(json['publicUuid']),
|
||||
validateDate(json['created']),
|
||||
validateList(json['groups'], Group.fromJson),
|
||||
validateString(json['name']),
|
||||
validateBoolean(json['password']),
|
||||
validateList(json['groups'], Group.fromJson),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -49,6 +49,10 @@ export class UserService {
|
||||
this.api.postSingle(['User', 'changeName'], name, UserPrivate.fromJson, next);
|
||||
}
|
||||
|
||||
changePassword(password: string, next?: Next<UserPrivate>) {
|
||||
this.api.postSingle(['User', 'changePassword'], password, UserPrivate.fromJson, next);
|
||||
}
|
||||
|
||||
goto(user: UserPublic) {
|
||||
this.router.navigate(['/User', user.publicUuid]);
|
||||
}
|
||||
@ -81,5 +85,4 @@ export class UserService {
|
||||
newSubscriber(): Subscribed<UserPrivate> {
|
||||
return new Subscribed<UserPrivate>(UserPrivate.samePrivateUuid, (user, next) => this.api.subscribe(['UserPrivate', user.privateUuid], UserPrivate.fromJson, next));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
<ng-container *ngIf="group.value">
|
||||
<h1>Nummern</h1>
|
||||
<h1>Gruppe</h1>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Erstellt</th>
|
||||
|
||||
@ -1,5 +1,60 @@
|
||||
<ng-container *ngIf="userService.user !== null">
|
||||
<h1>Profil</h1>
|
||||
<app-text [initial]="userService.user.name" [editable]="true" (onChange)="userService.changeName($event)"></app-text>
|
||||
<table>
|
||||
<tr>
|
||||
<th>
|
||||
Name:
|
||||
</th>
|
||||
<td>
|
||||
<app-text [initial]="userService.user.name" [editable]="true" (onChange)="userService.changeName($event)" [validator]="nameValidator"></app-text>
|
||||
</td>
|
||||
<td class="hint">
|
||||
Mindestens {{ USER_NAME_MIN_LENGTH }} Zeichen. Keine Leerzeichen. Buchstaben oder Zahlen müssen enthalten sein.
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th colspan="3"> </th>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th>
|
||||
Passwort:
|
||||
</th>
|
||||
<td>
|
||||
<div *ngIf="!userService.user.password" class="passwordNotSet">Nicht gesetzt</div>
|
||||
<div *ngIf="userService.user.password" class="passwordSet">Gesetzt</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
Neues Passwort:
|
||||
</th>
|
||||
<td>
|
||||
<input #p0 type="text" [(ngModel)]="password0" [class.passwordInvalid]="p0Invalid()" [class.passwordValid]="p0Valid()" (keydown.enter)="p0Enter()">
|
||||
</td>
|
||||
<td class="hint">
|
||||
Mindestens {{ USER_PASSWORD_MIN_LENGTH }} Zeichen. Nicht nur Zahlen. Nicht nur Buchstaben.
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>
|
||||
Wiederholung:
|
||||
</th>
|
||||
<td>
|
||||
<input #p1 type="text" [(ngModel)]="password1" [class.passwordInvalid]="p1Invalid()" [class.passwordValid]="p1Valid()" (keydown.enter)="p1Enter()">
|
||||
</td>
|
||||
<td class="hint">
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
<tr>
|
||||
<th colspan="3"> </th>
|
||||
</tr>
|
||||
|
||||
</table>
|
||||
|
||||
<app-group-list [groups]="userService.user.groups"></app-group-list>
|
||||
|
||||
</ng-container>
|
||||
|
||||
@ -0,0 +1,23 @@
|
||||
th {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.passwordSet {
|
||||
color: green;
|
||||
}
|
||||
|
||||
.passwordNotSet {
|
||||
color: darkred;
|
||||
}
|
||||
|
||||
input {
|
||||
-webkit-text-security: disc;
|
||||
}
|
||||
|
||||
.passwordInvalid {
|
||||
background-color: indianred;
|
||||
}
|
||||
|
||||
.passwordValid {
|
||||
background-color: lightgreen;
|
||||
}
|
||||
@ -1,10 +1,14 @@
|
||||
import {Component} from '@angular/core';
|
||||
import {Component, ElementRef, ViewChild} from '@angular/core';
|
||||
import {NgForOf, NgIf} from "@angular/common";
|
||||
import {UserService} from "../../api/User/user.service";
|
||||
import {FormsModule} from "@angular/forms";
|
||||
import {TextComponent} from "../../shared/text/text.component";
|
||||
import {GroupListComponent} from "../group/shared/group-list/group-list.component";
|
||||
|
||||
const USER_NAME_MIN_LENGTH = 2;
|
||||
|
||||
const USER_PASSWORD_MIN_LENGTH = 10;
|
||||
|
||||
@Component({
|
||||
selector: 'app-profile',
|
||||
standalone: true,
|
||||
@ -20,10 +24,67 @@ import {GroupListComponent} from "../group/shared/group-list/group-list.componen
|
||||
})
|
||||
export class ProfileComponent {
|
||||
|
||||
protected readonly USER_NAME_MIN_LENGTH = USER_NAME_MIN_LENGTH;
|
||||
|
||||
protected readonly USER_PASSWORD_MIN_LENGTH = USER_PASSWORD_MIN_LENGTH;
|
||||
|
||||
protected password0: string = "";
|
||||
|
||||
protected password1: string = "";
|
||||
|
||||
@ViewChild('p1')
|
||||
p1!: ElementRef;
|
||||
|
||||
constructor(
|
||||
protected readonly userService: UserService,
|
||||
) {
|
||||
// -
|
||||
}
|
||||
|
||||
protected nameValidator(name: string): boolean {
|
||||
console.log(name);
|
||||
return name.length >= USER_NAME_MIN_LENGTH && !/\s+|^[^a-zA-Z0-9]+$/.test(name);
|
||||
}
|
||||
|
||||
protected passwordValidator(password: string) {
|
||||
return password.length >= USER_PASSWORD_MIN_LENGTH && !/^[a-zA-Z]+$|^[0-9]+$/.test(password);
|
||||
}
|
||||
|
||||
protected p0Invalid(): boolean {
|
||||
return this.password0 !== '' && !this.passwordValidator(this.password0);
|
||||
}
|
||||
|
||||
protected p0Valid(): boolean {
|
||||
return this.password0 !== '' && this.passwordValidator(this.password0);
|
||||
}
|
||||
|
||||
protected p1Invalid(): boolean {
|
||||
return this.p0Invalid() || this.password0 !== this.password1;
|
||||
}
|
||||
|
||||
protected p1Valid(): boolean {
|
||||
return this.p0Valid() && this.password0 === this.password1;
|
||||
}
|
||||
|
||||
protected p0Enter() {
|
||||
if (this.p1Valid()) {
|
||||
this.doChangePassword();
|
||||
} else if (this.p0Valid()) {
|
||||
this.p1.nativeElement.focus();
|
||||
}
|
||||
}
|
||||
|
||||
protected p1Enter() {
|
||||
if (this.p1Valid()) {
|
||||
this.doChangePassword();
|
||||
}
|
||||
}
|
||||
|
||||
private doChangePassword() {
|
||||
this.userService.changePassword(this.password0, _ => {
|
||||
this.password0 = "";
|
||||
this.password1 = "";
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,2 +1,12 @@
|
||||
<input *ngIf="editable" [(ngModel)]="model" (focus)="begin()" (blur)="apply()" (keydown.enter)="apply()" (keydown.escape)="abort()">
|
||||
<input
|
||||
*ngIf="editable"
|
||||
[(ngModel)]="model"
|
||||
(focus)="begin()"
|
||||
(blur)="apply()"
|
||||
(keydown.enter)="apply()"
|
||||
(keydown.escape)="abort()"
|
||||
[class.invalid]="validator !== null && !validator(model)"
|
||||
[class.unsaved]="model !== _initial"
|
||||
>
|
||||
|
||||
<div *ngIf="!editable">{{ _initial }}</div>
|
||||
|
||||
@ -1 +1,9 @@
|
||||
@import "../../../common.less";
|
||||
|
||||
.unsaved {
|
||||
background-color: yellow;
|
||||
}
|
||||
|
||||
.invalid {
|
||||
background-color: indianred;
|
||||
}
|
||||
|
||||
@ -34,6 +34,9 @@ export class TextComponent implements OnInit {
|
||||
@Input()
|
||||
editable: boolean = false;
|
||||
|
||||
@Input()
|
||||
validator: ((password: string) => boolean) | null = null;
|
||||
|
||||
ngOnInit(): void {
|
||||
this.model = this._initial;
|
||||
}
|
||||
@ -43,7 +46,7 @@ export class TextComponent implements OnInit {
|
||||
}
|
||||
|
||||
apply() {
|
||||
if (this.model !== this._initial) {
|
||||
if (this.model !== this._initial && (!this.validator || this.validator(this.model))) {
|
||||
this.onChange.emit(this.model);
|
||||
}
|
||||
this.end();
|
||||
|
||||
@ -3,6 +3,9 @@ package de.ph87.tools;
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.boot.web.servlet.support.SpringBootServletInitializer;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
@SpringBootApplication
|
||||
public class Backend extends SpringBootServletInitializer {
|
||||
@ -11,4 +14,9 @@ public class Backend extends SpringBootServletInitializer {
|
||||
SpringApplication.run(Backend.class, args);
|
||||
}
|
||||
|
||||
@Bean
|
||||
public PasswordEncoder passwordEncoder() {
|
||||
return new BCryptPasswordEncoder();
|
||||
}
|
||||
|
||||
}
|
||||
16
src/main/java/de/ph87/tools/user/LoginRequest.java
Normal file
16
src/main/java/de/ph87/tools/user/LoginRequest.java
Normal file
@ -0,0 +1,16 @@
|
||||
package de.ph87.tools.user;
|
||||
|
||||
import lombok.Getter;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.ToString;
|
||||
|
||||
@Getter
|
||||
@ToString
|
||||
@RequiredArgsConstructor
|
||||
public class LoginRequest {
|
||||
|
||||
public final String name;
|
||||
|
||||
public final String password;
|
||||
|
||||
}
|
||||
@ -6,6 +6,7 @@ import jakarta.persistence.Entity;
|
||||
import jakarta.persistence.Id;
|
||||
import jakarta.persistence.Table;
|
||||
import lombok.*;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
|
||||
import java.time.ZonedDateTime;
|
||||
import java.util.List;
|
||||
@ -37,8 +38,19 @@ public class User implements IWebSocketMessage {
|
||||
|
||||
@Setter
|
||||
@NonNull
|
||||
@Column(nullable = false, unique = true)
|
||||
private String name;
|
||||
|
||||
@Column(nullable = false)
|
||||
private String name = "Neuer Benutzer";
|
||||
private boolean admin = false;
|
||||
|
||||
@NonNull
|
||||
@Column(nullable = false)
|
||||
private String password = "";
|
||||
|
||||
public User(@NonNull final String name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
public void touch() {
|
||||
lastAccess = ZonedDateTime.now();
|
||||
@ -62,4 +74,8 @@ public class User implements IWebSocketMessage {
|
||||
return privateUuid.hashCode();
|
||||
}
|
||||
|
||||
public void setPassword(@NonNull final PasswordEncoder passwordEncoder, @NonNull final String password) {
|
||||
this.password = passwordEncoder.encode(password);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -24,12 +24,24 @@ public class UserController {
|
||||
return userService.getUserByPrivateUuidOrNull(userUuid, response);
|
||||
}
|
||||
|
||||
@Nullable
|
||||
@GetMapping("login")
|
||||
public UserPrivateDto login(@NonNull @RequestBody final LoginRequest loginRequest, @NonNull final HttpServletResponse response) {
|
||||
return userService.login(loginRequest, response);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@PostMapping("changeName")
|
||||
public UserPrivateDto changeName(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @NonNull @RequestBody final String name) {
|
||||
return userService.changeName(userUuid, name);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@PostMapping("changePassword")
|
||||
public UserPrivateDto changePassword(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @NonNull @RequestBody final String password) {
|
||||
return userService.changePassword(userUuid, password);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
@PostMapping("getCommonByUuid")
|
||||
public UserCommonDto getCommonByUuid(@CookieValue(name = USER_UUID_COOKIE_NAME) @NonNull final String userUuid, @NonNull @RequestBody final String targetUuid) {
|
||||
|
||||
@ -29,11 +29,14 @@ public class UserPrivateDto implements IWebSocketMessage {
|
||||
@NonNull
|
||||
private final Set<GroupDto> groups;
|
||||
|
||||
private final boolean password;
|
||||
|
||||
public UserPrivateDto(@NonNull final User user, final @NonNull Set<GroupDto> groups) {
|
||||
this.publicUuid = user.getPublicUuid();
|
||||
this.name = user.getName();
|
||||
this.privateUuid = user.getPrivateUuid();
|
||||
this.created = user.getCreated();
|
||||
this.password = !user.getPassword().isEmpty();
|
||||
this.groups = groups;
|
||||
}
|
||||
|
||||
|
||||
@ -38,9 +38,11 @@ public class UserPrivateMapper {
|
||||
return toPrivateDto(user);
|
||||
}
|
||||
|
||||
public void publish(@NonNull final User user) {
|
||||
@NonNull
|
||||
public UserPrivateDto publish(@NonNull final User user) {
|
||||
final UserPrivateDto dto = toPrivateDto(user);
|
||||
applicationEventPublisher.publishEvent(dto);
|
||||
return dto;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -7,6 +7,10 @@ import java.util.Optional;
|
||||
|
||||
public interface UserRepository extends ListCrudRepository<User, String> {
|
||||
|
||||
boolean existsByName(@NonNull String name);
|
||||
|
||||
Optional<User> findByName(@NonNull String name);
|
||||
|
||||
@NonNull
|
||||
Optional<User> findByPublicUuid(@NonNull String publicUuid);
|
||||
|
||||
|
||||
@ -10,13 +10,16 @@ import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.scheduling.annotation.EnableScheduling;
|
||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.Optional;
|
||||
import java.util.Random;
|
||||
import java.util.Set;
|
||||
import java.util.function.Consumer;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
import static de.ph87.tools.UserArgumentResolver.USER_UUID_COOKIE_NAME;
|
||||
|
||||
@ -27,6 +30,16 @@ import static de.ph87.tools.UserArgumentResolver.USER_UUID_COOKIE_NAME;
|
||||
@RequiredArgsConstructor
|
||||
public class UserService {
|
||||
|
||||
private static final String USER_NAME_DEFAULT_BASE = "Neuer Benutzer";
|
||||
|
||||
private static final int NAME_MIN_LENGTH = 2;
|
||||
|
||||
private static final Pattern NAME_NEGATIVE_REGEX = Pattern.compile("\\s+|^[^a-zA-Z0-9]+$");
|
||||
|
||||
private static final int PASSWORD_MIN_LENGTH = 10;
|
||||
|
||||
private static final Pattern PASSWORD_NEGATIVE_REGEX = Pattern.compile("^[a-zA-Z]+$|^[0-9]+$");
|
||||
|
||||
private final UserRepository userRepository;
|
||||
|
||||
private final UserPrivateMapper userPrivateMapper;
|
||||
@ -35,6 +48,20 @@ public class UserService {
|
||||
|
||||
private final GroupMapper groupMapper;
|
||||
|
||||
private final PasswordEncoder passwordEncoder;
|
||||
|
||||
@NonNull
|
||||
public UserPrivateDto login(@NonNull final LoginRequest loginRequest, @NonNull final HttpServletResponse response) {
|
||||
final User user = userRepository.findByName(loginRequest.name).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST));
|
||||
if (passwordEncoder.matches(loginRequest.password, user.getPassword())) {
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
user.touch();
|
||||
writeUserUuidCookie(response, user);
|
||||
userPublicMapper.publish(user);
|
||||
return userPrivateMapper.publish(user);
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public User getUserByPrivateUuidOrElseCreate(@Nullable final String privateUuid, @NonNull final HttpServletResponse response) {
|
||||
final User user = Optional.ofNullable(privateUuid).map(userRepository::findByPrivateUuid).filter(Optional::isPresent).map(Optional::get).orElseGet(this::createUnchecked);
|
||||
@ -55,11 +82,43 @@ public class UserService {
|
||||
@NonNull
|
||||
public UserPrivateDto changeName(@NonNull final String privateUuid, @NonNull final String name) {
|
||||
return modify(privateUuid, user -> {
|
||||
if (user.getName().equals(name)) {
|
||||
return;
|
||||
}
|
||||
final User duplicate = userRepository.findByName(name).orElse(null);
|
||||
if (duplicate != null) {
|
||||
log.warn("Cannot change User name because of duplicate: name={}, user={}, duplicate={}", name, user, duplicate);
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (name.length() < NAME_MIN_LENGTH) {
|
||||
log.warn("Cannot change User name: too short: length={}/{}, user={}", name.length(), NAME_MIN_LENGTH, user);
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (NAME_NEGATIVE_REGEX.matcher(name).find()) {
|
||||
log.warn("Cannot change User name: Matches negative regex: name={}, user={}", name, user);
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
user.setName(name);
|
||||
log.info("User name changed: user={}", user);
|
||||
});
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public UserPrivateDto changePassword(@NonNull final String privateUuid, @NonNull final String password) {
|
||||
return modify(privateUuid, user -> {
|
||||
if (password.length() < PASSWORD_MIN_LENGTH) {
|
||||
log.warn("Cannot change User password: too short: length={}/{}, user={}", password.length(), PASSWORD_MIN_LENGTH, user);
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (PASSWORD_NEGATIVE_REGEX.matcher(password).find()) {
|
||||
log.warn("Cannot change User password: Matches negative regex: user={}", user);
|
||||
throw new ResponseStatusException(HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
user.setPassword(passwordEncoder, password);
|
||||
log.info("User password changed: user={}", user);
|
||||
});
|
||||
}
|
||||
|
||||
@NonNull
|
||||
public UserCommonDto getCommonByUuid(@NonNull final String privateUuid, @NonNull final String targetUuid) {
|
||||
final User principal = getByPrivateUuidOrThrow(privateUuid);
|
||||
@ -77,11 +136,23 @@ public class UserService {
|
||||
|
||||
@NonNull
|
||||
private User createUnchecked() {
|
||||
final User user = userRepository.save(new User());
|
||||
final String name = generateFreeUserName();
|
||||
final User user = userRepository.save(new User(name));
|
||||
log.info("User CREATED: {}", user);
|
||||
return user;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private String generateFreeUserName() {
|
||||
int index = new Random().nextInt(1234, 5723);
|
||||
String name = USER_NAME_DEFAULT_BASE;
|
||||
while (userRepository.existsByName(name)) {
|
||||
index++;
|
||||
name = "%s #%d".formatted(USER_NAME_DEFAULT_BASE, index);
|
||||
}
|
||||
return name;
|
||||
}
|
||||
|
||||
@NonNull
|
||||
private UserPrivateDto modify(@NonNull final String privateUuid, @NonNull Consumer<User> modifier) {
|
||||
final User user = getByPrivateUuidOrThrow(privateUuid);
|
||||
@ -121,7 +192,7 @@ public class UserService {
|
||||
if (user != null) {
|
||||
cookie.setValue(user.getPrivateUuid());
|
||||
}
|
||||
cookie.setMaxAge(10 * 365 * 24 * 60 * 60);
|
||||
cookie.setMaxAge(PASSWORD_MIN_LENGTH * 365 * 24 * 60 * 60);
|
||||
cookie.setPath("/");
|
||||
response.addCookie(cookie);
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user