From 7d7b131f550c4c2458902ac8edce9eae0b3356d4 Mon Sep 17 00:00:00 2001 From: Lucio Lelii Date: Thu, 26 Mar 2026 09:58:33 +0100 Subject: [PATCH] users administration api added --- .../auth/config/JwtAuthenticationFilter.java | 12 +- .../manager/auth/config/SecurityConfig.java | 4 +- .../auth/model/AdminChangePasswordResult.java | 7 + .../model/AdminChangeUserPasswordRequest.java | 5 + .../model/AdminChangeUserRoleRequest.java | 5 + .../auth/model/AdminCreateUserRequest.java | 8 + .../manager/auth/model/AuthRequest.java | 5 +- .../auth/model/ChangePasswordResult.java | 8 + .../auth/model/ChangeUserRoleResult.java | 8 + .../manager/auth/model/CreateUserResult.java | 8 + .../manager/auth/model/DeleteUserResult.java | 7 + .../workflow/manager/auth/model/UserRole.java | 6 + .../workflow/manager/auth/model/UserView.java | 7 + .../manager/auth/repo/AuthRepository.java | 3 + .../manager/auth/repo/LoginEntity.java | 18 ++ .../manager/auth/services/AuthService.java | 105 ++++++++++-- .../auth/services/UserImportComponent.java | 8 +- .../manager/controllers/AuthController.java | 120 ++++++++++++-- .../manager/executions/ExecutionsService.java | 8 +- .../resources/workflow-editor-init/users.json | 3 +- .../controllers/AuthControllerTest.java | 155 +++++++++++++++++- .../controllers/ExecutionControllerTest.java | 8 + .../controllers/FlowControllerTest.java | 9 + 23 files changed, 485 insertions(+), 42 deletions(-) create mode 100644 src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminChangePasswordResult.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminChangeUserPasswordRequest.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminChangeUserRoleRequest.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminCreateUserRequest.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/auth/model/ChangePasswordResult.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/auth/model/ChangeUserRoleResult.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/auth/model/CreateUserResult.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/auth/model/DeleteUserResult.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/auth/model/UserRole.java create mode 100644 src/main/java/it/cnr/isti/workflow/manager/auth/model/UserView.java diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/config/JwtAuthenticationFilter.java b/src/main/java/it/cnr/isti/workflow/manager/auth/config/JwtAuthenticationFilter.java index e5fecdd..2ba24ff 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/auth/config/JwtAuthenticationFilter.java +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/config/JwtAuthenticationFilter.java @@ -1,12 +1,12 @@ package it.cnr.isti.workflow.manager.auth.config; import java.io.IOException; -import java.util.Collections; import java.util.List; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.lang.NonNull; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Component; @@ -30,7 +30,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { @Autowired private AuthRepository authRepository; - private static final List EXCLUDED_PATHS = List.of("/auth/", "/health", "/blocks/types", "/retriever/"); + private static final List EXCLUDED_PATHS = List.of( + "/auth/login", + "/auth/register", + "/auth/change-password", + "/health", + "/blocks/types", + "/retriever/"); @Override protected void doFilterInternal(@NonNull HttpServletRequest request, @@ -57,7 +63,7 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { logger.warn("JWT username '{}' not found in database", username); } else if (jwtUtil.validateToken(jwt, userDetails)) { UsernamePasswordAuthenticationToken authToken = new UsernamePasswordAuthenticationToken(userDetails, - null, Collections.emptyList()); + null, List.of(new SimpleGrantedAuthority("ROLE_" + userDetails.effectiveRole().name()))); authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); SecurityContextHolder.getContext().setAuthentication(authToken); diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java b/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java index 74d9577..98f74c0 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java @@ -28,7 +28,9 @@ public class SecurityConfig { .cors(Customizer.withDefaults()) .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth - .requestMatchers("/auth/**").permitAll() + .requestMatchers("/auth/login").permitAll() + .requestMatchers("/auth/register").permitAll() + .requestMatchers("/auth/change-password").permitAll() .requestMatchers("/swagger-ui.html").permitAll() .requestMatchers("/swagger-ui/**").permitAll() .requestMatchers("/v3/api-docs/**").permitAll() diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminChangePasswordResult.java b/src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminChangePasswordResult.java new file mode 100644 index 0000000..6402f4d --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminChangePasswordResult.java @@ -0,0 +1,7 @@ +package it.cnr.isti.workflow.manager.auth.model; + +public enum AdminChangePasswordResult { + SUCCESS, + USER_NOT_FOUND, + INVALID_PASSWORD +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminChangeUserPasswordRequest.java b/src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminChangeUserPasswordRequest.java new file mode 100644 index 0000000..c250d17 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminChangeUserPasswordRequest.java @@ -0,0 +1,5 @@ +package it.cnr.isti.workflow.manager.auth.model; + +public record AdminChangeUserPasswordRequest( + String newPassword) { +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminChangeUserRoleRequest.java b/src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminChangeUserRoleRequest.java new file mode 100644 index 0000000..3e93c26 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminChangeUserRoleRequest.java @@ -0,0 +1,5 @@ +package it.cnr.isti.workflow.manager.auth.model; + +public record AdminChangeUserRoleRequest( + UserRole role) { +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminCreateUserRequest.java b/src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminCreateUserRequest.java new file mode 100644 index 0000000..792e2f7 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/model/AdminCreateUserRequest.java @@ -0,0 +1,8 @@ +package it.cnr.isti.workflow.manager.auth.model; + +public record AdminCreateUserRequest( + String username, + String password, + String email, + UserRole role) { +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/model/AuthRequest.java b/src/main/java/it/cnr/isti/workflow/manager/auth/model/AuthRequest.java index fec60ff..8092624 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/auth/model/AuthRequest.java +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/model/AuthRequest.java @@ -8,6 +8,7 @@ import lombok.NoArgsConstructor; public class AuthRequest { private String username; private String password; + private String email; // Getters e Setters public String getUsername() { return username; } @@ -16,9 +17,11 @@ public class AuthRequest { public String getPassword() { return password; } public void setPassword(String password) { this.password = password; } + public String getEmail() { return email; } + public void setEmail(String email) { this.email = email; } + boolean isValid() { return username.contains(" ") && username != null && !username.isEmpty() && username.length()>5 && password != null && username.contains(" ") && password.length()>5 && !password.isEmpty(); } } - diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/model/ChangePasswordResult.java b/src/main/java/it/cnr/isti/workflow/manager/auth/model/ChangePasswordResult.java new file mode 100644 index 0000000..da6522b --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/model/ChangePasswordResult.java @@ -0,0 +1,8 @@ +package it.cnr.isti.workflow.manager.auth.model; + +public enum ChangePasswordResult { + SUCCESS, + USER_NOT_FOUND, + INVALID_OLD_PASSWORD, + INVALID_NEW_PASSWORD +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/model/ChangeUserRoleResult.java b/src/main/java/it/cnr/isti/workflow/manager/auth/model/ChangeUserRoleResult.java new file mode 100644 index 0000000..a8281ef --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/model/ChangeUserRoleResult.java @@ -0,0 +1,8 @@ +package it.cnr.isti.workflow.manager.auth.model; + +public enum ChangeUserRoleResult { + SUCCESS, + USER_NOT_FOUND, + INVALID_ROLE, + LAST_ADMIN +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/model/CreateUserResult.java b/src/main/java/it/cnr/isti/workflow/manager/auth/model/CreateUserResult.java new file mode 100644 index 0000000..a69b1e9 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/model/CreateUserResult.java @@ -0,0 +1,8 @@ +package it.cnr.isti.workflow.manager.auth.model; + +public enum CreateUserResult { + SUCCESS, + USER_ALREADY_EXISTS, + INVALID_EMAIL, + INVALID_PASSWORD +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/model/DeleteUserResult.java b/src/main/java/it/cnr/isti/workflow/manager/auth/model/DeleteUserResult.java new file mode 100644 index 0000000..17387a7 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/model/DeleteUserResult.java @@ -0,0 +1,7 @@ +package it.cnr.isti.workflow.manager.auth.model; + +public enum DeleteUserResult { + SUCCESS, + USER_NOT_FOUND, + LAST_ADMIN +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/model/UserRole.java b/src/main/java/it/cnr/isti/workflow/manager/auth/model/UserRole.java new file mode 100644 index 0000000..2e29631 --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/model/UserRole.java @@ -0,0 +1,6 @@ +package it.cnr.isti.workflow.manager.auth.model; + +public enum UserRole { + USER, + ADMIN +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/model/UserView.java b/src/main/java/it/cnr/isti/workflow/manager/auth/model/UserView.java new file mode 100644 index 0000000..ecf311c --- /dev/null +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/model/UserView.java @@ -0,0 +1,7 @@ +package it.cnr.isti.workflow.manager.auth.model; + +public record UserView( + String username, + String email, + UserRole role) { +} diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/repo/AuthRepository.java b/src/main/java/it/cnr/isti/workflow/manager/auth/repo/AuthRepository.java index e2b07bf..9225d2d 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/auth/repo/AuthRepository.java +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/repo/AuthRepository.java @@ -4,8 +4,11 @@ package it.cnr.isti.workflow.manager.auth.repo; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import it.cnr.isti.workflow.manager.auth.model.UserRole; @Repository public interface AuthRepository extends JpaRepository { + long countByRole(UserRole role); + } diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/repo/LoginEntity.java b/src/main/java/it/cnr/isti/workflow/manager/auth/repo/LoginEntity.java index 3429d65..432043f 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/auth/repo/LoginEntity.java +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/repo/LoginEntity.java @@ -1,6 +1,9 @@ package it.cnr.isti.workflow.manager.auth.repo; +import it.cnr.isti.workflow.manager.auth.model.UserRole; import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; import jakarta.persistence.Id; import lombok.AllArgsConstructor; import lombok.Data; @@ -14,4 +17,19 @@ public class LoginEntity { @Id private String username; private String password; + private String email; + @Enumerated(EnumType.STRING) + private UserRole role; + + public LoginEntity(String username, String password) { + this(username, password, null, UserRole.USER); + } + + public LoginEntity(String username, String password, UserRole role) { + this(username, password, null, role); + } + + public UserRole effectiveRole() { + return role == null ? UserRole.USER : role; + } } diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/services/AuthService.java b/src/main/java/it/cnr/isti/workflow/manager/auth/services/AuthService.java index 5e8f980..7d0c03a 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/auth/services/AuthService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/services/AuthService.java @@ -4,20 +4,22 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; +import it.cnr.isti.workflow.manager.auth.model.AdminChangePasswordResult; +import it.cnr.isti.workflow.manager.auth.model.ChangePasswordResult; +import it.cnr.isti.workflow.manager.auth.model.ChangeUserRoleResult; +import it.cnr.isti.workflow.manager.auth.model.CreateUserResult; +import it.cnr.isti.workflow.manager.auth.model.DeleteUserResult; +import it.cnr.isti.workflow.manager.auth.model.UserRole; +import it.cnr.isti.workflow.manager.auth.model.UserView; import it.cnr.isti.workflow.manager.auth.repo.AuthRepository; import it.cnr.isti.workflow.manager.auth.repo.LoginEntity; import static it.cnr.isti.workflow.manager.auth.services.PasswordHasher.*; +import java.util.List; + @Service public class AuthService { - public enum ChangePasswordResult { - SUCCESS, - USER_NOT_FOUND, - INVALID_OLD_PASSWORD, - INVALID_NEW_PASSWORD - } - @Autowired AuthRepository authRepository; @@ -30,13 +32,23 @@ public class AuthService { } - public boolean registerUser(String username, String password) { + public CreateUserResult registerUser(String username, String password, String email) { + return createUser(username, password, email, UserRole.USER); + } + + public CreateUserResult createUser(String username, String password, String email, UserRole role) { if (authRepository.existsById(username)) { - return false; // User already exists + return CreateUserResult.USER_ALREADY_EXISTS; + } + if (!isValidEmail(email)) { + return CreateUserResult.INVALID_EMAIL; + } + if (!isValidPassword(password)) { + return CreateUserResult.INVALID_PASSWORD; } String hashedPassword = hashPassword(password); - authRepository.save(new LoginEntity(username, hashedPassword)); - return true; + authRepository.save(new LoginEntity(username, hashedPassword, email.trim(), role == null ? UserRole.USER : role)); + return CreateUserResult.SUCCESS; } public ChangePasswordResult changePassword(String username, String oldPassword, String newPassword) { @@ -55,6 +67,60 @@ public class AuthService { return ChangePasswordResult.SUCCESS; } + public AdminChangePasswordResult adminChangePassword(String username, String newPassword) { + LoginEntity user = authRepository.findById(username).orElse(null); + if (user == null) { + return AdminChangePasswordResult.USER_NOT_FOUND; + } + if (!isValidPassword(newPassword)) { + return AdminChangePasswordResult.INVALID_PASSWORD; + } + user.setPassword(hashPassword(newPassword)); + authRepository.save(user); + return AdminChangePasswordResult.SUCCESS; + } + + public DeleteUserResult deleteUser(String username) { + LoginEntity user = authRepository.findById(username).orElse(null); + if (user == null) { + return DeleteUserResult.USER_NOT_FOUND; + } + if (user.effectiveRole() == UserRole.ADMIN && authRepository.countByRole(UserRole.ADMIN) <= 1) { + return DeleteUserResult.LAST_ADMIN; + } + authRepository.deleteById(username); + return DeleteUserResult.SUCCESS; + } + + public ChangeUserRoleResult changeUserRole(String username, UserRole role) { + LoginEntity user = authRepository.findById(username).orElse(null); + if (user == null) { + return ChangeUserRoleResult.USER_NOT_FOUND; + } + if (role == null) { + return ChangeUserRoleResult.INVALID_ROLE; + } + if (user.effectiveRole() == UserRole.ADMIN + && role != UserRole.ADMIN + && authRepository.countByRole(UserRole.ADMIN) <= 1) { + return ChangeUserRoleResult.LAST_ADMIN; + } + user.setRole(role); + authRepository.save(user); + return ChangeUserRoleResult.SUCCESS; + } + + public List listUsers() { + return authRepository.findAll().stream() + .map(user -> new UserView(user.getUsername(), user.getEmail(), user.effectiveRole())) + .sorted(java.util.Comparator.comparing(UserView::username)) + .toList(); + } + + public LoginEntity getUser(String username) { + return authRepository.findById(username).orElse(null); + } + private boolean isValidPassword(String password) { if (password == null || password.length() < 8) { return false; @@ -80,4 +146,21 @@ public class AuthService { return hasUppercase && hasLowercase && hasDigit && hasSpecial; } + private boolean isValidEmail(String email) { + if (email == null) { + return false; + } + String normalizedEmail = email.trim(); + if (normalizedEmail.isEmpty() || normalizedEmail.contains(" ")) { + return false; + } + int atIndex = normalizedEmail.indexOf('@'); + int lastAtIndex = normalizedEmail.lastIndexOf('@'); + int dotIndex = normalizedEmail.lastIndexOf('.'); + return atIndex > 0 + && atIndex == lastAtIndex + && dotIndex > atIndex + 1 + && dotIndex < normalizedEmail.length() - 1; + } + } diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/services/UserImportComponent.java b/src/main/java/it/cnr/isti/workflow/manager/auth/services/UserImportComponent.java index af5ec28..ad30c82 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/auth/services/UserImportComponent.java +++ b/src/main/java/it/cnr/isti/workflow/manager/auth/services/UserImportComponent.java @@ -48,12 +48,8 @@ public class UserImportComponent { ObjectMapperHolder.mapper.getTypeFactory().constructCollectionType(List.class, LoginEntity.class)); for (LoginEntity user : users) { authRepository.findById(user.getUsername()).ifPresentOrElse( - existingUser -> { - logger.debug("User {} already present in the database, updating password", - user.getUsername()); - existingUser.setPassword(PasswordHasher.hashPassword(user.getPassword())); - authRepository.save(existingUser); - }, + existingUser -> logger.debug("User {} already present in the database, skipping import", + user.getUsername()), () -> { logger.debug("User {} not found, saving new user", user.getUsername()); user.setPassword(PasswordHasher.hashPassword(user.getPassword())); diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/AuthController.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/AuthController.java index b503370..358b018 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/controllers/AuthController.java +++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/AuthController.java @@ -4,21 +4,35 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import it.cnr.isti.workflow.manager.auth.config.JwtUtil; +import it.cnr.isti.workflow.manager.auth.model.AdminChangePasswordResult; +import it.cnr.isti.workflow.manager.auth.model.AdminChangeUserPasswordRequest; +import it.cnr.isti.workflow.manager.auth.model.AdminChangeUserRoleRequest; +import it.cnr.isti.workflow.manager.auth.model.AdminCreateUserRequest; import it.cnr.isti.workflow.manager.auth.model.AuthRequest; +import it.cnr.isti.workflow.manager.auth.model.ChangePasswordResult; +import it.cnr.isti.workflow.manager.auth.model.ChangeUserRoleResult; import it.cnr.isti.workflow.manager.auth.model.ChangePasswordRequest; +import it.cnr.isti.workflow.manager.auth.model.CreateUserResult; +import it.cnr.isti.workflow.manager.auth.model.DeleteUserResult; import it.cnr.isti.workflow.manager.auth.services.AuthService; -import java.util.Collections; - import org.slf4j.Logger; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.PutMapping; import org.eclipse.microprofile.openapi.annotations.Operation; +import java.util.LinkedHashMap; +import java.util.Map; + @RestController @RequestMapping("/auth") public class AuthController { @@ -43,7 +57,13 @@ public class AuthController { return ResponseEntity.status(401).body("Invalid password"); } String jwt = jwtUtil.generateToken(request.getUsername()); - return ResponseEntity.ok(Collections.singletonMap("token", jwt)); + var user = authService.getUser(request.getUsername()); + Map response = new LinkedHashMap<>(); + response.put("token", jwt); + response.put("username", request.getUsername()); + response.put("role", user == null ? "USER" : user.effectiveRole().name()); + response.put("email", user == null ? null : user.getEmail()); + return ResponseEntity.ok(response); } catch (UsernameNotFoundException e) { return ResponseEntity.status(404).body("User "+request.getUsername()+" not found"); } @@ -52,18 +72,21 @@ public class AuthController { @PostMapping("/register") @Operation(summary = "Register user", description = "Registers a new user account with username and password.") public ResponseEntity register(@RequestBody AuthRequest request) { - logger.info("Register attempt for user {} with pwd {}", request.getUsername(), request.getPassword()); - if (request.getUsername() == null || request.getPassword() == null) { - return ResponseEntity.badRequest().body("Username and password are required"); + logger.info("Register attempt for user {}", request.getUsername()); + if (request.getUsername() == null || request.getPassword() == null || request.getEmail() == null) { + return ResponseEntity.badRequest().body("Username, password and email are required"); } - if (request.getUsername().length() < 3 || request.getPassword().length() < 6) { + if (request.getUsername().length() < 3) { return ResponseEntity.badRequest() - .body("Username must be at least 3 characters and password at least 6 characters"); + .body("Username must be at least 3 characters"); } - if (authService.registerUser(request.getUsername(), request.getPassword())) { - return ResponseEntity.ok().build(); - } - return ResponseEntity.badRequest().body("the user already exists"); + CreateUserResult result = authService.registerUser(request.getUsername(), request.getPassword(), request.getEmail()); + return switch (result) { + case SUCCESS -> ResponseEntity.ok().build(); + case USER_ALREADY_EXISTS -> ResponseEntity.badRequest().body("the user already exists"); + case INVALID_EMAIL -> ResponseEntity.badRequest().body("INVALID_EMAIL"); + case INVALID_PASSWORD -> ResponseEntity.badRequest().body("INVALID_PASSWORD"); + }; } @PostMapping("/change-password") @@ -77,7 +100,7 @@ public class AuthController { .body("Username, oldPassword/currentPassword and newPassword are required"); } - AuthService.ChangePasswordResult result = authService.changePassword( + ChangePasswordResult result = authService.changePassword( request.getUsername(), request.getOldPassword(), request.getNewPassword()); @@ -93,6 +116,77 @@ public class AuthController { }; } + @GetMapping("/admin/users") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "List users", description = "Returns the list of users with their role.") + public ResponseEntity listUsers() { + return ResponseEntity.ok(authService.listUsers()); + } + + @PostMapping("/admin/users") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Create user", description = "Creates a new user with USER or ADMIN role.") + public ResponseEntity createUser(@RequestBody AdminCreateUserRequest request) { + if (request == null || isBlank(request.username()) || isBlank(request.password()) || isBlank(request.email())) { + return ResponseEntity.badRequest().body("username, password and email are required"); + } + CreateUserResult result = authService.createUser(request.username(), request.password(), request.email(), request.role()); + return switch (result) { + case SUCCESS -> ResponseEntity.ok().build(); + case USER_ALREADY_EXISTS -> ResponseEntity.badRequest().body("the user already exists"); + case INVALID_EMAIL -> ResponseEntity.badRequest().body("INVALID_EMAIL"); + case INVALID_PASSWORD -> ResponseEntity.badRequest().body("INVALID_PASSWORD"); + }; + } + + @PutMapping("/admin/users/{username}/password") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Change user password", description = "Admin operation to reset another user's password.") + public ResponseEntity adminChangePassword(@PathVariable String username, + @RequestBody AdminChangeUserPasswordRequest request) { + if (request == null || isBlank(username) || isBlank(request.newPassword())) { + return ResponseEntity.badRequest().body("username and newPassword are required"); + } + AdminChangePasswordResult result = authService.adminChangePassword(username, request.newPassword()); + return switch (result) { + case SUCCESS -> ResponseEntity.ok().build(); + case USER_NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body("User " + username + " not found"); + case INVALID_PASSWORD -> ResponseEntity.badRequest().body("INVALID_PASSWORD"); + }; + } + + @PutMapping("/admin/users/{username}/role") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Change user role", description = "Admin operation to change a user's role.") + public ResponseEntity adminChangeUserRole(@PathVariable String username, + @RequestBody AdminChangeUserRoleRequest request) { + if (request == null || isBlank(username) || request.role() == null) { + return ResponseEntity.badRequest().body("username and role are required"); + } + ChangeUserRoleResult result = authService.changeUserRole(username, request.role()); + return switch (result) { + case SUCCESS -> ResponseEntity.ok().build(); + case USER_NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body("User " + username + " not found"); + case INVALID_ROLE -> ResponseEntity.badRequest().body("INVALID_ROLE"); + case LAST_ADMIN -> ResponseEntity.status(HttpStatus.CONFLICT).body("LAST_ADMIN"); + }; + } + + @DeleteMapping("/admin/users/{username}") + @PreAuthorize("hasRole('ADMIN')") + @Operation(summary = "Delete user", description = "Removes a user.") + public ResponseEntity deleteUser(@PathVariable String username) { + if (isBlank(username)) { + return ResponseEntity.badRequest().body("username is required"); + } + DeleteUserResult result = authService.deleteUser(username); + return switch (result) { + case SUCCESS -> ResponseEntity.ok().build(); + case USER_NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body("User " + username + " not found"); + case LAST_ADMIN -> ResponseEntity.status(HttpStatus.CONFLICT).body("LAST_ADMIN"); + }; + } + private boolean isBlank(String value) { return value == null || value.isBlank(); } diff --git a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java index 3d86351..f1029a6 100644 --- a/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java +++ b/src/main/java/it/cnr/isti/workflow/manager/executions/ExecutionsService.java @@ -82,7 +82,8 @@ public class ExecutionsService { ExecutionObject toReturn = executions.get(id); if (toReturn == null) { ExecutionEntity entity = executionRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("Execution with id " + id + " not found")); + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, + "Execution with id " + id + " not found")); toReturn = rebuildExecution(entity); executions.put(id, toReturn); } @@ -100,8 +101,9 @@ public class ExecutionsService { if (execution == null && executionRepository.existsById(id)) { execution = getExecution(id); } - if (execution == null) - throw new IllegalArgumentException("Execution with id " + id + " not found"); + if (execution == null) { + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Execution with id " + id + " not found"); + } if (execution.getContext().getStatus() == ExecutionStatus.RUNNING) throw new IllegalStateException("Execution with id " + id + " is still running"); executions.remove(id); diff --git a/src/main/resources/workflow-editor-init/users.json b/src/main/resources/workflow-editor-init/users.json index 98aeec7..16e89c7 100644 --- a/src/main/resources/workflow-editor-init/users.json +++ b/src/main/resources/workflow-editor-init/users.json @@ -1,6 +1,7 @@ [ { "username": "testuser", - "password": "testpassword" + "password": "testpassword", + "email": "testuser@example.com" } ] diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/AuthControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/AuthControllerTest.java index bcc9439..87c2025 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/AuthControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/AuthControllerTest.java @@ -1,26 +1,54 @@ package it.cnr.isti.workflow.manager.controllers; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.test.context.TestPropertySource; +import org.springframework.test.web.servlet.MockMvc; +import it.cnr.isti.workflow.manager.auth.config.JwtUtil; +import it.cnr.isti.workflow.manager.auth.model.AdminChangeUserPasswordRequest; +import it.cnr.isti.workflow.manager.auth.model.AdminChangeUserRoleRequest; +import it.cnr.isti.workflow.manager.auth.model.AdminCreateUserRequest; import it.cnr.isti.workflow.manager.auth.model.AuthRequest; import it.cnr.isti.workflow.manager.auth.model.ChangePasswordRequest; +import it.cnr.isti.workflow.manager.auth.model.UserRole; +import it.cnr.isti.workflow.manager.auth.repo.AuthRepository; +import it.cnr.isti.workflow.manager.auth.repo.LoginEntity; @SpringBootTest +@AutoConfigureMockMvc @TestPropertySource(locations = "classpath:test.properties") public class AuthControllerTest { @Autowired private AuthController authController; + @Autowired + private AuthRepository authRepository; + + @Autowired + private MockMvc mockMvc; + + @Autowired + private JwtUtil jwtUtil; + @Test public void testLogin() { - ResponseEntity response = authController.login(new AuthRequest("testuser", "testpassword")); + ResponseEntity response = authController.login(new AuthRequest("testuser", "testpassword", "testuser@example.com")); assert response.getStatusCode().is2xxSuccessful(); + assert response.getBody() instanceof java.util.Map; + assert "USER".equals(((java.util.Map) response.getBody()).get("role")); + assert ((java.util.Map) response.getBody()).containsKey("email"); } @Test @@ -33,7 +61,7 @@ public class AuthControllerTest { ResponseEntity response = authController.changePassword(request); assert response.getStatusCode().is2xxSuccessful(); - ResponseEntity loginResponse = authController.login(new AuthRequest("testuser", "Newpassword1!")); + ResponseEntity loginResponse = authController.login(new AuthRequest("testuser", "Newpassword1!", "testuser@example.com")); assert loginResponse.getStatusCode().is2xxSuccessful(); } @@ -50,7 +78,7 @@ public class AuthControllerTest { @Test public void testChangePasswordReturnsInvalidNewPasswordForWeakPassword() { - authController.register(new AuthRequest("weakpwduser", "Startpass1!")); + authController.register(new AuthRequest("weakpwduser", "Startpass1!", "weakpwduser@example.com")); ChangePasswordRequest request = new ChangePasswordRequest(); request.setUsername("weakpwduser"); @@ -61,4 +89,125 @@ public class AuthControllerTest { assert response.getStatusCode() == HttpStatus.BAD_REQUEST; assert "INVALID_NEW_PASSWORD".equals(response.getBody()); } + + @Test + public void adminCanCreateListChangePasswordAndDeleteUsers() { + LoginEntity admin = new LoginEntity("adminuser", + it.cnr.isti.workflow.manager.auth.services.PasswordHasher.hashPassword("Adminpass1!"), + "adminuser@example.com", + UserRole.ADMIN); + authRepository.save(admin); + + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + admin, + null, + java.util.List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); + try { + ResponseEntity createResponse = authController.createUser( + new AdminCreateUserRequest("manageduser", "Managedpass1!", "manageduser@example.com", UserRole.USER)); + assert createResponse.getStatusCode().is2xxSuccessful(); + + ResponseEntity listResponse = authController.listUsers(); + assert listResponse.getStatusCode().is2xxSuccessful(); + assert listResponse.getBody() instanceof java.util.List; + assert ((java.util.List) listResponse.getBody()).stream() + .anyMatch(item -> item.toString().contains("manageduser") && item.toString().contains("manageduser@example.com")); + + ResponseEntity passwordResponse = authController.adminChangePassword( + "manageduser", + new AdminChangeUserPasswordRequest("Changedpass1!")); + assert passwordResponse.getStatusCode().is2xxSuccessful(); + + ResponseEntity roleResponse = authController.adminChangeUserRole( + "manageduser", + new AdminChangeUserRoleRequest(UserRole.ADMIN)); + assert roleResponse.getStatusCode().is2xxSuccessful(); + assert authRepository.findById("manageduser").orElseThrow().effectiveRole() == UserRole.ADMIN; + + ResponseEntity loginResponse = authController.login(new AuthRequest("manageduser", "Changedpass1!", "manageduser@example.com")); + assert loginResponse.getStatusCode().is2xxSuccessful(); + assert loginResponse.getBody() instanceof java.util.Map; + assert "ADMIN".equals(((java.util.Map) loginResponse.getBody()).get("role")); + assert "manageduser@example.com".equals(((java.util.Map) loginResponse.getBody()).get("email")); + + ResponseEntity deleteResponse = authController.deleteUser("manageduser"); + assert deleteResponse.getStatusCode().is2xxSuccessful(); + } finally { + SecurityContextHolder.clearContext(); + } + } + + @Test + public void cannotDowngradeLastAdmin() { + authRepository.findAll().stream() + .filter(user -> user.effectiveRole() == UserRole.ADMIN) + .map(LoginEntity::getUsername) + .forEach(authRepository::deleteById); + LoginEntity admin = new LoginEntity("soloadmin", + it.cnr.isti.workflow.manager.auth.services.PasswordHasher.hashPassword("Adminpass1!"), + "soloadmin@example.com", + UserRole.ADMIN); + authRepository.save(admin); + + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + admin, + null, + java.util.List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); + try { + ResponseEntity roleResponse = authController.adminChangeUserRole( + "soloadmin", + new AdminChangeUserRoleRequest(UserRole.USER)); + assert roleResponse.getStatusCode() == HttpStatus.CONFLICT; + assert "LAST_ADMIN".equals(roleResponse.getBody()); + } finally { + SecurityContextHolder.clearContext(); + } + } + + @Test + public void cannotDeleteLastAdmin() { + authRepository.findAll().stream() + .filter(user -> user.effectiveRole() == UserRole.ADMIN) + .map(LoginEntity::getUsername) + .forEach(authRepository::deleteById); + LoginEntity admin = new LoginEntity("onlyadmin", + it.cnr.isti.workflow.manager.auth.services.PasswordHasher.hashPassword("Adminpass1!"), + "onlyadmin@example.com", + UserRole.ADMIN); + authRepository.save(admin); + + SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken( + admin, + null, + java.util.List.of(new SimpleGrantedAuthority("ROLE_ADMIN")))); + try { + ResponseEntity deleteResponse = authController.deleteUser("onlyadmin"); + assert deleteResponse.getStatusCode() == HttpStatus.CONFLICT; + assert "LAST_ADMIN".equals(deleteResponse.getBody()); + } finally { + SecurityContextHolder.clearContext(); + } + } + + @Test + public void registerRejectsInvalidEmail() { + ResponseEntity response = authController.register(new AuthRequest("mailuser", "Validpass1!", "not-an-email")); + assert response.getStatusCode() == HttpStatus.BAD_REQUEST; + assert "INVALID_EMAIL".equals(response.getBody()); + } + + @Test + public void adminEndpointsAcceptJwtAdminRole() throws Exception { + LoginEntity admin = new LoginEntity("jwtadmin", + it.cnr.isti.workflow.manager.auth.services.PasswordHasher.hashPassword("Adminpass1!"), + "jwtadmin@example.com", + UserRole.ADMIN); + authRepository.save(admin); + + String token = jwtUtil.generateToken(admin.getUsername()); + + mockMvc.perform(get("/auth/admin/users") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()); + } } diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java index 5f05bb2..37e8be7 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/ExecutionControllerTest.java @@ -209,6 +209,14 @@ public class ExecutionControllerTest { org.junit.jupiter.api.Assertions.assertTrue(events.stream().anyMatch(event -> event.getType() == ExecutionEventType.LLM_REQUEST)); } + @Test + public void getMissingExecutionReturnsNotFound() { + ResponseStatusException exception = org.junit.jupiter.api.Assertions.assertThrows( + ResponseStatusException.class, + () -> executionsController.get("missing-execution-id")); + org.junit.jupiter.api.Assertions.assertEquals(HttpStatus.NOT_FOUND, exception.getStatusCode()); + } + @Test public void executionPayloadDoesNotSerializeEvents() throws JsonProcessingException { LLMDescriptor llmDescriptor = LLMDescriptor.builder() diff --git a/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java b/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java index 72646fa..3fd52dc 100644 --- a/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java +++ b/src/test/java/it/cnr/isti/workflow/manager/controllers/FlowControllerTest.java @@ -327,6 +327,15 @@ public class FlowControllerTest { assertEquals(404, response.getStatusCode().value()); } + @Test + public void getMissingFlowReturnsNotFound() { + ResponseEntity response = flowController.getFlow( + "missing-id", + new LoginEntity("testuser", "testpassword")); + + assertEquals(404, response.getStatusCode().value()); + } + @Test public void deleteFlow() { LLMDescriptor llmDescriptor = LLMDescriptor.builder()