diff --git a/pom.xml b/pom.xml index 05b131e..2067740 100644 --- a/pom.xml +++ b/pom.xml @@ -32,6 +32,11 @@ spring-boot-starter-websocket + + org.springframework.security + spring-security-core + + org.springframework.boot spring-boot-starter-data-jpa diff --git a/src/main/angular/src/app/api/User/UserPrivate.ts b/src/main/angular/src/app/api/User/UserPrivate.ts index 8cd9546..d4f3721 100644 --- a/src/main/angular/src/app/api/User/UserPrivate.ts +++ b/src/main/angular/src/app/api/User/UserPrivate.ts @@ -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), ); } diff --git a/src/main/angular/src/app/api/User/user.service.ts b/src/main/angular/src/app/api/User/user.service.ts index 5496800..ad5c174 100644 --- a/src/main/angular/src/app/api/User/user.service.ts +++ b/src/main/angular/src/app/api/User/user.service.ts @@ -49,6 +49,10 @@ export class UserService { this.api.postSingle(['User', 'changeName'], name, UserPrivate.fromJson, next); } + changePassword(password: string, next?: Next) { + 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 { return new Subscribed(UserPrivate.samePrivateUuid, (user, next) => this.api.subscribe(['UserPrivate', user.privateUuid], UserPrivate.fromJson, next)); } - } diff --git a/src/main/angular/src/app/pages/group/group/group.component.html b/src/main/angular/src/app/pages/group/group/group.component.html index 4ca6bbc..6007f3e 100644 --- a/src/main/angular/src/app/pages/group/group/group.component.html +++ b/src/main/angular/src/app/pages/group/group/group.component.html @@ -1,5 +1,5 @@ -

Nummern

+

Gruppe

diff --git a/src/main/angular/src/app/pages/profile/profile.component.html b/src/main/angular/src/app/pages/profile/profile.component.html index ac348cb..c65ad9e 100644 --- a/src/main/angular/src/app/pages/profile/profile.component.html +++ b/src/main/angular/src/app/pages/profile/profile.component.html @@ -1,5 +1,60 @@

Profil

- +
Erstellt
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ Name: + + + + Mindestens {{ USER_NAME_MIN_LENGTH }} Zeichen. Keine Leerzeichen. Buchstaben oder Zahlen müssen enthalten sein. +
 
+ Passwort: + +
Nicht gesetzt
+
Gesetzt
+
+ Neues Passwort: + + + + Mindestens {{ USER_PASSWORD_MIN_LENGTH }} Zeichen. Nicht nur Zahlen. Nicht nur Buchstaben. +
+ Wiederholung: + + + +   +
 
+ +
diff --git a/src/main/angular/src/app/pages/profile/profile.component.less b/src/main/angular/src/app/pages/profile/profile.component.less index e69de29..830a9c4 100644 --- a/src/main/angular/src/app/pages/profile/profile.component.less +++ b/src/main/angular/src/app/pages/profile/profile.component.less @@ -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; +} diff --git a/src/main/angular/src/app/pages/profile/profile.component.ts b/src/main/angular/src/app/pages/profile/profile.component.ts index 1a8ed3d..4428599 100644 --- a/src/main/angular/src/app/pages/profile/profile.component.ts +++ b/src/main/angular/src/app/pages/profile/profile.component.ts @@ -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 = ""; + }); + } + } diff --git a/src/main/angular/src/app/shared/text/text.component.html b/src/main/angular/src/app/shared/text/text.component.html index e446ae2..2964f92 100644 --- a/src/main/angular/src/app/shared/text/text.component.html +++ b/src/main/angular/src/app/shared/text/text.component.html @@ -1,2 +1,12 @@ - + +
{{ _initial }}
diff --git a/src/main/angular/src/app/shared/text/text.component.less b/src/main/angular/src/app/shared/text/text.component.less index 6857e0d..ff9ef55 100644 --- a/src/main/angular/src/app/shared/text/text.component.less +++ b/src/main/angular/src/app/shared/text/text.component.less @@ -1 +1,9 @@ @import "../../../common.less"; + +.unsaved { + background-color: yellow; +} + +.invalid { + background-color: indianred; +} diff --git a/src/main/angular/src/app/shared/text/text.component.ts b/src/main/angular/src/app/shared/text/text.component.ts index 4d5776d..8867cce 100644 --- a/src/main/angular/src/app/shared/text/text.component.ts +++ b/src/main/angular/src/app/shared/text/text.component.ts @@ -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(); diff --git a/src/main/java/de/ph87/tools/Backend.java b/src/main/java/de/ph87/tools/Backend.java index 1351546..b30734a 100644 --- a/src/main/java/de/ph87/tools/Backend.java +++ b/src/main/java/de/ph87/tools/Backend.java @@ -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(); + } + } \ No newline at end of file diff --git a/src/main/java/de/ph87/tools/user/LoginRequest.java b/src/main/java/de/ph87/tools/user/LoginRequest.java new file mode 100644 index 0000000..4e0cea9 --- /dev/null +++ b/src/main/java/de/ph87/tools/user/LoginRequest.java @@ -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; + +} diff --git a/src/main/java/de/ph87/tools/user/User.java b/src/main/java/de/ph87/tools/user/User.java index 9fbe8ef..f5d56dd 100644 --- a/src/main/java/de/ph87/tools/user/User.java +++ b/src/main/java/de/ph87/tools/user/User.java @@ -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); + } + } diff --git a/src/main/java/de/ph87/tools/user/UserController.java b/src/main/java/de/ph87/tools/user/UserController.java index 9b830c9..42f08f2 100644 --- a/src/main/java/de/ph87/tools/user/UserController.java +++ b/src/main/java/de/ph87/tools/user/UserController.java @@ -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) { diff --git a/src/main/java/de/ph87/tools/user/UserPrivateDto.java b/src/main/java/de/ph87/tools/user/UserPrivateDto.java index e34b6f0..2d2921f 100644 --- a/src/main/java/de/ph87/tools/user/UserPrivateDto.java +++ b/src/main/java/de/ph87/tools/user/UserPrivateDto.java @@ -29,11 +29,14 @@ public class UserPrivateDto implements IWebSocketMessage { @NonNull private final Set groups; + private final boolean password; + public UserPrivateDto(@NonNull final User user, final @NonNull Set 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; } diff --git a/src/main/java/de/ph87/tools/user/UserPrivateMapper.java b/src/main/java/de/ph87/tools/user/UserPrivateMapper.java index 4d96fa8..b8fd027 100644 --- a/src/main/java/de/ph87/tools/user/UserPrivateMapper.java +++ b/src/main/java/de/ph87/tools/user/UserPrivateMapper.java @@ -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; } } diff --git a/src/main/java/de/ph87/tools/user/UserRepository.java b/src/main/java/de/ph87/tools/user/UserRepository.java index d025266..1d85e2e 100644 --- a/src/main/java/de/ph87/tools/user/UserRepository.java +++ b/src/main/java/de/ph87/tools/user/UserRepository.java @@ -7,6 +7,10 @@ import java.util.Optional; public interface UserRepository extends ListCrudRepository { + boolean existsByName(@NonNull String name); + + Optional findByName(@NonNull String name); + @NonNull Optional findByPublicUuid(@NonNull String publicUuid); diff --git a/src/main/java/de/ph87/tools/user/UserService.java b/src/main/java/de/ph87/tools/user/UserService.java index b8d3592..a526c33 100644 --- a/src/main/java/de/ph87/tools/user/UserService.java +++ b/src/main/java/de/ph87/tools/user/UserService.java @@ -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 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); }