1
0
Fork 0
mirror of https://github.com/s-frick/effigenix.git synced 2026-03-28 19:40:18 +01:00

refactor(usermanagement): implement code review findings for User Management BC

Address all 18 findings from security code review (5 critical, 7 medium, 6 low):

Domain: make User and Role immutable with wither-pattern, add status transition
guards (ACTIVE->LOCKED, LOCKED->ACTIVE, ACTIVE|LOCKED->INACTIVE, INACTIVE->ACTIVE)

Application: enforce authorization via AuthorizationPort in all use cases, add
input validation, introduce LockUserCommand/UnlockUserCommand/RemoveRoleCommand,
fix audit event on password change failure (K5), use flatMap/mapError chains

Infrastructure: JWT blacklist with TTL and scheduled cleanup, login rate limiting
(5 attempts/15min), configurable CORS, generic error messages, conditional Swagger,
seed data context restriction

Tests: unit tests for all 10 use cases, adapted domain and integration tests
This commit is contained in:
Sebastian Frick 2026-02-19 10:11:20 +01:00
parent a1161cfbad
commit 05878b1ce9
45 changed files with 1989 additions and 2207 deletions

View file

@ -4,6 +4,7 @@ import de.effigenix.infrastructure.config.DatabaseProfileInitializer;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* Main Application Class for Effigenix ERP System.
@ -16,6 +17,7 @@ import org.springframework.scheduling.annotation.EnableAsync;
*/
@SpringBootApplication
@EnableAsync
@EnableScheduling
public class EffigenixApplication {
public static void main(String[] args) {

View file

@ -6,12 +6,11 @@ import de.effigenix.domain.usermanagement.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import static de.effigenix.shared.common.Result.*;
/**
* Use Case: Assign a role to a user.
*/
@ -21,60 +20,61 @@ public class AssignRole {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final AuditLogger auditLogger;
private final AuthorizationPort authPort;
public AssignRole(
UserRepository userRepository,
RoleRepository roleRepository,
AuditLogger auditLogger
AuditLogger auditLogger,
AuthorizationPort authPort
) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.auditLogger = auditLogger;
this.authPort = authPort;
}
public Result<UserError, UserDTO> execute(AssignRoleCommand cmd, ActorId performedBy) {
// 1. Find user
// 0. Authorization
if (!authPort.can(performedBy, UserManagementAction.ROLE_ASSIGN)) {
return Result.failure(new UserError.Unauthorized("Not authorized to assign roles"));
}
// 1. Input validation
if (cmd.userId() == null || cmd.userId().isBlank()) {
return Result.failure(new UserError.InvalidInput("User ID must not be blank"));
}
if (cmd.roleName() == null) {
return Result.failure(new UserError.InvalidInput("Role name must not be null"));
}
UserId userId = UserId.of(cmd.userId());
User user;
switch (userRepository.findById(userId)) {
case Failure<RepositoryError, Optional<User>> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Optional<User>> s -> {
if (s.value().isEmpty()) {
return Result.failure(new UserError.UserNotFound(userId));
}
user = s.value().get();
}
}
return findUser(userId).flatMap(user -> findRoleAndAssign(user, cmd, performedBy));
}
// 2. Find role
Role role;
switch (roleRepository.findByName(cmd.roleName())) {
case Failure<RepositoryError, Optional<Role>> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Optional<Role>> s -> {
if (s.value().isEmpty()) {
return Result.failure(new UserError.RoleNotFound(cmd.roleName()));
}
role = s.value().get();
}
}
private Result<UserError, UserDTO> findRoleAndAssign(User user, AssignRoleCommand cmd, ActorId performedBy) {
return roleRepository.findByName(cmd.roleName())
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.flatMap(optRole -> {
if (optRole.isEmpty()) {
return Result.failure(new UserError.RoleNotFound(cmd.roleName()));
}
Role role = optRole.get();
return user.assignRole(role)
.flatMap(updated -> userRepository.save(updated)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.map(ignored -> {
auditLogger.log(AuditEvent.ROLE_ASSIGNED, updated.id().value(), "Role: " + role.name(), performedBy);
return UserDTO.from(updated);
}));
});
}
// 3. Assign role
switch (user.assignRole(role)) {
case Failure<UserError, Void> f -> { return Result.failure(f.error()); }
case Success<UserError, Void> ignored -> { }
}
switch (userRepository.save(user)) {
case Failure<RepositoryError, Void> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Void> ignored -> { }
}
// 4. Audit log
auditLogger.log(AuditEvent.ROLE_ASSIGNED, userId.value(), "Role: " + role.name(), performedBy);
return Result.success(UserDTO.from(user));
private Result<UserError, User> findUser(UserId userId) {
return userRepository.findById(userId)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.flatMap(opt -> opt
.map(Result::<UserError, User>success)
.orElse(Result.failure(new UserError.UserNotFound(userId))));
}
}

View file

@ -20,6 +20,7 @@ public enum AuditEvent {
ROLE_REMOVED,
PASSWORD_CHANGED,
PASSWORD_CHANGE_FAILED,
PASSWORD_RESET,
LOGIN_SUCCESS,

View file

@ -3,7 +3,6 @@ package de.effigenix.application.usermanagement;
import de.effigenix.application.usermanagement.command.AuthenticateCommand;
import de.effigenix.application.usermanagement.dto.SessionToken;
import de.effigenix.domain.usermanagement.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import org.springframework.transaction.annotation.Transactional;
@ -11,8 +10,6 @@ import org.springframework.transaction.annotation.Transactional;
import java.time.LocalDateTime;
import java.util.Optional;
import static de.effigenix.shared.common.Result.*;
/**
* Use Case: Authenticate a user (login).
*
@ -41,19 +38,18 @@ public class AuthenticateUser {
public Result<UserError, SessionToken> execute(AuthenticateCommand cmd) {
// 1. Find user by username
User user;
switch (userRepository.findByUsername(cmd.username())) {
case Failure<RepositoryError, Optional<User>> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Optional<User>> s -> {
if (s.value().isEmpty()) {
auditLogger.log(AuditEvent.LOGIN_FAILED, "Username not found: " + cmd.username());
return Result.failure(new UserError.InvalidCredentials());
}
user = s.value().get();
}
}
return userRepository.findByUsername(cmd.username())
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.flatMap(optUser -> {
if (optUser.isEmpty()) {
auditLogger.log(AuditEvent.LOGIN_FAILED, "Username not found: " + cmd.username());
return Result.failure(new UserError.InvalidCredentials());
}
return authenticateUser(optUser.get(), cmd);
});
}
private Result<UserError, SessionToken> authenticateUser(User user, AuthenticateCommand cmd) {
// 2. Check user status
if (user.status() == UserStatus.LOCKED) {
auditLogger.log(AuditEvent.LOGIN_BLOCKED, user.id().value(), ActorId.of(user.id().value()));
@ -74,17 +70,13 @@ public class AuthenticateUser {
// 4. Create JWT session
SessionToken token = sessionManager.createSession(user);
// 5. Update last login timestamp
user.updateLastLogin(LocalDateTime.now());
switch (userRepository.save(user)) {
case Failure<RepositoryError, Void> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Void> ignored -> { }
}
// 6. Audit log
auditLogger.log(AuditEvent.LOGIN_SUCCESS, user.id().value(), ActorId.of(user.id().value()));
return Result.success(token);
// 5. Update last login timestamp (immutable)
return user.withLastLogin(LocalDateTime.now())
.flatMap(updated -> userRepository.save(updated)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.map(ignored -> {
auditLogger.log(AuditEvent.LOGIN_SUCCESS, updated.id().value(), ActorId.of(updated.id().value()));
return token;
}));
}
}

View file

@ -2,19 +2,16 @@ package de.effigenix.application.usermanagement;
import de.effigenix.application.usermanagement.command.ChangePasswordCommand;
import de.effigenix.domain.usermanagement.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import static de.effigenix.shared.common.Result.*;
/**
* Use Case: Change user password.
*
* Requires current password verification for security.
* Self-service: users can change their own password without PASSWORD_CHANGE permission.
*/
@Transactional
public class ChangePassword {
@ -22,61 +19,71 @@ public class ChangePassword {
private final UserRepository userRepository;
private final PasswordHasher passwordHasher;
private final AuditLogger auditLogger;
private final AuthorizationPort authPort;
public ChangePassword(
UserRepository userRepository,
PasswordHasher passwordHasher,
AuditLogger auditLogger
AuditLogger auditLogger,
AuthorizationPort authPort
) {
this.userRepository = userRepository;
this.passwordHasher = passwordHasher;
this.auditLogger = auditLogger;
this.authPort = authPort;
}
public Result<UserError, Void> execute(ChangePasswordCommand cmd, ActorId performedBy) {
// 1. Find user
UserId userId = UserId.of(cmd.userId());
User user;
switch (userRepository.findById(userId)) {
case Failure<RepositoryError, Optional<User>> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Optional<User>> s -> {
if (s.value().isEmpty()) {
return Result.failure(new UserError.UserNotFound(userId));
}
user = s.value().get();
}
// 0. Input validation
if (cmd.userId() == null || cmd.userId().isBlank()) {
return Result.failure(new UserError.InvalidInput("User ID must not be blank"));
}
if (cmd.currentPassword() == null || cmd.currentPassword().isBlank()) {
return Result.failure(new UserError.InvalidInput("Current password must not be blank"));
}
if (cmd.newPassword() == null || cmd.newPassword().isBlank()) {
return Result.failure(new UserError.InvalidInput("New password must not be blank"));
}
// 2. Verify current password
// 1. Authorization: self-service allowed, otherwise need PASSWORD_CHANGE permission
boolean isSelfService = performedBy.value().equals(cmd.userId());
if (!isSelfService && !authPort.can(performedBy, UserManagementAction.PASSWORD_CHANGE)) {
return Result.failure(new UserError.Unauthorized("Not authorized to change password for other users"));
}
// 2. Find user
UserId userId = UserId.of(cmd.userId());
return findUser(userId).flatMap(user -> changeUserPassword(user, cmd, performedBy));
}
private Result<UserError, Void> changeUserPassword(User user, ChangePasswordCommand cmd, ActorId performedBy) {
// 3. Verify current password
if (!passwordHasher.verify(cmd.currentPassword(), user.passwordHash())) {
auditLogger.log(AuditEvent.PASSWORD_CHANGED, userId.value(), performedBy);
auditLogger.log(AuditEvent.PASSWORD_CHANGE_FAILED, user.id().value(), performedBy);
return Result.failure(new UserError.InvalidCredentials());
}
// 3. Validate new password
// 4. Validate new password
if (!passwordHasher.isValidPassword(cmd.newPassword())) {
return Result.failure(new UserError.InvalidPassword("Password must be at least 8 characters and contain uppercase, lowercase, digit, and special character"));
}
// 4. Hash new password
// 5. Hash and update (immutable)
PasswordHash newPasswordHash = passwordHasher.hash(cmd.newPassword());
return user.changePassword(newPasswordHash)
.flatMap(updated -> userRepository.save(updated)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.map(ignored -> {
auditLogger.log(AuditEvent.PASSWORD_CHANGED, updated.id().value(), performedBy);
return null;
}));
}
// 5. Update user
switch (user.changePassword(newPasswordHash)) {
case Failure<UserError, Void> f -> { return Result.failure(f.error()); }
case Success<UserError, Void> ignored -> { }
}
switch (userRepository.save(user)) {
case Failure<RepositoryError, Void> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Void> ignored -> { }
}
// 6. Audit log
auditLogger.log(AuditEvent.PASSWORD_CHANGED, user.id().value(), performedBy);
return Result.success(null);
private Result<UserError, User> findUser(UserId userId) {
return userRepository.findById(userId)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.flatMap(opt -> opt
.map(Result::<UserError, User>success)
.orElse(Result.failure(new UserError.UserNotFound(userId))));
}
}

View file

@ -6,20 +6,14 @@ import de.effigenix.domain.usermanagement.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
import java.util.HashSet;
import java.util.Set;
import static de.effigenix.shared.common.Result.*;
/**
* Use Case: Create a new user account.
*
* Transaction Script Pattern (Generic Subdomain):
* - Simple procedural logic
* - No complex domain model
* - Direct repository interaction
*/
@Transactional
public class CreateUser {
@ -28,57 +22,74 @@ public class CreateUser {
private final RoleRepository roleRepository;
private final PasswordHasher passwordHasher;
private final AuditLogger auditLogger;
private final AuthorizationPort authPort;
public CreateUser(
UserRepository userRepository,
RoleRepository roleRepository,
PasswordHasher passwordHasher,
AuditLogger auditLogger
AuditLogger auditLogger,
AuthorizationPort authPort
) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.passwordHasher = passwordHasher;
this.auditLogger = auditLogger;
this.authPort = authPort;
}
public Result<UserError, UserDTO> execute(CreateUserCommand cmd, ActorId performedBy) {
// 1. Validate password
// 0. Authorization
if (!authPort.can(performedBy, UserManagementAction.USER_CREATE)) {
return Result.failure(new UserError.Unauthorized("Not authorized to create users"));
}
// 1. Input validation
if (cmd.username() == null || cmd.username().isBlank()) {
return Result.failure(new UserError.InvalidInput("Username must not be blank"));
}
if (cmd.email() == null || cmd.email().isBlank()) {
return Result.failure(new UserError.InvalidInput("Email must not be blank"));
}
if (cmd.password() == null || cmd.password().isBlank()) {
return Result.failure(new UserError.InvalidInput("Password must not be blank"));
}
if (cmd.roleNames() == null || cmd.roleNames().isEmpty()) {
return Result.failure(new UserError.InvalidInput("At least one role must be specified"));
}
// 2. Validate password
if (!passwordHasher.isValidPassword(cmd.password())) {
return Result.failure(new UserError.InvalidPassword("Password must be at least 8 characters and contain uppercase, lowercase, digit, and special character"));
}
// 2. Check username uniqueness
switch (userRepository.existsByUsername(cmd.username())) {
case Failure<RepositoryError, Boolean> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Boolean> s -> {
if (s.value()) {
return Result.failure(new UserError.UsernameAlreadyExists(cmd.username()));
}
}
}
// 3. Check username uniqueness
return userRepository.existsByUsername(cmd.username())
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.flatMap(exists -> {
if (exists) return Result.failure(new UserError.UsernameAlreadyExists(cmd.username()));
return checkEmailAndCreate(cmd, performedBy);
});
}
// 3. Check email uniqueness
switch (userRepository.existsByEmail(cmd.email())) {
case Failure<RepositoryError, Boolean> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Boolean> s -> {
if (s.value()) {
return Result.failure(new UserError.EmailAlreadyExists(cmd.email()));
}
}
}
private Result<UserError, UserDTO> checkEmailAndCreate(CreateUserCommand cmd, ActorId performedBy) {
return userRepository.existsByEmail(cmd.email())
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.flatMap(exists -> {
if (exists) return Result.failure(new UserError.EmailAlreadyExists(cmd.email()));
return loadRolesAndCreate(cmd, performedBy);
});
}
// 4. Hash password (BCrypt)
private Result<UserError, UserDTO> loadRolesAndCreate(CreateUserCommand cmd, ActorId performedBy) {
PasswordHash passwordHash = passwordHasher.hash(cmd.password());
// 5. Load roles
Set<Role> roles = new HashSet<>();
for (RoleName roleName : cmd.roleNames()) {
switch (roleRepository.findByName(roleName)) {
case Failure<RepositoryError, java.util.Optional<Role>> f ->
case Result.Failure<RepositoryError, java.util.Optional<Role>> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, java.util.Optional<Role>> s -> {
case Result.Success<RepositoryError, java.util.Optional<Role>> s -> {
if (s.value().isEmpty()) {
return Result.failure(new UserError.RoleNotFound(roleName));
}
@ -87,25 +98,12 @@ public class CreateUser {
}
}
// 6. Create user entity (simple entity, not aggregate)
switch (User.create(cmd.username(), cmd.email(), passwordHash, roles, cmd.branchId())) {
case Failure<UserError, User> f ->
{ return Result.failure(f.error()); }
case Success<UserError, User> s -> {
User user = s.value();
// 7. Save
switch (userRepository.save(user)) {
case Failure<RepositoryError, Void> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Void> ignored -> { }
}
// 8. Audit log (HACCP/GoBD compliance)
auditLogger.log(AuditEvent.USER_CREATED, user.id().value(), performedBy);
return Result.success(UserDTO.from(user));
}
}
return User.create(cmd.username(), cmd.email(), passwordHash, roles, cmd.branchId())
.flatMap(user -> userRepository.save(user)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.map(ignored -> {
auditLogger.log(AuditEvent.USER_CREATED, user.id().value(), performedBy);
return UserDTO.from(user);
}));
}
}

View file

@ -2,14 +2,11 @@ package de.effigenix.application.usermanagement;
import de.effigenix.application.usermanagement.dto.UserDTO;
import de.effigenix.domain.usermanagement.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import static de.effigenix.shared.common.Result.*;
/**
* Use Case: Get a single user by ID.
*/
@ -17,20 +14,24 @@ import static de.effigenix.shared.common.Result.*;
public class GetUser {
private final UserRepository userRepository;
private final AuthorizationPort authPort;
public GetUser(UserRepository userRepository) {
public GetUser(UserRepository userRepository, AuthorizationPort authPort) {
this.userRepository = userRepository;
this.authPort = authPort;
}
public Result<UserError, UserDTO> execute(String userIdValue) {
public Result<UserError, UserDTO> execute(String userIdValue, ActorId performedBy) {
// 0. Authorization
if (!authPort.can(performedBy, UserManagementAction.USER_VIEW)) {
return Result.failure(new UserError.Unauthorized("Not authorized to view users"));
}
UserId userId = UserId.of(userIdValue);
return switch (userRepository.findById(userId)) {
case Failure<RepositoryError, Optional<User>> f ->
Result.failure(new UserError.RepositoryFailure(f.error().message()));
case Success<RepositoryError, Optional<User>> s ->
s.value()
.map(user -> Result.<UserError, UserDTO>success(UserDTO.from(user)))
.orElse(Result.failure(new UserError.UserNotFound(userId)));
};
return userRepository.findById(userId)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.flatMap(opt -> opt
.map(user -> Result.<UserError, UserDTO>success(UserDTO.from(user)))
.orElse(Result.failure(new UserError.UserNotFound(userId))));
}
}

View file

@ -1,19 +1,18 @@
package de.effigenix.application.usermanagement;
import de.effigenix.application.usermanagement.dto.UserDTO;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.domain.usermanagement.User;
import de.effigenix.domain.usermanagement.UserError;
import de.effigenix.domain.usermanagement.UserManagementAction;
import de.effigenix.domain.usermanagement.UserRepository;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import de.effigenix.shared.security.BranchId;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.stream.Collectors;
import static de.effigenix.shared.common.Result.*;
/**
* Use Case: List all users (with optional branch filtering).
*/
@ -21,36 +20,36 @@ import static de.effigenix.shared.common.Result.*;
public class ListUsers {
private final UserRepository userRepository;
private final AuthorizationPort authPort;
public ListUsers(UserRepository userRepository) {
public ListUsers(UserRepository userRepository, AuthorizationPort authPort) {
this.userRepository = userRepository;
this.authPort = authPort;
}
/**
* Lists all users (admin view).
*/
public Result<UserError, List<UserDTO>> execute() {
return switch (userRepository.findAll()) {
case Failure<RepositoryError, List<User>> f ->
Result.failure(new UserError.RepositoryFailure(f.error().message()));
case Success<RepositoryError, List<User>> s ->
Result.success(s.value().stream()
.map(UserDTO::from)
.collect(Collectors.toList()));
};
public Result<UserError, List<UserDTO>> execute(ActorId performedBy) {
if (!authPort.can(performedBy, UserManagementAction.USER_LIST)) {
return Result.failure(new UserError.Unauthorized("Not authorized to list users"));
}
return userRepository.findAll()
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.map(users -> users.stream().map(UserDTO::from).collect(Collectors.toList()));
}
/**
* Lists users for a specific branch (filtered view).
*/
public Result<UserError, List<UserDTO>> executeForBranch(BranchId branchId) {
return switch (userRepository.findByBranchId(branchId.value())) {
case Failure<RepositoryError, List<User>> f ->
Result.failure(new UserError.RepositoryFailure(f.error().message()));
case Success<RepositoryError, List<User>> s ->
Result.success(s.value().stream()
.map(UserDTO::from)
.collect(Collectors.toList()));
};
public Result<UserError, List<UserDTO>> executeForBranch(BranchId branchId, ActorId performedBy) {
if (!authPort.can(performedBy, UserManagementAction.USER_LIST)) {
return Result.failure(new UserError.Unauthorized("Not authorized to list users"));
}
return userRepository.findByBranchId(branchId.value())
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.map(users -> users.stream().map(UserDTO::from).collect(Collectors.toList()));
}
}

View file

@ -1,16 +1,13 @@
package de.effigenix.application.usermanagement;
import de.effigenix.application.usermanagement.command.LockUserCommand;
import de.effigenix.application.usermanagement.dto.UserDTO;
import de.effigenix.domain.usermanagement.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import static de.effigenix.shared.common.Result.*;
/**
* Use Case: Lock a user account (prevent login).
*/
@ -19,36 +16,41 @@ public class LockUser {
private final UserRepository userRepository;
private final AuditLogger auditLogger;
private final AuthorizationPort authPort;
public LockUser(UserRepository userRepository, AuditLogger auditLogger) {
public LockUser(UserRepository userRepository, AuditLogger auditLogger, AuthorizationPort authPort) {
this.userRepository = userRepository;
this.auditLogger = auditLogger;
this.authPort = authPort;
}
public Result<UserError, UserDTO> execute(String userIdValue, ActorId performedBy) {
UserId userId = UserId.of(userIdValue);
User user;
switch (userRepository.findById(userId)) {
case Failure<RepositoryError, Optional<User>> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Optional<User>> s -> {
if (s.value().isEmpty()) {
return Result.failure(new UserError.UserNotFound(userId));
}
user = s.value().get();
}
public Result<UserError, UserDTO> execute(LockUserCommand cmd, ActorId performedBy) {
// 0. Authorization
if (!authPort.can(performedBy, UserManagementAction.USER_LOCK)) {
return Result.failure(new UserError.Unauthorized("Not authorized to lock users"));
}
user.lock();
switch (userRepository.save(user)) {
case Failure<RepositoryError, Void> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Void> ignored -> { }
// 1. Input validation
if (cmd.userId() == null || cmd.userId().isBlank()) {
return Result.failure(new UserError.InvalidInput("User ID must not be blank"));
}
auditLogger.log(AuditEvent.USER_LOCKED, user.id().value(), performedBy);
UserId userId = UserId.of(cmd.userId());
return findUser(userId)
.flatMap(User::lock)
.flatMap(updated -> userRepository.save(updated)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.map(ignored -> {
auditLogger.log(AuditEvent.USER_LOCKED, updated.id().value(), performedBy);
return UserDTO.from(updated);
}));
}
return Result.success(UserDTO.from(user));
private Result<UserError, User> findUser(UserId userId) {
return userRepository.findById(userId)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.flatMap(opt -> opt
.map(Result::<UserError, User>success)
.orElse(Result.failure(new UserError.UserNotFound(userId))));
}
}

View file

@ -1,21 +1,15 @@
package de.effigenix.application.usermanagement;
import de.effigenix.application.usermanagement.command.RemoveRoleCommand;
import de.effigenix.application.usermanagement.dto.UserDTO;
import de.effigenix.domain.usermanagement.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import static de.effigenix.shared.common.Result.*;
/**
* Use Case: Remove a role from a user.
*
* Allows administrators to revoke roles from users.
* Role removal is immediate and affects user's permissions.
*/
@Transactional
public class RemoveRole {
@ -23,65 +17,61 @@ public class RemoveRole {
private final UserRepository userRepository;
private final RoleRepository roleRepository;
private final AuditLogger auditLogger;
private final AuthorizationPort authPort;
public RemoveRole(
UserRepository userRepository,
RoleRepository roleRepository,
AuditLogger auditLogger
AuditLogger auditLogger,
AuthorizationPort authPort
) {
this.userRepository = userRepository;
this.roleRepository = roleRepository;
this.auditLogger = auditLogger;
this.authPort = authPort;
}
/**
* Removes a role from a user.
*
* @param userId User ID
* @param roleName Role name to remove
* @param performedBy Actor performing the action
* @return Result with UserDTO or UserError
*/
public Result<UserError, UserDTO> execute(String userId, RoleName roleName, ActorId performedBy) {
// 1. Find user
UserId userIdObj = UserId.of(userId);
User user;
switch (userRepository.findById(userIdObj)) {
case Failure<RepositoryError, Optional<User>> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Optional<User>> s -> {
if (s.value().isEmpty()) {
return Result.failure(new UserError.UserNotFound(userIdObj));
}
user = s.value().get();
}
public Result<UserError, UserDTO> execute(RemoveRoleCommand cmd, ActorId performedBy) {
// 0. Authorization
if (!authPort.can(performedBy, UserManagementAction.ROLE_REMOVE)) {
return Result.failure(new UserError.Unauthorized("Not authorized to remove roles"));
}
// 2. Find role
Role role;
switch (roleRepository.findByName(roleName)) {
case Failure<RepositoryError, Optional<Role>> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Optional<Role>> s -> {
if (s.value().isEmpty()) {
return Result.failure(new UserError.RoleNotFound(roleName));
}
role = s.value().get();
}
// 1. Input validation
if (cmd.userId() == null || cmd.userId().isBlank()) {
return Result.failure(new UserError.InvalidInput("User ID must not be blank"));
}
if (cmd.roleName() == null) {
return Result.failure(new UserError.InvalidInput("Role name must not be null"));
}
// 3. Remove role
user.removeRole(role);
UserId userId = UserId.of(cmd.userId());
return findUser(userId).flatMap(user -> findRoleAndRemove(user, cmd, performedBy));
}
switch (userRepository.save(user)) {
case Failure<RepositoryError, Void> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Void> ignored -> { }
}
private Result<UserError, UserDTO> findRoleAndRemove(User user, RemoveRoleCommand cmd, ActorId performedBy) {
return roleRepository.findByName(cmd.roleName())
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.flatMap(optRole -> {
if (optRole.isEmpty()) {
return Result.failure(new UserError.RoleNotFound(cmd.roleName()));
}
Role role = optRole.get();
return user.removeRole(role)
.flatMap(updated -> userRepository.save(updated)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.map(ignored -> {
auditLogger.log(AuditEvent.ROLE_REMOVED, updated.id().value(), "Role: " + role.name(), performedBy);
return UserDTO.from(updated);
}));
});
}
// 4. Audit log
auditLogger.log(AuditEvent.ROLE_REMOVED, userId, "Role: " + roleName, performedBy);
return Result.success(UserDTO.from(user));
private Result<UserError, User> findUser(UserId userId) {
return userRepository.findById(userId)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.flatMap(opt -> opt
.map(Result::<UserError, User>success)
.orElse(Result.failure(new UserError.UserNotFound(userId))));
}
}

View file

@ -1,16 +1,13 @@
package de.effigenix.application.usermanagement;
import de.effigenix.application.usermanagement.command.UnlockUserCommand;
import de.effigenix.application.usermanagement.dto.UserDTO;
import de.effigenix.domain.usermanagement.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import static de.effigenix.shared.common.Result.*;
/**
* Use Case: Unlock a user account (allow login).
*/
@ -19,36 +16,41 @@ public class UnlockUser {
private final UserRepository userRepository;
private final AuditLogger auditLogger;
private final AuthorizationPort authPort;
public UnlockUser(UserRepository userRepository, AuditLogger auditLogger) {
public UnlockUser(UserRepository userRepository, AuditLogger auditLogger, AuthorizationPort authPort) {
this.userRepository = userRepository;
this.auditLogger = auditLogger;
this.authPort = authPort;
}
public Result<UserError, UserDTO> execute(String userIdValue, ActorId performedBy) {
UserId userId = UserId.of(userIdValue);
User user;
switch (userRepository.findById(userId)) {
case Failure<RepositoryError, Optional<User>> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Optional<User>> s -> {
if (s.value().isEmpty()) {
return Result.failure(new UserError.UserNotFound(userId));
}
user = s.value().get();
}
public Result<UserError, UserDTO> execute(UnlockUserCommand cmd, ActorId performedBy) {
// 0. Authorization
if (!authPort.can(performedBy, UserManagementAction.USER_UNLOCK)) {
return Result.failure(new UserError.Unauthorized("Not authorized to unlock users"));
}
user.unlock();
switch (userRepository.save(user)) {
case Failure<RepositoryError, Void> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Void> ignored -> { }
// 1. Input validation
if (cmd.userId() == null || cmd.userId().isBlank()) {
return Result.failure(new UserError.InvalidInput("User ID must not be blank"));
}
auditLogger.log(AuditEvent.USER_UNLOCKED, user.id().value(), performedBy);
UserId userId = UserId.of(cmd.userId());
return findUser(userId)
.flatMap(User::unlock)
.flatMap(updated -> userRepository.save(updated)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.map(ignored -> {
auditLogger.log(AuditEvent.USER_UNLOCKED, updated.id().value(), performedBy);
return UserDTO.from(updated);
}));
}
return Result.success(UserDTO.from(user));
private Result<UserError, User> findUser(UserId userId) {
return userRepository.findById(userId)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.flatMap(opt -> opt
.map(Result::<UserError, User>success)
.orElse(Result.failure(new UserError.UserNotFound(userId))));
}
}

View file

@ -3,15 +3,11 @@ package de.effigenix.application.usermanagement;
import de.effigenix.application.usermanagement.command.UpdateUserCommand;
import de.effigenix.application.usermanagement.dto.UserDTO;
import de.effigenix.domain.usermanagement.*;
import de.effigenix.shared.common.RepositoryError;
import de.effigenix.shared.common.Result;
import de.effigenix.shared.security.ActorId;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.transaction.annotation.Transactional;
import java.util.Optional;
import static de.effigenix.shared.common.Result.*;
/**
* Use Case: Update user details (email, branch).
*/
@ -20,61 +16,57 @@ public class UpdateUser {
private final UserRepository userRepository;
private final AuditLogger auditLogger;
private final AuthorizationPort authPort;
public UpdateUser(UserRepository userRepository, AuditLogger auditLogger) {
public UpdateUser(UserRepository userRepository, AuditLogger auditLogger, AuthorizationPort authPort) {
this.userRepository = userRepository;
this.auditLogger = auditLogger;
this.authPort = authPort;
}
public Result<UserError, UserDTO> execute(UpdateUserCommand cmd, ActorId performedBy) {
// 1. Find user
UserId userId = UserId.of(cmd.userId());
User user;
switch (userRepository.findById(userId)) {
case Failure<RepositoryError, Optional<User>> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Optional<User>> s -> {
if (s.value().isEmpty()) {
return Result.failure(new UserError.UserNotFound(userId));
}
user = s.value().get();
}
// 0. Authorization
if (!authPort.can(performedBy, UserManagementAction.USER_UPDATE)) {
return Result.failure(new UserError.Unauthorized("Not authorized to update users"));
}
// 2. Update email if provided
UserId userId = UserId.of(cmd.userId());
return findUser(userId).flatMap(user -> updateUser(user, cmd, performedBy));
}
private Result<UserError, UserDTO> updateUser(User user, UpdateUserCommand cmd, ActorId performedBy) {
Result<UserError, User> current = Result.success(user);
// Update email if provided
if (cmd.email() != null && !cmd.email().equals(user.email())) {
// Check email uniqueness
switch (userRepository.existsByEmail(cmd.email())) {
case Failure<RepositoryError, Boolean> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Boolean> s -> {
if (s.value()) {
return Result.failure(new UserError.EmailAlreadyExists(cmd.email()));
}
}
}
switch (user.updateEmail(cmd.email())) {
case Failure<UserError, Void> f -> { return Result.failure(f.error()); }
case Success<UserError, Void> ignored -> { }
var emailExists = userRepository.existsByEmail(cmd.email())
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()));
if (emailExists.isFailure()) return Result.failure(emailExists.unsafeGetError());
if (emailExists.unsafeGetValue()) {
return Result.failure(new UserError.EmailAlreadyExists(cmd.email()));
}
current = current.flatMap(u -> u.updateEmail(cmd.email()));
}
// 3. Update branch if provided
// Update branch if provided
if (cmd.branchId() != null && !cmd.branchId().equals(user.branchId())) {
user.updateBranch(cmd.branchId());
current = current.flatMap(u -> u.updateBranch(cmd.branchId()));
}
// 4. Save
switch (userRepository.save(user)) {
case Failure<RepositoryError, Void> f ->
{ return Result.failure(new UserError.RepositoryFailure(f.error().message())); }
case Success<RepositoryError, Void> ignored -> { }
}
return current.flatMap(updated -> userRepository.save(updated)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.map(ignored -> {
auditLogger.log(AuditEvent.USER_UPDATED, updated.id().value(), performedBy);
return UserDTO.from(updated);
}));
}
// 5. Audit log
auditLogger.log(AuditEvent.USER_UPDATED, user.id().value(), performedBy);
return Result.success(UserDTO.from(user));
private Result<UserError, User> findUser(UserId userId) {
return userRepository.findById(userId)
.mapError(err -> (UserError) new UserError.RepositoryFailure(err.message()))
.flatMap(opt -> opt
.map(Result::<UserError, User>success)
.orElse(Result.failure(new UserError.UserNotFound(userId))));
}
}

View file

@ -0,0 +1,9 @@
package de.effigenix.application.usermanagement.command;
/**
* Command for locking a user account.
*/
public record LockUserCommand(
String userId
) {
}

View file

@ -0,0 +1,12 @@
package de.effigenix.application.usermanagement.command;
import de.effigenix.domain.usermanagement.RoleName;
/**
* Command for removing a role from a user.
*/
public record RemoveRoleCommand(
String userId,
RoleName roleName
) {
}

View file

@ -0,0 +1,9 @@
package de.effigenix.application.usermanagement.command;
/**
* Command for unlocking a user account.
*/
public record UnlockUserCommand(
String userId
) {
}

View file

@ -11,14 +11,15 @@ import java.util.Set;
*
* Roles are predefined and loaded from seed data.
* Each Role has a set of Permissions that grant access to specific actions.
* Immutable: all business methods return new instances via Result.
*
* Invariant: id is non-null, name is non-null
*/
public class Role {
private final RoleId id;
private final RoleName name;
private Set<Permission> permissions;
private String description;
private final Set<Permission> permissions;
private final String description;
private Role(
RoleId id,
@ -28,7 +29,7 @@ public class Role {
) {
this.id = id;
this.name = name;
this.permissions = permissions != null ? new HashSet<>(permissions) : new HashSet<>();
this.permissions = permissions != null ? Set.copyOf(permissions) : Set.of();
this.description = description;
}
@ -63,24 +64,32 @@ public class Role {
return new Role(id, name, permissions, description);
}
// ==================== Business Methods ====================
// ==================== Business Methods (Wither-Pattern) ====================
public Result<UserError, Void> addPermission(Permission permission) {
public Result<UserError, Role> addPermission(Permission permission) {
if (permission == null) {
return Result.failure(new UserError.NullRole());
return Result.failure(new UserError.NullPermission());
}
this.permissions.add(permission);
return Result.success(null);
Set<Permission> newPermissions = new HashSet<>(permissions);
newPermissions.add(permission);
return Result.success(new Role(id, name, newPermissions, description));
}
public void removePermission(Permission permission) {
this.permissions.remove(permission);
public Result<UserError, Role> removePermission(Permission permission) {
if (permission == null) {
return Result.failure(new UserError.NullPermission());
}
Set<Permission> newPermissions = new HashSet<>(permissions);
newPermissions.remove(permission);
return Result.success(new Role(id, name, newPermissions, description));
}
public void updateDescription(String newDescription) {
this.description = newDescription;
public Result<UserError, Role> updateDescription(String newDescription) {
return Result.success(new Role(id, name, permissions, newDescription));
}
// ==================== Query Methods ====================
public boolean hasPermission(Permission permission) {
return permissions.contains(permission);
}

View file

@ -11,24 +11,31 @@ import java.util.Set;
* User Entity (Simple Entity, NOT an Aggregate).
*
* Generic Subdomain Minimal DDD:
* - Immutable: All business methods return new instances via Result
* - Validation via Result type in factory method
* - NO complex business logic
* - Status transition guards enforce valid state machine
* - NO domain events
*
* Invariant: username is non-blank, email is valid, passwordHash is non-null, status is non-null
* Uniqueness (username, email) is enforced in the Application Layer (repository concern)
*
* Status transitions:
* - ACTIVE LOCKED (lock)
* - LOCKED ACTIVE (unlock)
* - ACTIVE | LOCKED INACTIVE (deactivate)
* - INACTIVE ACTIVE (activate)
*/
public class User {
private final UserId id;
private String username;
private String email;
private PasswordHash passwordHash;
private Set<Role> roles;
private String branchId;
private UserStatus status;
private LocalDateTime createdAt;
private LocalDateTime lastLogin;
private final String username;
private final String email;
private final PasswordHash passwordHash;
private final Set<Role> roles;
private final String branchId;
private final UserStatus status;
private final LocalDateTime createdAt;
private final LocalDateTime lastLogin;
// Invariant: all fields validated via create() or reconstitute()
private User(
UserId id,
String username,
@ -44,7 +51,7 @@ public class User {
this.username = username;
this.email = email;
this.passwordHash = passwordHash;
this.roles = roles != null ? new HashSet<>(roles) : new HashSet<>();
this.roles = roles != null ? Set.copyOf(roles) : Set.of();
this.branchId = branchId;
this.status = status;
this.createdAt = createdAt != null ? createdAt : LocalDateTime.now();
@ -101,63 +108,81 @@ public class User {
return new User(id, username, email, passwordHash, roles, branchId, status, createdAt, lastLogin);
}
// ==================== Business Methods ====================
// ==================== Business Methods (Wither-Pattern) ====================
public void updateLastLogin(LocalDateTime timestamp) {
this.lastLogin = timestamp;
public Result<UserError, User> withLastLogin(LocalDateTime timestamp) {
return Result.success(new User(id, username, email, passwordHash, roles, branchId, status, createdAt, timestamp));
}
public Result<UserError, Void> changePassword(PasswordHash newPasswordHash) {
public Result<UserError, User> changePassword(PasswordHash newPasswordHash) {
if (newPasswordHash == null) {
return Result.failure(new UserError.NullPasswordHash());
}
this.passwordHash = newPasswordHash;
return Result.success(null);
return Result.success(new User(id, username, email, newPasswordHash, roles, branchId, status, createdAt, lastLogin));
}
public void lock() {
this.status = UserStatus.LOCKED;
public Result<UserError, User> lock() {
if (status != UserStatus.ACTIVE) {
return Result.failure(new UserError.InvalidStatusTransition(status, UserStatus.LOCKED));
}
return Result.success(new User(id, username, email, passwordHash, roles, branchId, UserStatus.LOCKED, createdAt, lastLogin));
}
public void unlock() {
this.status = UserStatus.ACTIVE;
public Result<UserError, User> unlock() {
if (status != UserStatus.LOCKED) {
return Result.failure(new UserError.InvalidStatusTransition(status, UserStatus.ACTIVE));
}
return Result.success(new User(id, username, email, passwordHash, roles, branchId, UserStatus.ACTIVE, createdAt, lastLogin));
}
public void deactivate() {
this.status = UserStatus.INACTIVE;
public Result<UserError, User> deactivate() {
if (status != UserStatus.ACTIVE && status != UserStatus.LOCKED) {
return Result.failure(new UserError.InvalidStatusTransition(status, UserStatus.INACTIVE));
}
return Result.success(new User(id, username, email, passwordHash, roles, branchId, UserStatus.INACTIVE, createdAt, lastLogin));
}
public void activate() {
this.status = UserStatus.ACTIVE;
public Result<UserError, User> activate() {
if (status != UserStatus.INACTIVE) {
return Result.failure(new UserError.InvalidStatusTransition(status, UserStatus.ACTIVE));
}
return Result.success(new User(id, username, email, passwordHash, roles, branchId, UserStatus.ACTIVE, createdAt, lastLogin));
}
public Result<UserError, Void> assignRole(Role role) {
public Result<UserError, User> assignRole(Role role) {
if (role == null) {
return Result.failure(new UserError.NullRole());
}
this.roles.add(role);
return Result.success(null);
Set<Role> newRoles = new HashSet<>(roles);
newRoles.add(role);
return Result.success(new User(id, username, email, passwordHash, newRoles, branchId, status, createdAt, lastLogin));
}
public void removeRole(Role role) {
this.roles.remove(role);
public Result<UserError, User> removeRole(Role role) {
if (role == null) {
return Result.failure(new UserError.NullRole());
}
Set<Role> newRoles = new HashSet<>(roles);
newRoles.remove(role);
return Result.success(new User(id, username, email, passwordHash, newRoles, branchId, status, createdAt, lastLogin));
}
public Result<UserError, Void> updateEmail(String newEmail) {
public Result<UserError, User> updateEmail(String newEmail) {
if (newEmail == null || newEmail.isBlank()) {
return Result.failure(new UserError.InvalidEmail("null or empty"));
}
if (!isValidEmail(newEmail)) {
return Result.failure(new UserError.InvalidEmail(newEmail));
}
this.email = newEmail;
return Result.success(null);
return Result.success(new User(id, username, newEmail, passwordHash, roles, branchId, status, createdAt, lastLogin));
}
public void updateBranch(String newBranchId) {
this.branchId = newBranchId;
public Result<UserError, User> updateBranch(String newBranchId) {
return Result.success(new User(id, username, email, passwordHash, roles, newBranchId, status, createdAt, lastLogin));
}
// ==================== Query Methods ====================
public boolean isActive() {
return status == UserStatus.ACTIVE;
}

View file

@ -73,6 +73,20 @@ public sealed interface UserError {
@Override public String message() { return "Role cannot be null"; }
}
record NullPermission() implements UserError {
@Override public String code() { return "USER_NULL_PERMISSION"; }
@Override public String message() { return "Permission cannot be null"; }
}
record InvalidStatusTransition(UserStatus from, UserStatus to) implements UserError {
@Override public String code() { return "USER_INVALID_STATUS_TRANSITION"; }
@Override public String message() { return "Invalid status transition from " + from + " to " + to; }
}
record InvalidInput(String message) implements UserError {
@Override public String code() { return "INVALID_INPUT"; }
}
record RepositoryFailure(String message) implements UserError {
@Override public String code() { return "REPOSITORY_ERROR"; }
}

View file

@ -0,0 +1,19 @@
package de.effigenix.domain.usermanagement;
import de.effigenix.shared.security.Action;
/**
* Domain actions for the User Management Bounded Context.
* Used with AuthorizationPort for type-safe authorization checks.
*/
public enum UserManagementAction implements Action {
USER_CREATE,
USER_UPDATE,
USER_LOCK,
USER_UNLOCK,
USER_VIEW,
USER_LIST,
ROLE_ASSIGN,
ROLE_REMOVE,
PASSWORD_CHANGE
}

View file

@ -3,6 +3,7 @@ package de.effigenix.infrastructure.config;
import de.effigenix.application.usermanagement.*;
import de.effigenix.domain.usermanagement.RoleRepository;
import de.effigenix.domain.usermanagement.UserRepository;
import de.effigenix.shared.security.AuthorizationPort;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -20,9 +21,10 @@ public class UseCaseConfiguration {
UserRepository userRepository,
RoleRepository roleRepository,
PasswordHasher passwordHasher,
AuditLogger auditLogger
AuditLogger auditLogger,
AuthorizationPort authPort
) {
return new CreateUser(userRepository, roleRepository, passwordHasher, auditLogger);
return new CreateUser(userRepository, roleRepository, passwordHasher, auditLogger, authPort);
}
@Bean
@ -39,60 +41,66 @@ public class UseCaseConfiguration {
public ChangePassword changePassword(
UserRepository userRepository,
PasswordHasher passwordHasher,
AuditLogger auditLogger
AuditLogger auditLogger,
AuthorizationPort authPort
) {
return new ChangePassword(userRepository, passwordHasher, auditLogger);
return new ChangePassword(userRepository, passwordHasher, auditLogger, authPort);
}
@Bean
public UpdateUser updateUser(
UserRepository userRepository,
AuditLogger auditLogger
AuditLogger auditLogger,
AuthorizationPort authPort
) {
return new UpdateUser(userRepository, auditLogger);
return new UpdateUser(userRepository, auditLogger, authPort);
}
@Bean
public AssignRole assignRole(
UserRepository userRepository,
RoleRepository roleRepository,
AuditLogger auditLogger
AuditLogger auditLogger,
AuthorizationPort authPort
) {
return new AssignRole(userRepository, roleRepository, auditLogger);
return new AssignRole(userRepository, roleRepository, auditLogger, authPort);
}
@Bean
public RemoveRole removeRole(
UserRepository userRepository,
RoleRepository roleRepository,
AuditLogger auditLogger
AuditLogger auditLogger,
AuthorizationPort authPort
) {
return new RemoveRole(userRepository, roleRepository, auditLogger);
return new RemoveRole(userRepository, roleRepository, auditLogger, authPort);
}
@Bean
public LockUser lockUser(
UserRepository userRepository,
AuditLogger auditLogger
AuditLogger auditLogger,
AuthorizationPort authPort
) {
return new LockUser(userRepository, auditLogger);
return new LockUser(userRepository, auditLogger, authPort);
}
@Bean
public UnlockUser unlockUser(
UserRepository userRepository,
AuditLogger auditLogger
AuditLogger auditLogger,
AuthorizationPort authPort
) {
return new UnlockUser(userRepository, auditLogger);
return new UnlockUser(userRepository, auditLogger, authPort);
}
@Bean
public GetUser getUser(UserRepository userRepository) {
return new GetUser(userRepository);
public GetUser getUser(UserRepository userRepository, AuthorizationPort authPort) {
return new GetUser(userRepository, authPort);
}
@Bean
public ListUsers listUsers(UserRepository userRepository) {
return new ListUsers(userRepository);
public ListUsers listUsers(UserRepository userRepository, AuthorizationPort authPort) {
return new ListUsers(userRepository, authPort);
}
}

View file

@ -8,6 +8,7 @@ import de.effigenix.domain.production.ProductionAction;
import de.effigenix.domain.quality.QualityAction;
import de.effigenix.domain.sales.SalesAction;
import de.effigenix.domain.usermanagement.Permission;
import de.effigenix.domain.usermanagement.UserManagementAction;
import de.effigenix.shared.security.Action;
import org.springframework.stereotype.Component;
@ -71,6 +72,8 @@ public class ActionToPermissionMapper {
return mapLabelingAction(labelingAction);
} else if (action instanceof FilialesAction filialesAction) {
return mapFilialesAction(filialesAction);
} else if (action instanceof UserManagementAction userMgmtAction) {
return mapUserManagementAction(userMgmtAction);
} else {
throw new IllegalArgumentException("Unknown action type: " + action.getClass().getName());
}
@ -157,4 +160,18 @@ public class ActionToPermissionMapper {
case BRANCH_DELETE -> Permission.BRANCH_DELETE;
};
}
private Permission mapUserManagementAction(UserManagementAction action) {
return switch (action) {
case USER_CREATE -> Permission.USER_WRITE;
case USER_UPDATE -> Permission.USER_WRITE;
case USER_LOCK -> Permission.USER_LOCK;
case USER_UNLOCK -> Permission.USER_UNLOCK;
case USER_VIEW -> Permission.USER_READ;
case USER_LIST -> Permission.USER_READ;
case ROLE_ASSIGN -> Permission.ROLE_ASSIGN;
case ROLE_REMOVE -> Permission.ROLE_REMOVE;
case PASSWORD_CHANGE -> Permission.USER_WRITE;
};
}
}

View file

@ -4,122 +4,65 @@ import de.effigenix.application.usermanagement.SessionManager;
import de.effigenix.application.usermanagement.dto.SessionToken;
import de.effigenix.domain.usermanagement.User;
import de.effigenix.domain.usermanagement.UserId;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Service;
import java.util.Set;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
/**
* JWT-based Session Manager implementation.
*
* Implements the SessionManager port from Application Layer using JWT tokens.
* Stateless session management - no server-side session storage.
*
* Token Invalidation:
* - Access tokens are stateless (cannot be truly invalidated)
* - For MVP: Use in-memory blacklist for logged-out tokens
* - For Production: Consider Redis-based blacklist or short token expiration
*
* Refresh Tokens:
* - For MVP: Simple refresh token validation
* - For Production: Consider refresh token rotation and family tracking
*
* Infrastructure Layer Implements Application Layer port
* - Uses in-memory blacklist with TTL (token expiration time)
* - Expired tokens are automatically cleaned up every 5 minutes
* - For production: consider Redis-based blacklist
*/
@Service
public class JwtSessionManager implements SessionManager {
private final JwtTokenProvider tokenProvider;
// In-memory token blacklist (for MVP - replace with Redis in production)
private final Set<String> tokenBlacklist = ConcurrentHashMap.newKeySet();
// Token expiration instant (for TTL-based cleanup)
private final ConcurrentHashMap<String, Instant> tokenBlacklist = new ConcurrentHashMap<>();
public JwtSessionManager(JwtTokenProvider tokenProvider) {
this.tokenProvider = tokenProvider;
}
/**
* Creates a new session (JWT token) for a user.
*
* Generates both access and refresh tokens:
* - Access token: Short-lived (8h), contains user info and permissions
* - Refresh token: Long-lived (7d), used to obtain new access tokens
*
* @param user User to create session for
* @return Session token (access token + refresh token)
*/
@Override
public SessionToken createSession(User user) {
if (user == null) {
throw new IllegalArgumentException("User cannot be null");
}
// Generate access token with user information and permissions
String accessToken = tokenProvider.generateAccessToken(
user.id(),
user.username(),
user.getAllPermissions(),
user.branchId()
user.id(), user.username(), user.getAllPermissions(), user.branchId()
);
// Generate refresh token for session renewal
String refreshToken = tokenProvider.generateRefreshToken(user.id());
return SessionToken.create(
accessToken,
tokenProvider.getAccessTokenExpiration(),
refreshToken
);
return SessionToken.create(accessToken, tokenProvider.getAccessTokenExpiration(), refreshToken);
}
/**
* Validates a JWT token and extracts the user ID.
*
* Checks:
* 1. Token signature is valid
* 2. Token is not expired
* 3. Token is not blacklisted (logged out)
*
* @param token JWT access token
* @return UserId if valid
* @throws RuntimeException if token is invalid, expired, or blacklisted
*/
@Override
public UserId validateToken(String token) {
if (token == null || token.isBlank()) {
throw new IllegalArgumentException("Token cannot be null or empty");
}
// Check if token is blacklisted (user logged out)
if (tokenBlacklist.contains(token)) {
if (tokenBlacklist.containsKey(token)) {
throw new SecurityException("Token has been invalidated (user logged out)");
}
try {
// Validate token and extract userId
return tokenProvider.extractUserId(token);
} catch (JwtException e) {
throw new SecurityException("Invalid or expired JWT token: " + e.getMessage(), e);
}
}
/**
* Refreshes an expired access token using a refresh token.
*
* Process:
* 1. Validate refresh token
* 2. Extract userId from refresh token
* 3. Load user from repository (not implemented here - done in Application Layer)
* 4. Generate new access token
*
* Note: This method only validates the refresh token and extracts userId.
* The Application Layer is responsible for loading the user and creating a new session.
*
* @param refreshToken Refresh token
* @return New session token
* @throws RuntimeException if refresh token is invalid or expired
*/
@Override
public SessionToken refreshSession(String refreshToken) {
if (refreshToken == null || refreshToken.isBlank()) {
@ -127,62 +70,53 @@ public class JwtSessionManager implements SessionManager {
}
try {
// Validate refresh token and extract userId
UserId userId = tokenProvider.extractUserId(refreshToken);
// Validate refresh token
Claims claims = tokenProvider.validateToken(refreshToken);
String type = claims.get("type", String.class);
if (!"refresh".equals(type)) {
throw new SecurityException("Not a refresh token");
}
// NOTE: In a real implementation, we would:
// 1. Load the user from repository
// 2. Verify user is still active
// 3. Generate new access + refresh tokens
//
// For now, this is a placeholder that demonstrates the contract.
// The Application Layer service will handle the full flow.
UserId userId = UserId.of(claims.getSubject());
// Note: full implementation with user loading should be done via a dedicated Use Case.
// This is the infrastructure-level token refresh.
throw new UnsupportedOperationException(
"Session refresh requires user loading from repository. " +
"This should be implemented in the Application Layer service."
"Use the RefreshSession use case instead."
);
} catch (JwtException e) {
throw new SecurityException("Invalid or expired refresh token: " + e.getMessage(), e);
}
}
/**
* Invalidates a session (logout).
*
* Adds the token to the blacklist to prevent further use.
* Note: Blacklist is in-memory for MVP. For production, use Redis with TTL.
*
* @param token JWT access token to invalidate
*/
@Override
public void invalidateSession(String token) {
if (token != null && !token.isBlank()) {
tokenBlacklist.add(token);
// TODO: In production, implement automatic cleanup of expired tokens from blacklist
// Option 1: Use Redis with TTL (token expiration time)
// Option 2: Background task to clean up expired tokens
// Option 3: Use a time-based eviction cache (Caffeine, Guava)
try {
Claims claims = tokenProvider.validateToken(token);
Instant expiration = claims.getExpiration().toInstant();
tokenBlacklist.put(token, expiration);
} catch (JwtException e) {
// Token is already invalid/expired, no need to blacklist
}
}
}
/**
* Checks if a token is blacklisted.
* Useful for debugging and testing.
*
* @param token JWT access token
* @return true if token is blacklisted, false otherwise
* Scheduled cleanup of expired tokens from the blacklist.
* Runs every 5 minutes.
*/
public boolean isTokenBlacklisted(String token) {
return tokenBlacklist.contains(token);
@Scheduled(fixedRate = 300_000)
public void cleanupExpiredTokens() {
Instant now = Instant.now();
tokenBlacklist.entrySet().removeIf(entry -> entry.getValue().isBefore(now));
}
public boolean isTokenBlacklisted(String token) {
return tokenBlacklist.containsKey(token);
}
/**
* Clears the token blacklist.
* Useful for testing. DO NOT use in production!
*/
public void clearBlacklist() {
tokenBlacklist.clear();
}

View file

@ -0,0 +1,75 @@
package de.effigenix.infrastructure.security;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.time.Instant;
import java.util.concurrent.ConcurrentHashMap;
/**
* In-memory rate limiter for login attempts.
* Prevents brute-force attacks by limiting failed login attempts per IP/username.
*
* Configuration:
* - Max 5 attempts per 15 minutes per key (IP or username)
* - Automatic cleanup of expired entries every 5 minutes
*
* For production: consider Redis-based rate limiting for distributed environments.
*/
@Component
public class LoginRateLimiter {
private static final int MAX_ATTEMPTS = 5;
private static final long WINDOW_MS = 15 * 60 * 1000L; // 15 minutes
private final ConcurrentHashMap<String, AttemptRecord> attempts = new ConcurrentHashMap<>();
/**
* Records a failed login attempt for the given key (e.g., IP address or username).
*/
public void recordFailedAttempt(String key) {
attempts.compute(key, (k, existing) -> {
Instant now = Instant.now();
if (existing == null || existing.windowStart.plusMillis(WINDOW_MS).isBefore(now)) {
return new AttemptRecord(now, 1);
}
return new AttemptRecord(existing.windowStart, existing.count + 1);
});
}
/**
* Checks if the given key is rate-limited (too many failed attempts).
*/
public boolean isRateLimited(String key) {
AttemptRecord record = attempts.get(key);
if (record == null) return false;
Instant now = Instant.now();
if (record.windowStart.plusMillis(WINDOW_MS).isBefore(now)) {
attempts.remove(key);
return false;
}
return record.count >= MAX_ATTEMPTS;
}
/**
* Resets the attempt counter for a key (e.g., after successful login).
*/
public void resetAttempts(String key) {
attempts.remove(key);
}
/**
* Scheduled cleanup of expired attempt records.
* Runs every 5 minutes.
*/
@Scheduled(fixedRate = 300_000)
public void cleanupExpiredEntries() {
Instant now = Instant.now();
attempts.entrySet().removeIf(entry ->
entry.getValue().windowStart.plusMillis(WINDOW_MS).isBefore(now));
}
private record AttemptRecord(Instant windowStart, int count) {}
}

View file

@ -1,8 +1,8 @@
package de.effigenix.infrastructure.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
@ -12,6 +12,11 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
/**
* Spring Security 6 Configuration for JWT-based authentication.
@ -20,18 +25,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
* - Stateless: No server-side sessions (SessionCreationPolicy.STATELESS)
* - JWT-based: Authentication via JWT tokens in Authorization header
* - BCrypt: Password hashing with strength 12
* - Role-based: Authorization via permissions (not roles!)
*
* Endpoint Security:
* - Public: /api/auth/login, /api/auth/refresh
* - Protected: All other /api/** endpoints require authentication
* - Swagger: /swagger-ui/**, /api-docs/** (public for development, restrict in production)
*
* Filter Chain:
* 1. JwtAuthenticationFilter: Validates JWT and sets Authentication in SecurityContext
* 2. Spring Security filters: Authorization checks based on permissions
*
* Infrastructure Layer Spring Security Configuration
* - CORS: Configurable allowed origins
*/
@Configuration
@EnableWebSecurity
@ -42,6 +36,9 @@ public class SecurityConfig {
private final ApiAuthenticationEntryPoint authenticationEntryPoint;
private final ApiAccessDeniedHandler accessDeniedHandler;
@Value("${effigenix.cors.allowed-origins:http://localhost:3000}")
private List<String> allowedOrigins;
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter,
ApiAuthenticationEntryPoint authenticationEntryPoint,
ApiAccessDeniedHandler accessDeniedHandler) {
@ -50,81 +47,49 @@ public class SecurityConfig {
this.accessDeniedHandler = accessDeniedHandler;
}
/**
* Configures the Spring Security filter chain.
*
* Security Configuration:
* - CSRF disabled (stateless JWT - no cookie-based sessions)
* - CORS configured (allows cross-origin requests)
* - Session management: STATELESS (no server-side sessions)
* - Authorization: Public endpoints vs protected endpoints
* - Exception handling: 401 Unauthorized for authentication failures
*
* @param http HttpSecurity builder
* @return Configured SecurityFilterChain
* @throws Exception if configuration fails
*/
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
// CSRF Protection: Disabled for stateless JWT authentication
// IMPORTANT: Enable CSRF for cookie-based sessions in production!
.csrf(AbstractHttpConfigurer::disable)
// CORS Configuration: Allow cross-origin requests
// TODO: Configure specific origins in production (not allowAll)
.cors(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
// Session Management: Stateless (no server-side sessions)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
// Authorization Rules
.authorizeHttpRequests(auth -> auth
// Public Endpoints: Authentication
.requestMatchers("/api/auth/login", "/api/auth/refresh").permitAll()
// Public Endpoints: Swagger/OpenAPI (restrict in production!)
.requestMatchers("/swagger-ui/**", "/api-docs/**", "/v3/api-docs/**").permitAll()
// Public Endpoints: Health check (for load balancers)
.requestMatchers("/actuator/health").permitAll()
// Protected Endpoints: All other /api/** endpoints require authentication
.requestMatchers("/api/**").authenticated()
// All other requests: Deny by default (secure by default)
.anyRequest().denyAll()
)
// Exception Handling: Return 401/403 with consistent ErrorResponse format
.exceptionHandling(exception -> exception
.authenticationEntryPoint(authenticationEntryPoint)
.accessDeniedHandler(accessDeniedHandler)
)
// Add JWT Authentication Filter before Spring Security's authentication filters
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
/**
* Password Encoder Bean: BCrypt with strength 12.
*
* BCrypt Properties:
* - Strength 12: ~250ms hashing time on modern hardware
* - 2^12 = 4,096 iterations
* - Includes automatic salt generation
* - Resistant to rainbow table and brute-force attacks
*
* This bean is used by:
* - BCryptPasswordHasher (for password hashing)
* - Spring Security (for password verification in authentication)
*
* @return BCryptPasswordEncoder with strength 12
*/
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
configuration.setAllowedOrigins(allowedOrigins);
configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));
configuration.setAllowCredentials(true);
configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", configuration);
return source;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);

View file

@ -5,6 +5,7 @@ import de.effigenix.application.usermanagement.SessionManager;
import de.effigenix.application.usermanagement.command.AuthenticateCommand;
import de.effigenix.application.usermanagement.dto.SessionToken;
import de.effigenix.domain.usermanagement.UserError;
import de.effigenix.infrastructure.security.LoginRateLimiter;
import de.effigenix.infrastructure.usermanagement.web.dto.LoginRequest;
import de.effigenix.infrastructure.usermanagement.web.dto.LoginResponse;
import de.effigenix.infrastructure.usermanagement.web.dto.RefreshTokenRequest;
@ -27,17 +28,9 @@ import org.springframework.web.bind.annotation.*;
/**
* REST Controller for Authentication endpoints.
*
* Endpoints:
* - POST /api/auth/login - Login with username/password, returns JWT
* - POST /api/auth/logout - Logout (invalidate JWT)
* - POST /api/auth/refresh - Refresh access token using refresh token
*
* Security:
* - All endpoints are PUBLIC (configured in SecurityConfig)
* - No authentication required for login/refresh
* - Logout requires valid JWT token
*
* Infrastructure Layer REST API
* - Rate limiting on login endpoint (max 5 attempts per 15min per IP)
* - All auth endpoints are PUBLIC (configured in SecurityConfig)
*/
@RestController
@RequestMapping("/api/auth")
@ -48,118 +41,60 @@ public class AuthController {
private final AuthenticateUser authenticateUser;
private final SessionManager sessionManager;
private final LoginRateLimiter rateLimiter;
public AuthController(
AuthenticateUser authenticateUser,
SessionManager sessionManager
SessionManager sessionManager,
LoginRateLimiter rateLimiter
) {
this.authenticateUser = authenticateUser;
this.sessionManager = sessionManager;
this.rateLimiter = rateLimiter;
}
/**
* Login endpoint.
*
* Authenticates user with username and password.
* Returns JWT access token and refresh token on success.
*
* POST /api/auth/login
*
* Request Body:
* {
* "username": "admin",
* "password": "admin123"
* }
*
* Response (200 OK):
* {
* "accessToken": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...",
* "tokenType": "Bearer",
* "expiresIn": 3600,
* "expiresAt": "2026-02-17T14:30:00",
* "refreshToken": "refresh-token-here"
* }
*
* Error Responses:
* - 401 Unauthorized: Invalid credentials, user locked, or user inactive
* - 400 Bad Request: Validation error (missing username/password)
*
* @param request Login request with username and password
* @return LoginResponse with JWT tokens
*/
@PostMapping("/login")
@Operation(
summary = "User login",
description = "Authenticate user with username and password. Returns JWT access token and refresh token."
)
@Operation(summary = "User login", description = "Authenticate user with username and password. Returns JWT tokens.")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "Login successful",
content = @Content(schema = @Schema(implementation = LoginResponse.class))
),
@ApiResponse(
responseCode = "401",
description = "Invalid credentials, user locked, or user inactive"
),
@ApiResponse(
responseCode = "400",
description = "Validation error (missing username or password)"
)
@ApiResponse(responseCode = "200", description = "Login successful",
content = @Content(schema = @Schema(implementation = LoginResponse.class))),
@ApiResponse(responseCode = "401", description = "Invalid credentials, user locked, or user inactive"),
@ApiResponse(responseCode = "429", description = "Too many login attempts")
})
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
logger.info("Login attempt for username: {}", request.username());
public ResponseEntity<LoginResponse> login(
@Valid @RequestBody LoginRequest request,
HttpServletRequest httpRequest
) {
String clientIp = getClientIp(httpRequest);
// Execute authentication use case
AuthenticateCommand command = new AuthenticateCommand(
request.username(),
request.password()
);
Result<UserError, SessionToken> result = authenticateUser.execute(command);
// Handle result
if (result.isFailure()) {
// Throw the domain error - will be handled by GlobalExceptionHandler
UserError error = result.unsafeGetError();
throw new AuthenticationFailedException(error);
// Rate limiting check
if (rateLimiter.isRateLimited(clientIp)) {
logger.warn("Rate limited login attempt from IP: {}", clientIp);
throw new AuthenticationFailedException(new UserError.Unauthorized("Too many login attempts. Please try again later."));
}
logger.info("Login attempt for username: {}", request.username());
AuthenticateCommand command = new AuthenticateCommand(request.username(), request.password());
Result<UserError, SessionToken> result = authenticateUser.execute(command);
if (result.isFailure()) {
rateLimiter.recordFailedAttempt(clientIp);
throw new AuthenticationFailedException(result.unsafeGetError());
}
rateLimiter.resetAttempts(clientIp);
SessionToken token = result.unsafeGetValue();
logger.info("Login successful for username: {}", request.username());
return ResponseEntity.ok(LoginResponse.from(token));
}
/**
* Logout endpoint.
*
* Invalidates the current JWT token.
* Client should also delete the token from local storage.
*
* POST /api/auth/logout
* Authorization: Bearer <access-token>
*
* Response (204 No Content):
* (empty body)
*
* @param authentication Current authentication (from JWT token)
* @return Empty response with 204 status
*/
@PostMapping("/logout")
@Operation(
summary = "User logout",
description = "Invalidate current JWT token. Requires authentication."
)
@Operation(summary = "User logout", description = "Invalidate current JWT token.")
@ApiResponses({
@ApiResponse(
responseCode = "204",
description = "Logout successful"
),
@ApiResponse(
responseCode = "401",
description = "Invalid or missing authentication token"
)
@ApiResponse(responseCode = "204", description = "Logout successful"),
@ApiResponse(responseCode = "401", description = "Invalid or missing authentication token")
})
public ResponseEntity<Void> logout(HttpServletRequest request, Authentication authentication) {
String token = extractTokenFromRequest(request);
@ -172,6 +107,26 @@ public class AuthController {
return ResponseEntity.noContent().build();
}
@PostMapping("/refresh")
@Operation(summary = "Refresh access token", description = "Refresh an expired access token using a valid refresh token.")
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Token refresh successful",
content = @Content(schema = @Schema(implementation = LoginResponse.class))),
@ApiResponse(responseCode = "401", description = "Invalid or expired refresh token")
})
public ResponseEntity<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
logger.info("Token refresh attempt");
try {
SessionToken token = sessionManager.refreshSession(request.refreshToken());
logger.info("Token refresh successful");
return ResponseEntity.ok(LoginResponse.from(token));
} catch (RuntimeException ex) {
logger.warn("Token refresh failed");
throw new AuthenticationFailedException(new UserError.InvalidCredentials());
}
}
private String extractTokenFromRequest(HttpServletRequest request) {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
@ -180,76 +135,14 @@ public class AuthController {
return null;
}
/**
* Refresh token endpoint.
*
* Refreshes an expired access token using a valid refresh token.
* Returns new JWT access token and refresh token.
*
* POST /api/auth/refresh
*
* Request Body:
* {
* "refreshToken": "refresh-token-here"
* }
*
* Response (200 OK):
* {
* "accessToken": "new-access-token",
* "tokenType": "Bearer",
* "expiresIn": 3600,
* "expiresAt": "2026-02-17T15:30:00",
* "refreshToken": "new-refresh-token"
* }
*
* Error Responses:
* - 401 Unauthorized: Invalid or expired refresh token
* - 400 Bad Request: Validation error (missing refresh token)
*
* @param request Refresh token request
* @return LoginResponse with new JWT tokens
*/
@PostMapping("/refresh")
@Operation(
summary = "Refresh access token",
description = "Refresh an expired access token using a valid refresh token. Returns new access token and refresh token."
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "Token refresh successful",
content = @Content(schema = @Schema(implementation = LoginResponse.class))
),
@ApiResponse(
responseCode = "401",
description = "Invalid or expired refresh token"
),
@ApiResponse(
responseCode = "400",
description = "Validation error (missing refresh token)"
)
})
public ResponseEntity<LoginResponse> refresh(@Valid @RequestBody RefreshTokenRequest request) {
logger.info("Token refresh attempt");
try {
SessionToken token = sessionManager.refreshSession(request.refreshToken());
logger.info("Token refresh successful");
return ResponseEntity.ok(LoginResponse.from(token));
} catch (RuntimeException ex) {
logger.warn("Token refresh failed: {}", ex.getMessage());
logger.trace("Token refresh exception details", ex);
throw new AuthenticationFailedException(
new UserError.InvalidCredentials()
);
private String getClientIp(HttpServletRequest request) {
String xForwardedFor = request.getHeader("X-Forwarded-For");
if (xForwardedFor != null && !xForwardedFor.isBlank()) {
return xForwardedFor.split(",")[0].trim();
}
return request.getRemoteAddr();
}
/**
* Custom runtime exception to wrap UserError for authentication failures.
* This allows the GlobalExceptionHandler to catch and convert it properly.
*/
public static class AuthenticationFailedException extends RuntimeException {
private final UserError error;

View file

@ -1,13 +1,9 @@
package de.effigenix.infrastructure.usermanagement.web.controller;
import de.effigenix.application.usermanagement.*;
import de.effigenix.application.usermanagement.command.AssignRoleCommand;
import de.effigenix.application.usermanagement.command.ChangePasswordCommand;
import de.effigenix.application.usermanagement.command.CreateUserCommand;
import de.effigenix.application.usermanagement.command.UpdateUserCommand;
import de.effigenix.application.usermanagement.command.*;
import de.effigenix.application.usermanagement.dto.UserDTO;
import de.effigenix.domain.usermanagement.RoleName;
import de.effigenix.domain.usermanagement.User;
import de.effigenix.domain.usermanagement.UserError;
import de.effigenix.infrastructure.usermanagement.web.dto.*;
import de.effigenix.shared.common.Result;
@ -34,21 +30,9 @@ import java.util.List;
/**
* REST Controller for User Management endpoints.
*
* Endpoints:
* - POST /api/users - Create user (ADMIN only)
* - GET /api/users - List all users
* - GET /api/users/{id} - Get user by ID
* - PUT /api/users/{id} - Update user
* - POST /api/users/{id}/lock - Lock user (ADMIN only)
* - POST /api/users/{id}/unlock - Unlock user (ADMIN only)
* - POST /api/users/{id}/roles - Assign role (ADMIN only)
* - DELETE /api/users/{id}/roles/{roleName} - Remove role (ADMIN only)
* - PUT /api/users/{id}/password - Change password
*
* Security:
* - All endpoints require authentication (JWT token)
* - ADMIN-only endpoints check for USER_MANAGEMENT permission
* - Users can change their own password
* - @PreAuthorize as defense-in-depth (authorization also enforced in use cases)
*
* Infrastructure Layer REST API
*/
@ -92,54 +76,14 @@ public class UserController {
this.changePassword = changePassword;
}
/**
* Create user endpoint.
*
* Creates a new user account with specified roles.
* Requires ADMIN permission (USER_MANAGEMENT).
*
* POST /api/users
* Authorization: Bearer <access-token>
*
* Request Body:
* {
* "username": "john.doe",
* "email": "john.doe@example.com",
* "password": "SecurePass123",
* "roleNames": ["USER", "MANAGER"],
* "branchId": "BRANCH-001"
* }
*
* Response (201 Created):
* {
* "id": "user-uuid",
* "username": "john.doe",
* "email": "john.doe@example.com",
* "roles": [...],
* "branchId": "BRANCH-001",
* "status": "ACTIVE",
* "createdAt": "2026-02-17T12:00:00",
* "lastLogin": null
* }
*
* @param request Create user request
* @param authentication Current authentication
* @return Created user DTO
*/
@PostMapping
@PreAuthorize("hasAuthority('USER_WRITE')")
@Operation(
summary = "Create user (ADMIN only)",
description = "Create a new user account with specified roles. Requires USER_MANAGEMENT permission."
)
@Operation(summary = "Create user (ADMIN only)", description = "Create a new user account with specified roles.")
@ApiResponses({
@ApiResponse(
responseCode = "201",
description = "User created successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))
),
@ApiResponse(responseCode = "201", description = "User created successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))),
@ApiResponse(responseCode = "400", description = "Validation error or invalid password"),
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
@ApiResponse(responseCode = "403", description = "Missing permission"),
@ApiResponse(responseCode = "409", description = "Username or email already exists")
})
public ResponseEntity<UserDTO> createUser(
@ -149,508 +93,202 @@ public class UserController {
ActorId actorId = extractActorId(authentication);
logger.info("Creating user: {} by actor: {}", request.username(), actorId.value());
// Note: Authorization is checked via @PreAuthorize annotation
// No need for additional manual authorization check here
// Execute use case
CreateUserCommand command = new CreateUserCommand(
request.username(),
request.email(),
request.password(),
request.roleNames(),
request.branchId()
request.username(), request.email(), request.password(),
request.roleNames(), request.branchId()
);
Result<UserError, UserDTO> result = createUser.execute(command, actorId);
if (result.isFailure()) {
throw new DomainErrorException(result.unsafeGetError());
}
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
logger.info("User created successfully: {}", request.username());
return ResponseEntity.status(HttpStatus.CREATED).body(result.unsafeGetValue());
}
/**
* List users endpoint.
*
* Lists all users in the system.
* Returns simplified user information.
*
* GET /api/users
* Authorization: Bearer <access-token>
*
* Response (200 OK):
* [
* {
* "id": "user-uuid",
* "username": "john.doe",
* "email": "john.doe@example.com",
* "roles": [...],
* "branchId": "BRANCH-001",
* "status": "ACTIVE",
* "createdAt": "2026-02-17T12:00:00",
* "lastLogin": "2026-02-17T14:30:00"
* }
* ]
*
* @param authentication Current authentication
* @return List of user DTOs
*/
@GetMapping
@Operation(
summary = "List all users",
description = "Get a list of all users in the system. Requires authentication."
)
@Operation(summary = "List all users", description = "Get a list of all users in the system.")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "Users retrieved successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))
),
@ApiResponse(responseCode = "200", description = "Users retrieved successfully"),
@ApiResponse(responseCode = "401", description = "Authentication required")
})
public ResponseEntity<List<UserDTO>> listUsers(Authentication authentication) {
ActorId actorId = extractActorId(authentication);
logger.info("Listing users by actor: {}", actorId.value());
Result<UserError, List<UserDTO>> result = listUsers.execute();
if (result.isFailure()) {
throw new DomainErrorException(result.unsafeGetError());
}
Result<UserError, List<UserDTO>> result = listUsers.execute(actorId);
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
return ResponseEntity.ok(result.unsafeGetValue());
}
/**
* Get user by ID endpoint.
*
* Retrieves a single user by their ID.
*
* GET /api/users/{id}
* Authorization: Bearer <access-token>
*
* Response (200 OK):
* {
* "id": "user-uuid",
* "username": "john.doe",
* "email": "john.doe@example.com",
* "roles": [...],
* "branchId": "BRANCH-001",
* "status": "ACTIVE",
* "createdAt": "2026-02-17T12:00:00",
* "lastLogin": "2026-02-17T14:30:00"
* }
*
* @param userId User ID
* @param authentication Current authentication
* @return User DTO
*/
@GetMapping("/{id}")
@Operation(
summary = "Get user by ID",
description = "Retrieve a single user by their ID. Requires authentication."
)
@Operation(summary = "Get user by ID", description = "Retrieve a single user by their ID.")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "User retrieved successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))
),
@ApiResponse(responseCode = "200", description = "User retrieved successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))),
@ApiResponse(responseCode = "404", description = "User not found"),
@ApiResponse(responseCode = "401", description = "Authentication required")
})
public ResponseEntity<UserDTO> getUserById(
@Parameter(description = "User ID", example = "user-uuid")
@PathVariable("id") String userId,
@Parameter(description = "User ID") @PathVariable("id") String userId,
Authentication authentication
) {
ActorId actorId = extractActorId(authentication);
logger.info("Getting user: {} by actor: {}", userId, actorId.value());
Result<UserError, UserDTO> result = getUser.execute(userId);
if (result.isFailure()) {
throw new DomainErrorException(result.unsafeGetError());
}
Result<UserError, UserDTO> result = getUser.execute(userId, actorId);
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
return ResponseEntity.ok(result.unsafeGetValue());
}
/**
* Update user endpoint.
*
* Updates user details (email, branchId).
* Only provided fields will be updated.
*
* PUT /api/users/{id}
* Authorization: Bearer <access-token>
*
* Request Body:
* {
* "email": "newemail@example.com",
* "branchId": "BRANCH-002"
* }
*
* Response (200 OK):
* {
* "id": "user-uuid",
* "username": "john.doe",
* "email": "newemail@example.com",
* "roles": [...],
* "branchId": "BRANCH-002",
* "status": "ACTIVE",
* "createdAt": "2026-02-17T12:00:00",
* "lastLogin": "2026-02-17T14:30:00"
* }
*
* @param userId User ID
* @param request Update user request
* @param authentication Current authentication
* @return Updated user DTO
*/
@PutMapping("/{id}")
@Operation(
summary = "Update user",
description = "Update user details (email, branchId). Only provided fields will be updated."
)
@Operation(summary = "Update user", description = "Update user details (email, branchId).")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "User updated successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))
),
@ApiResponse(responseCode = "200", description = "User updated successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))),
@ApiResponse(responseCode = "404", description = "User not found"),
@ApiResponse(responseCode = "409", description = "Email already exists"),
@ApiResponse(responseCode = "401", description = "Authentication required")
@ApiResponse(responseCode = "409", description = "Email already exists")
})
public ResponseEntity<UserDTO> updateUser(
@Parameter(description = "User ID", example = "user-uuid")
@PathVariable("id") String userId,
@Parameter(description = "User ID") @PathVariable("id") String userId,
@Valid @RequestBody UpdateUserRequest request,
Authentication authentication
) {
ActorId actorId = extractActorId(authentication);
logger.info("Updating user: {} by actor: {}", userId, actorId.value());
UpdateUserCommand command = new UpdateUserCommand(
userId,
request.email(),
request.branchId()
);
UpdateUserCommand command = new UpdateUserCommand(userId, request.email(), request.branchId());
Result<UserError, UserDTO> result = updateUser.execute(command, actorId);
if (result.isFailure()) {
throw new DomainErrorException(result.unsafeGetError());
}
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
logger.info("User updated successfully: {}", userId);
return ResponseEntity.ok(result.unsafeGetValue());
}
/**
* Lock user endpoint.
*
* Locks a user account (prevents login).
* Requires ADMIN permission (USER_MANAGEMENT).
*
* POST /api/users/{id}/lock
* Authorization: Bearer <access-token>
*
* Response (200 OK):
* {
* "id": "user-uuid",
* "username": "john.doe",
* "email": "john.doe@example.com",
* "roles": [...],
* "branchId": "BRANCH-001",
* "status": "LOCKED",
* "createdAt": "2026-02-17T12:00:00",
* "lastLogin": "2026-02-17T14:30:00"
* }
*
* @param userId User ID
* @param authentication Current authentication
* @return Updated user DTO
*/
@PostMapping("/{id}/lock")
@PreAuthorize("hasAuthority('USER_LOCK')")
@Operation(
summary = "Lock user (ADMIN only)",
description = "Lock a user account (prevents login). Requires USER_MANAGEMENT permission."
)
@Operation(summary = "Lock user (ADMIN only)", description = "Lock a user account (prevents login).")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "User locked successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))
),
@ApiResponse(responseCode = "200", description = "User locked successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))),
@ApiResponse(responseCode = "404", description = "User not found"),
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
@ApiResponse(responseCode = "401", description = "Authentication required")
@ApiResponse(responseCode = "409", description = "Invalid status transition"),
@ApiResponse(responseCode = "403", description = "Missing permission")
})
public ResponseEntity<UserDTO> lockUser(
@Parameter(description = "User ID", example = "user-uuid")
@PathVariable("id") String userId,
@Parameter(description = "User ID") @PathVariable("id") String userId,
Authentication authentication
) {
ActorId actorId = extractActorId(authentication);
logger.info("Locking user: {} by actor: {}", userId, actorId.value());
Result<UserError, UserDTO> result = lockUser.execute(userId, actorId);
if (result.isFailure()) {
throw new DomainErrorException(result.unsafeGetError());
}
LockUserCommand command = new LockUserCommand(userId);
Result<UserError, UserDTO> result = lockUser.execute(command, actorId);
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
logger.info("User locked successfully: {}", userId);
return ResponseEntity.ok(result.unsafeGetValue());
}
/**
* Unlock user endpoint.
*
* Unlocks a user account (allows login).
* Requires ADMIN permission (USER_MANAGEMENT).
*
* POST /api/users/{id}/unlock
* Authorization: Bearer <access-token>
*
* Response (200 OK):
* {
* "id": "user-uuid",
* "username": "john.doe",
* "email": "john.doe@example.com",
* "roles": [...],
* "branchId": "BRANCH-001",
* "status": "ACTIVE",
* "createdAt": "2026-02-17T12:00:00",
* "lastLogin": "2026-02-17T14:30:00"
* }
*
* @param userId User ID
* @param authentication Current authentication
* @return Updated user DTO
*/
@PostMapping("/{id}/unlock")
@PreAuthorize("hasAuthority('USER_UNLOCK')")
@Operation(
summary = "Unlock user (ADMIN only)",
description = "Unlock a user account (allows login). Requires USER_MANAGEMENT permission."
)
@Operation(summary = "Unlock user (ADMIN only)", description = "Unlock a user account (allows login).")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "User unlocked successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))
),
@ApiResponse(responseCode = "200", description = "User unlocked successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))),
@ApiResponse(responseCode = "404", description = "User not found"),
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
@ApiResponse(responseCode = "401", description = "Authentication required")
@ApiResponse(responseCode = "409", description = "Invalid status transition"),
@ApiResponse(responseCode = "403", description = "Missing permission")
})
public ResponseEntity<UserDTO> unlockUser(
@Parameter(description = "User ID", example = "user-uuid")
@PathVariable("id") String userId,
@Parameter(description = "User ID") @PathVariable("id") String userId,
Authentication authentication
) {
ActorId actorId = extractActorId(authentication);
logger.info("Unlocking user: {} by actor: {}", userId, actorId.value());
Result<UserError, UserDTO> result = unlockUser.execute(userId, actorId);
if (result.isFailure()) {
throw new DomainErrorException(result.unsafeGetError());
}
UnlockUserCommand command = new UnlockUserCommand(userId);
Result<UserError, UserDTO> result = unlockUser.execute(command, actorId);
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
logger.info("User unlocked successfully: {}", userId);
return ResponseEntity.ok(result.unsafeGetValue());
}
/**
* Assign role endpoint.
*
* Assigns a role to a user.
* Requires ADMIN permission (USER_MANAGEMENT).
*
* POST /api/users/{id}/roles
* Authorization: Bearer <access-token>
*
* Request Body:
* {
* "roleName": "MANAGER"
* }
*
* Response (200 OK):
* {
* "id": "user-uuid",
* "username": "john.doe",
* "email": "john.doe@example.com",
* "roles": [...],
* "branchId": "BRANCH-001",
* "status": "ACTIVE",
* "createdAt": "2026-02-17T12:00:00",
* "lastLogin": "2026-02-17T14:30:00"
* }
*
* @param userId User ID
* @param request Assign role request
* @param authentication Current authentication
* @return Updated user DTO
*/
@PostMapping("/{id}/roles")
@PreAuthorize("hasAuthority('ROLE_ASSIGN')")
@Operation(
summary = "Assign role (ADMIN only)",
description = "Assign a role to a user. Requires USER_MANAGEMENT permission."
)
@Operation(summary = "Assign role (ADMIN only)", description = "Assign a role to a user.")
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "Role assigned successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))
),
@ApiResponse(responseCode = "200", description = "Role assigned successfully",
content = @Content(schema = @Schema(implementation = UserDTO.class))),
@ApiResponse(responseCode = "404", description = "User or role not found"),
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
@ApiResponse(responseCode = "401", description = "Authentication required")
@ApiResponse(responseCode = "403", description = "Missing permission")
})
public ResponseEntity<UserDTO> assignRole(
@Parameter(description = "User ID", example = "user-uuid")
@PathVariable("id") String userId,
@Parameter(description = "User ID") @PathVariable("id") String userId,
@Valid @RequestBody AssignRoleRequest request,
Authentication authentication
) {
ActorId actorId = extractActorId(authentication);
logger.info("Assigning role {} to user: {} by actor: {}",
request.roleName(), userId, actorId.value());
logger.info("Assigning role {} to user: {} by actor: {}", request.roleName(), userId, actorId.value());
AssignRoleCommand command = new AssignRoleCommand(userId, request.roleName());
Result<UserError, UserDTO> result = assignRole.execute(command, actorId);
if (result.isFailure()) {
throw new DomainErrorException(result.unsafeGetError());
}
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
logger.info("Role assigned successfully to user: {}", userId);
return ResponseEntity.ok(result.unsafeGetValue());
}
/**
* Remove role endpoint.
*
* Removes a role from a user.
* Requires ADMIN permission (USER_MANAGEMENT).
*
* DELETE /api/users/{id}/roles/{roleName}
* Authorization: Bearer <access-token>
*
* Response (204 No Content):
* (empty body)
*
* @param userId User ID
* @param roleName Role name to remove
* @param authentication Current authentication
* @return Empty response
*/
@DeleteMapping("/{id}/roles/{roleName}")
@PreAuthorize("hasAuthority('ROLE_REMOVE')")
@Operation(
summary = "Remove role (ADMIN only)",
description = "Remove a role from a user. Requires USER_MANAGEMENT permission."
)
@Operation(summary = "Remove role (ADMIN only)", description = "Remove a role from a user.")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Role removed successfully"),
@ApiResponse(responseCode = "404", description = "User or role not found"),
@ApiResponse(responseCode = "403", description = "Missing USER_MANAGEMENT permission"),
@ApiResponse(responseCode = "401", description = "Authentication required")
@ApiResponse(responseCode = "403", description = "Missing permission")
})
public ResponseEntity<Void> removeRole(
@Parameter(description = "User ID", example = "user-uuid")
@PathVariable("id") String userId,
@Parameter(description = "Role name", example = "MANAGER")
@PathVariable("roleName") RoleName roleName,
@Parameter(description = "User ID") @PathVariable("id") String userId,
@Parameter(description = "Role name") @PathVariable("roleName") RoleName roleName,
Authentication authentication
) {
ActorId actorId = extractActorId(authentication);
logger.info("Removing role {} from user: {} by actor: {}",
roleName, userId, actorId.value());
logger.info("Removing role {} from user: {} by actor: {}", roleName, userId, actorId.value());
Result<UserError, UserDTO> result = removeRole.execute(userId, roleName, actorId);
if (result.isFailure()) {
throw new DomainErrorException(result.unsafeGetError());
}
RemoveRoleCommand command = new RemoveRoleCommand(userId, roleName);
Result<UserError, UserDTO> result = removeRole.execute(command, actorId);
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
logger.info("Role removed successfully from user: {}", userId);
return ResponseEntity.noContent().build();
}
/**
* Change password endpoint.
*
* Changes a user's password.
* Requires current password for verification.
* Users can change their own password.
*
* PUT /api/users/{id}/password
* Authorization: Bearer <access-token>
*
* Request Body:
* {
* "currentPassword": "OldPass123",
* "newPassword": "NewSecurePass456"
* }
*
* Response (204 No Content):
* (empty body)
*
* @param userId User ID
* @param request Change password request
* @param authentication Current authentication
* @return Empty response
*/
@PutMapping("/{id}/password")
@Operation(
summary = "Change password",
description = "Change user password. Requires current password for verification."
)
@Operation(summary = "Change password", description = "Change user password. Requires current password for verification.")
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Password changed successfully"),
@ApiResponse(responseCode = "400", description = "Invalid password"),
@ApiResponse(responseCode = "401", description = "Invalid current password or authentication required"),
@ApiResponse(responseCode = "401", description = "Invalid current password"),
@ApiResponse(responseCode = "404", description = "User not found")
})
public ResponseEntity<Void> changePassword(
@Parameter(description = "User ID", example = "user-uuid")
@PathVariable("id") String userId,
@Parameter(description = "User ID") @PathVariable("id") String userId,
@Valid @RequestBody ChangePasswordRequest request,
Authentication authentication
) {
ActorId actorId = extractActorId(authentication);
logger.info("Changing password for user: {} by actor: {}", userId, actorId.value());
ChangePasswordCommand command = new ChangePasswordCommand(
userId,
request.currentPassword(),
request.newPassword()
);
ChangePasswordCommand command = new ChangePasswordCommand(userId, request.currentPassword(), request.newPassword());
Result<UserError, Void> result = changePassword.execute(command, actorId);
if (result.isFailure()) {
throw new DomainErrorException(result.unsafeGetError());
}
if (result.isFailure()) throw new DomainErrorException(result.unsafeGetError());
logger.info("Password changed successfully for user: {}", userId);
return ResponseEntity.noContent().build();
}
// ==================== Helper Methods ====================
/**
* Extracts ActorId from Spring Security Authentication.
*/
private ActorId extractActorId(Authentication authentication) {
if (authentication == null || authentication.getName() == null) {
throw new IllegalStateException("No authentication found in SecurityContext");
@ -658,10 +296,6 @@ public class UserController {
return ActorId.of(authentication.getName());
}
/**
* Custom exception to wrap UserError for domain failures.
* This exception is caught by GlobalExceptionHandler.
*/
public static class DomainErrorException extends RuntimeException {
private final UserError error;

View file

@ -283,7 +283,7 @@ public class GlobalExceptionHandler {
ErrorResponse errorResponse = ErrorResponse.from(
"AUTHENTICATION_FAILED",
"Authentication failed: " + ex.getMessage(),
"Authentication failed",
HttpStatus.UNAUTHORIZED.value(),
request.getRequestURI()
);
@ -312,7 +312,7 @@ public class GlobalExceptionHandler {
ErrorResponse errorResponse = ErrorResponse.from(
"ACCESS_DENIED",
"Access denied: " + ex.getMessage(),
"Access denied",
HttpStatus.FORBIDDEN.value(),
request.getRequestURI()
);

View file

@ -21,6 +21,9 @@ public final class UserErrorHttpStatusMapper {
case UserError.InvalidUsername e -> 400;
case UserError.NullPasswordHash e -> 400;
case UserError.NullRole e -> 400;
case UserError.NullPermission e -> 400;
case UserError.InvalidStatusTransition e -> 409;
case UserError.InvalidInput e -> 400;
case UserError.RepositoryFailure e -> 500;
};
}

View file

@ -0,0 +1,17 @@
springdoc:
api-docs:
enabled: false
swagger-ui:
enabled: false
logging:
level:
root: WARN
de.effigenix: INFO
org.springframework.security: WARN
org.hibernate.SQL: WARN
server:
error:
include-message: never
include-binding-errors: never

View file

@ -47,10 +47,16 @@ logging:
org.springframework.security: DEBUG
org.hibernate.SQL: DEBUG
# CORS Configuration
effigenix:
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:3000}
# API Documentation
springdoc:
api-docs:
path: /api-docs
enabled: ${SWAGGER_ENABLED:true}
swagger-ui:
path: /swagger-ui.html
enabled: true
enabled: ${SWAGGER_ENABLED:true}

View file

@ -1,26 +1,18 @@
-- Seed Admin User for initial system access
-- Username: admin
-- Password: admin123
-- BCrypt hash with strength 12
-- Insert Admin User
INSERT INTO users (id, username, email, password_hash, branch_id, status, created_at, last_login)
VALUES (
'00000000-0000-0000-0000-000000000001', -- Fixed UUID for admin
'00000000-0000-0000-0000-000000000001',
'admin',
'admin@effigenix.com',
'$2a$12$SJmX80hUZoA66W77CX7cHeRw1TPscXD6S8HYEZfhJ5PxTfkbwbLdi', -- BCrypt hash for "admin123"
NULL, -- No branch = global access
'$2a$12$SJmX80hUZoA66W77CX7cHeRw1TPscXD6S8HYEZfhJ5PxTfkbwbLdi',
NULL,
'ACTIVE',
CURRENT_TIMESTAMP,
NULL
);
-- Assign ADMIN role to admin user
INSERT INTO user_roles (user_id, role_id)
SELECT '00000000-0000-0000-0000-000000000001', id
FROM roles
WHERE name = 'ADMIN';
-- Add comment
COMMENT ON TABLE users IS 'Default admin user: username=admin, password=admin123 (CHANGE IN PRODUCTION!)';

View file

@ -8,7 +8,7 @@
<include file="db/changelog/changes/001-create-user-management-schema.xml"/>
<include file="db/changelog/changes/002-seed-roles-and-permissions.xml"/>
<include file="db/changelog/changes/003-create-audit-logs-table.xml"/>
<include file="db/changelog/changes/004-seed-admin-user.xml"/>
<include file="db/changelog/changes/004-seed-admin-user.xml" context="dev"/>
<include file="db/changelog/changes/005-create-masterdata-schema.xml"/>
<include file="db/changelog/changes/006-create-supplier-schema.xml"/>
<include file="db/changelog/changes/007-create-customer-schema.xml"/>