package de.ph87.tools.user; import de.ph87.tools.common.uuid.AbstractUuid; import de.ph87.tools.email.EmailService; import de.ph87.tools.group.GroupMapper; import de.ph87.tools.group.GroupRepository; import de.ph87.tools.user.push.UserPushService; import de.ph87.tools.user.requests.UserLoginRequest; import de.ph87.tools.user.uuid.UserPrivateUuid; import de.ph87.tools.user.uuid.UserPublicUuid; import jakarta.annotation.Nullable; import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletResponse; import lombok.NonNull; 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.function.Consumer; import java.util.regex.Pattern; import static de.ph87.tools.common.EmailHelper.isEmailValid; import static de.ph87.tools.user.uuid.UserPrivateUuidArgumentResolver.USER_UUID_COOKIE_NAME; @Slf4j @Service @Transactional @EnableScheduling @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 PasswordEncoder passwordEncoder; private final GroupRepository groupRepository; private final UserAccessService userAccessService; private final GroupMapper groupMapper; private final UserPushService userPushService; private final EmailService emailService; @Nullable public UserPrivateDto whoAmI(@Nullable final UserPrivateUuid privateUuid, @NonNull final HttpServletResponse response) { if (privateUuid == null) { return null; } final User user = userRepository.findByPrivateUuid(privateUuid.uuid).orElse(null); writeUserUuidCookie(response, user); return UserPrivateDto.orNull(user); } @NonNull public User getUserByPrivateUuidOrElseCreate(@Nullable final UserPrivateUuid privateUuid, @NonNull final HttpServletResponse response) { final User user = Optional .ofNullable(privateUuid) .map(AbstractUuid::getUuid) .map(userRepository::findByPrivateUuid) .filter(Optional::isPresent) .map(Optional::get) .orElseGet(this::createUnchecked); writeUserUuidCookie(response, user); return user; } @NonNull public UserPrivateDto login(@NonNull final UserLoginRequest loginRequest, @NonNull final HttpServletResponse response) { final Optional userOptional = userRepository.findByName(loginRequest.username); if (userOptional.isEmpty()) { log.warn("Login failed: Unknown user: username={}", loginRequest.username); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } final User user = userOptional.get(); if (user.getPassword().isEmpty()) { log.warn("Login failed: No password set: user={}", user); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } if (!user.verifyPassword(passwordEncoder, loginRequest.password)) { log.warn("Login failed: Wrong password: user={}", user); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } user.touch(); writeUserUuidCookie(response, user); log.info("Login successful: user={}", user); return new UserPrivateDto(user); } public void logout(@NonNull final UserPrivateUuid privateUuid, @NonNull final HttpServletResponse response) { modify(privateUuid, user -> { writeUserUuidCookie(response, null); log.info("Logout successful: user={}", user); }); } @NonNull public UserPrivateDto changeName(@NonNull final UserPrivateUuid 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); groupRepository.findAllByUsersContains(user).forEach(groupMapper::push); }); } @NonNull public UserPrivateDto changePassword(@NonNull final UserPrivateUuid 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 UserPrivateDto changeEmail(@NonNull final UserPrivateUuid privateUuid, @NonNull final String email) { return modify(privateUuid, user -> { if (!isEmailValid(email)) { log.warn("Cannot change User email: not valid, user={}", user); throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } if (user.getEmail().equals(email)) { return; } user.setEmail(email); log.info("User email changed: user={}", user); emailService.queueEmailConfirmationEmail(user); }); } public boolean confirmEmail(@NonNull final String emailConfirmation) { final User user = userRepository.findByEmailConfirmation(emailConfirmation).orElse(null); if (user == null) { log.warn("Failed to confirm email: emailConfirmation={}", emailConfirmation); return false; } if (!user.isEmailConfirmed()) { user.setEmailConfirmed(true); log.info("User email confirmed: user={}", user); push(user); } return true; } public void delete(@NonNull final UserPrivateUuid privateUuid, @NonNull final HttpServletResponse response) { final User user = userRepository.findByPrivateUuid(privateUuid.uuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); deleteUnchecked(response, user); } /* CREATE, MODIFY, DELETE ----------------------------------------------------------------------- */ @NonNull private User createUnchecked() { 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 = "%s #%d".formatted(USER_NAME_DEFAULT_BASE, index); while (userRepository.existsByName(name)) { index++; name = "%s #%d".formatted(USER_NAME_DEFAULT_BASE, index); } return name; } @NonNull private UserPrivateDto modify(@NonNull final UserPrivateUuid privateUuid, @NonNull Consumer modifier) { final User user = userAccessService.access(privateUuid); modifier.accept(user); return push(user); } private @NonNull UserPrivateDto push(@NonNull final User user) { final UserPrivateDto privateDto = new UserPrivateDto(user); userPushService.push(user, privateDto); return privateDto; } private void deleteUnchecked(@NonNull final HttpServletResponse response, @NonNull final User user) { userRepository.delete(user); log.info("User DELETED: {}", user); writeUserUuidCookie(response, null); } /* GETTERS & FINDERS ---------------------------------------------------------------------------- */ @NonNull public UserPublicDto getDtoByPublicUuid(@NonNull final UserPublicUuid publicUuid) { return new UserPublicDto(getByPublicUuid(publicUuid)); } @NonNull public User getByPublicUuid(@NonNull final UserPublicUuid publicUuid) { return userRepository.findByPublicUuid(publicUuid.uuid).orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST)); } /* COOKIES -------------------------------------------------------------------------------------- */ private static void writeUserUuidCookie(@NonNull final HttpServletResponse response, @Nullable final User user) { final Cookie cookie = new Cookie(USER_UUID_COOKIE_NAME, ""); if (user != null) { cookie.setValue(user.getPrivateUuid().uuid); } cookie.setMaxAge(PASSWORD_MIN_LENGTH * 365 * 24 * 60 * 60); cookie.setPath("/"); response.addCookie(cookie); } }