UserManagementService.java
package com.taxonomy.security.service;
import com.taxonomy.security.model.AppRole;
import com.taxonomy.security.model.AppUser;
import com.taxonomy.security.repository.RoleRepository;
import com.taxonomy.security.repository.UserRepository;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.Set;
/** Application service for local user and role administration. */
@Service
@Profile("!keycloak")
@ConditionalOnProperty(name = "taxonomy.security.local-users-enabled",
havingValue = "true", matchIfMissing = true)
@Transactional
public class UserManagementService {
public static final int MIN_PASSWORD_LENGTH = 8;
private static final Logger log = LoggerFactory.getLogger(UserManagementService.class);
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final PasswordEncoder passwordEncoder;
@Value("${taxonomy.security.require-password-change:false}")
private boolean requirePasswordChange;
public UserManagementService(UserRepository userRepository,
RoleRepository roleRepository,
PasswordEncoder passwordEncoder) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.passwordEncoder = passwordEncoder;
}
@Transactional(readOnly = true)
public List<Map<String, Object>> listUsers() {
return userRepository.findAll().stream().map(this::toUserMap).toList();
}
@Transactional(readOnly = true)
public Optional<Map<String, Object>> getUser(Long id) {
return userRepository.findById(id).map(this::toUserMap);
}
public Map<String, Object> createUser(Map<String, Object> body, String actor) {
String username = stringValue(body.get("username"));
String password = stringValue(body.get("password"));
String displayName = stringValue(body.get("displayName"));
String email = stringValue(body.get("email"));
List<String> roles = stringList(body.get("roles"));
if (username == null || username.isBlank()) {
throw new ValidationException("Username is required.");
}
validatePassword(password);
if (userRepository.findByUsername(username).isPresent()) {
throw new ConflictException("Username '" + username + "' already exists.");
}
AppUser user = new AppUser();
user.setUsername(username);
user.setPasswordHash(passwordEncoder.encode(password));
user.setEnabled(true);
user.setMustChangePassword(requirePasswordChange);
user.setDisplayName(displayName);
user.setEmail(email);
user.setRoles(resolveRoles(roles));
AppUser saved = userRepository.save(user);
log.info("USER_CREATED user={} roles={} passwordChangeRequired={} by={}", username,
saved.getRoles().stream().map(AppRole::getName).sorted().toList(),
saved.isMustChangePassword(), actor);
return toUserMap(saved);
}
public Map<String, Object> updateUser(Long id, Map<String, Object> body, String actor) {
AppUser user = requireUser(id);
if (body.containsKey("displayName")) {
user.setDisplayName(stringValue(body.get("displayName")));
}
if (body.containsKey("email")) {
user.setEmail(stringValue(body.get("email")));
}
if (body.containsKey("enabled")) {
boolean enabled = Boolean.TRUE.equals(body.get("enabled"));
if (!enabled && isLastAdmin(user)) {
throw new ValidationException("Cannot disable the last admin user.");
}
user.setEnabled(enabled);
}
if (body.containsKey("roles")) {
Set<AppRole> newRoles = resolveRoles(stringList(body.get("roles")));
boolean removingAdmin = hasRole(user, "ROLE_ADMIN")
&& newRoles.stream().noneMatch(role -> "ROLE_ADMIN".equals(role.getName()));
if (removingAdmin && isLastAdmin(user)) {
throw new ValidationException("Cannot remove ADMIN role from the last admin user.");
}
user.setRoles(newRoles);
}
AppUser saved = userRepository.save(user);
log.info("USER_UPDATED user={} by={}", saved.getUsername(), actor);
return toUserMap(saved);
}
public void changePassword(Long id, String newPassword, String actor) {
validatePassword(newPassword);
AppUser user = requireUser(id);
user.setPasswordHash(passwordEncoder.encode(newPassword));
user.setMustChangePassword(requirePasswordChange);
userRepository.save(user);
log.info("USER_PASSWORD_CHANGED user={} temporary={} by={}",
user.getUsername(), user.isMustChangePassword(), actor);
}
public String disableUser(Long id, String actor) {
AppUser user = requireUser(id);
if (isLastAdmin(user)) {
throw new ValidationException("Cannot disable the last admin user.");
}
user.setEnabled(false);
userRepository.save(user);
log.info("USER_DISABLED user={} by={}", user.getUsername(), actor);
return user.getUsername();
}
private AppUser requireUser(Long id) {
return userRepository.findById(id)
.orElseThrow(() -> new NotFoundException("User not found: " + id));
}
private void validatePassword(String password) {
if (password == null || password.length() < MIN_PASSWORD_LENGTH) {
throw new ValidationException(
"Password must be at least " + MIN_PASSWORD_LENGTH + " characters.");
}
}
private boolean isLastAdmin(AppUser user) {
if (!hasRole(user, "ROLE_ADMIN")) {
return false;
}
long enabledAdmins = userRepository.findAll().stream()
.filter(AppUser::isEnabled)
.filter(candidate -> hasRole(candidate, "ROLE_ADMIN"))
.count();
return enabledAdmins <= 1;
}
private boolean hasRole(AppUser user, String roleName) {
return user.getRoles().stream().anyMatch(role -> roleName.equals(role.getName()));
}
private Set<AppRole> resolveRoles(List<String> roleNames) {
if (roleNames == null || roleNames.isEmpty()) {
return roleRepository.findByName("ROLE_USER").map(Set::of).orElse(Set.of());
}
Set<AppRole> roles = new HashSet<>();
for (String roleName : roleNames) {
if (roleName == null || roleName.isBlank()) {
continue;
}
String normalized = roleName.startsWith("ROLE_") ? roleName : "ROLE_" + roleName;
roleRepository.findByName(normalized).ifPresent(roles::add);
}
if (roles.isEmpty()) {
throw new ValidationException("At least one valid role is required.");
}
return roles;
}
private Map<String, Object> toUserMap(AppUser user) {
Map<String, Object> map = new LinkedHashMap<>();
map.put("id", user.getId());
map.put("username", user.getUsername());
map.put("displayName", user.getDisplayName());
map.put("email", user.getEmail());
map.put("enabled", user.isEnabled());
map.put("mustChangePassword", user.isMustChangePassword());
map.put("roles", user.getRoles().stream().map(AppRole::getName).sorted().toList());
return map;
}
private String stringValue(Object value) {
return value instanceof String string ? string : null;
}
private List<String> stringList(Object value) {
if (!(value instanceof List<?> list)) {
return List.of();
}
List<String> values = new ArrayList<>();
for (Object entry : list) {
if (entry instanceof String string) {
values.add(string);
}
}
return values;
}
public static class ValidationException extends RuntimeException {
public ValidationException(String message) { super(message); }
}
public static class ConflictException extends RuntimeException {
public ConflictException(String message) { super(message); }
}
public static class NotFoundException extends RuntimeException {
public NotFoundException(String message) { super(message); }
}
}