All Downloads are FREE. Search and download functionalities are using the official Maven repository.

de.frachtwerk.essencium.backend.service.AbstractUserService Maven / Gradle / Ivy

/*
 * Copyright (C) 2024 Frachtwerk GmbH, Leopoldstraße 7C, 76133 Karlsruhe.
 *
 * This file is part of essencium-backend.
 *
 * essencium-backend is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * essencium-backend is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
 * GNU Lesser General Public License for more details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with essencium-backend. If not, see .
 */

package de.frachtwerk.essencium.backend.service;

import static de.frachtwerk.essencium.backend.model.AbstractBaseUser.USER_ROLE_ATTRIBUTE;

import de.frachtwerk.essencium.backend.model.AbstractBaseUser;
import de.frachtwerk.essencium.backend.model.Role;
import de.frachtwerk.essencium.backend.model.SessionToken;
import de.frachtwerk.essencium.backend.model.UserInfoEssentials;
import de.frachtwerk.essencium.backend.model.dto.PasswordUpdateRequest;
import de.frachtwerk.essencium.backend.model.dto.UserDto;
import de.frachtwerk.essencium.backend.model.exception.NotAllowedException;
import de.frachtwerk.essencium.backend.model.exception.ResourceNotFoundException;
import de.frachtwerk.essencium.backend.model.exception.checked.CheckedMailException;
import de.frachtwerk.essencium.backend.repository.BaseUserRepository;
import jakarta.annotation.Nullable;
import jakarta.annotation.PostConstruct;
import jakarta.validation.Valid;
import jakarta.validation.constraints.NotNull;
import java.io.Serializable;
import java.security.Principal;
import java.security.SecureRandom;
import java.util.*;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.authentication.session.SessionAuthenticationException;

public abstract class AbstractUserService<
        USER extends AbstractBaseUser, ID extends Serializable, USERDTO extends UserDto>
    extends AbstractEntityService implements UserDetailsService {
  private static final Logger LOG = LoggerFactory.getLogger(AbstractUserService.class);
  private static final SecureRandom SECURE_RANDOM = new SecureRandom();

  protected final BaseUserRepository userRepository;
  private final PasswordEncoder passwordEncoder;
  private final UserMailService userMailService;
  protected final RoleService roleService;
  protected final AdminRightRoleCache adminRightRoleCache;
  private final JwtTokenService jwtTokenService;

  @Autowired
  protected AbstractUserService(
      @NotNull final BaseUserRepository userRepository,
      @NotNull final PasswordEncoder passwordEncoder,
      @NotNull final UserMailService userMailService,
      @NotNull final RoleService roleService,
      @NotNull final AdminRightRoleCache adminRightRoleCache,
      @NotNull final JwtTokenService jwtTokenService) {
    super(userRepository);
    this.userRepository = userRepository;
    this.passwordEncoder = passwordEncoder;
    this.userMailService = userMailService;
    this.roleService = roleService;
    this.adminRightRoleCache = adminRightRoleCache;
    this.jwtTokenService = jwtTokenService;
  }

  @PostConstruct
  private void setup() {
    this.roleService.setUserService(this);
    this.jwtTokenService.setUserService(this);
  }

  @Override
  public USER loadUserByUsername(final String username) throws UsernameNotFoundException {
    return userRepository
        .findByEmailIgnoreCase(username)
        .orElseThrow(
            () -> new UsernameNotFoundException(String.format("user '%s' not found", username)));
  }

  public List loadUsersByRole(final String role) throws UsernameNotFoundException {
    return userRepository.findByRoleName(role);
  }

  @NotNull
  public USER getUserFromPrincipal(@Nullable final Principal principal) {
    return principalAsUser(principal);
  }

  public void createResetPasswordToken(@NotNull final String username) {
    var user = loadUserByUsername(username);

    if (!user.hasLocalAuthentication()) {
      throw new NotAllowedException(
          String.format(
              "cannot reset password for users authenticated via '%s'", user.getSource()));
    }

    var token = createAndSaveNewPasswordToken(user);
    try {
      userMailService.sendResetToken(username, token, user.getLocale());
    } catch (CheckedMailException e) {
      LOG.error("Failed to send password token mail due to: {}", e.getLocalizedMessage());
    }
  }

  public void resetPasswordByToken(@NotNull final String token, @NotNull final String newPassword) {
    var userToUpdate =
        userRepository
            .findByPasswordResetToken(token)
            .orElseThrow(() -> new BadCredentialsException("Invalid reset token"));
    setNewPasswordAndClearToken(userToUpdate, newPassword);
  }

  protected void setNewPasswordAndClearToken(
      @NotNull final USER user, @NotNull final String newPassword) {
    sanitizePassword(user, newPassword);
    user.setLoginDisabled(false);
    user.setPasswordResetToken(null);
    userRepository.save(user);
  }

  @NotNull
  protected String createAndSaveNewPasswordToken(@NotNull final USER user) {
    var resetToken = UUID.randomUUID().toString();
    user.setPasswordResetToken(resetToken);
    return userRepository.save(user).getPasswordResetToken();
  }

  protected void sendResetToken(USER user) throws CheckedMailException {
    if (user.hasLocalAuthentication()) {
      var resetToken = user.getPasswordResetToken();
      if (resetToken != null && !resetToken.isEmpty()) {
        userMailService.sendNewUserMail(
            user.getEmail(), user.getPasswordResetToken(), user.getLocale());
      }
    }
  }

  public USER save(USER user) {
    return userRepository.save(user);
  }

  @NotNull
  @Override
  protected  @NotNull USER createPreProcessing(@NotNull E dto) {
    var userToCreate = convertDtoToEntity(dto, Optional.empty());
    userToCreate.setEmail(dto.getEmail() != null ? dto.getEmail().toLowerCase() : null);
    final String userPassword;

    if (userToCreate.hasLocalAuthentication()) {
      if (dto.getPassword() == null || dto.getPassword().isBlank()) {
        var passwordBytes = new byte[128];
        var token = UUID.randomUUID().toString();
        SECURE_RANDOM.nextBytes(passwordBytes);
        var randomPassword = Base64.getEncoder().encodeToString(passwordBytes);
        userToCreate.setPasswordResetToken(token);
        userPassword = randomPassword;
      } else {
        userPassword = dto.getPassword();
      }

      sanitizePassword(userToCreate, userPassword);
    }

    userToCreate.setNonce(generateNonce());
    userToCreate.setRoles(resolveRoles(dto));

    return userToCreate;
  }

  @Override
  protected @NotNull USER createPostProcessing(@NotNull USER saved) {
    try {
      sendResetToken(saved);
    } catch (CheckedMailException e) {
      LOG.error("Failed to send password token mail due to: {}", e.getLocalizedMessage());
    }
    return super.createPostProcessing(saved);
  }

  @Override
  protected  @NotNull USER updatePreProcessing(@NotNull ID id, @NotNull E dto) {
    var existingUser = repository.findById(id);

    Set rolesWithinUpdate = resolveRoles(dto);

    abortWhenRemovingAdminRole(id, rolesWithinUpdate);

    var userToUpdate = super.updatePreProcessing(id, dto);
    userToUpdate.setRoles(rolesWithinUpdate);
    userToUpdate.setSource(
        existingUser
            .map(USER::getSource)
            .orElseThrow(() -> new ResourceNotFoundException("user does not exists")));

    sanitizePassword(userToUpdate, dto.getPassword());

    return userToUpdate;
  }

  @Override
  protected abstract  @NotNull USER convertDtoToEntity(
      @NotNull E entity, Optional currentEntityOpt);

  @Override
  protected @NotNull USER patchPreProcessing(
      @NotNull ID id, @NotNull Map fieldUpdates) {
    final HashMap updates = new HashMap<>(fieldUpdates); // make sure map is mutable
    Optional.ofNullable(fieldUpdates.get(USER_ROLE_ATTRIBUTE))
        .ifPresent(
            o -> {
              if (o instanceof Collection objects) {
                if (objects.isEmpty()) {
                  updates.put(USER_ROLE_ATTRIBUTE, Collections.emptySet());
                } else if (objects.iterator().next() instanceof String) {
                  updates.put(
                      USER_ROLE_ATTRIBUTE,
                      objects.stream()
                          .map(String.class::cast)
                          .map(roleService::getByName)
                          .filter(Objects::nonNull)
                          .collect(Collectors.toSet()));
                } else if (objects.iterator().next() instanceof Map) {
                  updates.put(
                      USER_ROLE_ATTRIBUTE,
                      objects.stream()
                          .map(Map.class::cast)
                          .map(roleMap -> roleMap.get("name"))
                          .filter(String.class::isInstance)
                          .map(String.class::cast)
                          .map(roleService::getByName)
                          .filter(Objects::nonNull)
                          .collect(Collectors.toSet()));
                } else if (objects.iterator().next() instanceof Role) {
                  updates.put(
                      USER_ROLE_ATTRIBUTE,
                      objects.stream()
                          .map(Role.class::cast)
                          .map(role -> roleService.getByName(role.getName()))
                          .filter(Objects::nonNull)
                          .collect(Collectors.toSet()));
                }
              } else {
                throw new IllegalArgumentException("roles must be a collection of strings or maps");
              }
            });

    if (updates.get(USER_ROLE_ATTRIBUTE) != null) {
      abortWhenRemovingAdminRole(id, (Set) updates.get(USER_ROLE_ATTRIBUTE));
    }

    var userToUpdate = super.patchPreProcessing(id, updates);

    sanitizePassword(
        userToUpdate,
        Optional.ofNullable(updates.get("password")).map(Object::toString).orElse(null));

    return userToUpdate;
  }

  protected Set resolveRoles(USERDTO dto) throws ResourceNotFoundException {
    Set roles =
        dto.getRoles().stream()
            .map(roleService::getByName)
            .filter(Objects::nonNull)
            .collect(Collectors.toSet());
    Role defaultRole = roleService.getDefaultRole();
    if (roles.isEmpty() && Objects.nonNull(defaultRole)) {
      roles.add(defaultRole);
    }
    return roles;
  }

  protected void sanitizePassword(@NotNull USER user, @Nullable String newPassword) {
    Optional existingUser =
        Optional.ofNullable(user.getId()).flatMap(userRepository::findById);
    if (newPassword != null
        && !newPassword.isEmpty()
        && existingUser.map(AbstractBaseUser::hasLocalAuthentication).orElse(true)) {
      user.setNonce(generateNonce());
      user.setPassword(passwordEncoder.encode(newPassword));
    } else {
      user.setPassword(existingUser.map(AbstractBaseUser::getPassword).orElse(null));
    }

    if (user.getNonce() == null) {
      user.setNonce(existingUser.map(AbstractBaseUser::getNonce).orElse(null));
    }
  }

  @NotNull
  public USER selfUpdate(@NotNull final USER user, @NotNull final USERDTO updateInformation) {
    user.setFirstName(updateInformation.getFirstName());
    user.setLastName(updateInformation.getLastName());
    user.setPhone(updateInformation.getPhone());
    user.setMobile(updateInformation.getMobile());
    user.setLocale(updateInformation.getLocale());

    return userRepository.save(user);
  }

  @NotNull
  public USER selfUpdate(
      @NotNull final USER user, @NotNull final Map updateFields) {
    final var permittedFields = Set.of("firstName", "lastName", "phone", "mobile", "locale");
    final var filteredFields =
        updateFields.entrySet().stream()
            .filter(e -> permittedFields.contains(e.getKey()))
            .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue));

    return patch(Objects.requireNonNull(user.getId()), filteredFields);
  }

  @NotNull
  public USER updatePassword(
      @NotNull final USER user, @Valid @NotNull final PasswordUpdateRequest updateRequest) {
    if (!user.hasLocalAuthentication()) {
      throw new NotAllowedException(
          String.format(
              "cannot reset password for users authenticated via '%s'", user.getSource()));
    }

    if (!passwordEncoder.matches(updateRequest.verification(), user.getPassword())) {
      throw new BadCredentialsException("mismatching passwords");
    }

    sanitizePassword(user, updateRequest.password());
    return userRepository.save(user);
  }

  public abstract USERDTO getNewUser();

  public static String generateNonce() {
    return UUID.randomUUID().toString().substring(0, 8);
  }

  public USER createDefaultUser(UserInfoEssentials userInfo, String source) {
    Set roles = userInfo.getRoles();

    Role defaultRole = roleService.getDefaultRole();
    if (roles.isEmpty() && Objects.nonNull(defaultRole)) {
      roles.add(defaultRole);
    }

    final USERDTO user = getNewUser();
    user.setEmail(userInfo.getUsername().toLowerCase());
    user.setRoles(roles.stream().map(Role::getName).collect(Collectors.toSet()));
    user.setSource(source);
    user.setLocale(AbstractBaseUser.DEFAULT_LOCALE);
    user.setFirstName(userInfo.getFirstName());
    user.setLastName(userInfo.getLastName());
    return create(user);
  }

  private USER principalAsUser(Principal principal) {
    // due to the way our authentication works we can always assume that, if a user is logged in
    // the principal is always a UsernamePasswordAuthenticationToken and the contained entity is
    // always a User as resolved by this user details service

    if (!(principal instanceof UsernamePasswordAuthenticationToken)
        || !(((UsernamePasswordAuthenticationToken) principal).getPrincipal()
            instanceof AbstractBaseUser)) {
      throw new SessionAuthenticationException("not logged in");
    }
    return (USER) ((UsernamePasswordAuthenticationToken) principal).getPrincipal();
  }

  public List getTokens(USER user) {
    return jwtTokenService.getTokens(user.getUsername());
  }

  public void deleteToken(USER user, @NotNull UUID id) {
    jwtTokenService.deleteToken(user.getUsername(), id);
  }

  @Override
  protected void deletePreProcessing(@NotNull final ID id) {
    super.deletePreProcessing(id);

    userRepository.findById(id).ifPresent(user -> throwNotAllowedExceptionIfNoOtherAdminExists(id));
  }

  private void abortWhenRemovingAdminRole(ID id, Set rolesWithinUpdate) {
    boolean userRemainsAdmin =
        rolesWithinUpdate.stream().anyMatch(adminRightRoleCache.getAdminRoles()::contains);

    if (!userRemainsAdmin) {
      throwNotAllowedExceptionIfNoOtherAdminExists(id);
    }
  }

  private void throwNotAllowedExceptionIfNoOtherAdminExists(ID ignoredUserId) {
    boolean doesOtherAdminExists =
        userRepository.existsAnyAdminBesidesUserWithId(
            adminRightRoleCache.getAdminRoles(), ignoredUserId);

    if (!doesOtherAdminExists) {
      throw new NotAllowedException(
          "You cannot remove the role 'ADMIN' from yourself. That is to ensure there's at least one ADMIN remaining.");
    }
  }
}




© 2015 - 2025 Weber Informatics LLC | Privacy Policy