diff --git a/backend/src/main/java/com/sshmanager/config/DataInitializer.java b/backend/src/main/java/com/sshmanager/config/DataInitializer.java index 16370ae..768d1f9 100644 --- a/backend/src/main/java/com/sshmanager/config/DataInitializer.java +++ b/backend/src/main/java/com/sshmanager/config/DataInitializer.java @@ -26,8 +26,31 @@ public class DataInitializer implements CommandLineRunner { admin.setUsername("admin"); admin.setPasswordHash(passwordEncoder.encode("admin123")); admin.setDisplayName("Administrator"); - admin.setPasswordChangedAt(Instant.now()); + admin.setRole("ROLE_ADMIN"); + admin.setEnabled(true); + admin.setPasswordChangedAt(Instant.EPOCH); userRepository.save(admin); + } else { + // Backfill: ensure all existing admin users have ROLE_ADMIN and enabled + for (User user : userRepository.findAll()) { + boolean changed = false; + if (user.getRole() == null) { + if ("admin".equals(user.getUsername())) { + user.setRole("ROLE_ADMIN"); + } else { + user.setRole("ROLE_USER"); + } + changed = true; + } + if (user.getEnabled() == null) { + user.setEnabled(true); + changed = true; + } + if (changed) { + user.setUpdatedAt(Instant.now()); + userRepository.save(user); + } + } } } } diff --git a/backend/src/main/java/com/sshmanager/config/SecurityConfig.java b/backend/src/main/java/com/sshmanager/config/SecurityConfig.java index 7923510..b8ca638 100644 --- a/backend/src/main/java/com/sshmanager/config/SecurityConfig.java +++ b/backend/src/main/java/com/sshmanager/config/SecurityConfig.java @@ -46,6 +46,7 @@ public class SecurityConfig { .authorizeRequests() .antMatchers("/api/auth/**").permitAll() .antMatchers("/ws/**").authenticated() + .antMatchers("/api/users/**").hasRole("ADMIN") .antMatchers("/api/**").authenticated() .anyRequest().permitAll() .and() diff --git a/backend/src/main/java/com/sshmanager/controller/AuthController.java b/backend/src/main/java/com/sshmanager/controller/AuthController.java index 9193c19..42752ed 100644 --- a/backend/src/main/java/com/sshmanager/controller/AuthController.java +++ b/backend/src/main/java/com/sshmanager/controller/AuthController.java @@ -1,13 +1,17 @@ -package com.sshmanager.controller; - +package com.sshmanager.controller; + import com.sshmanager.dto.LoginRequest; import com.sshmanager.dto.LoginResponse; import com.sshmanager.dto.ChangePasswordRequest; +import com.sshmanager.dto.RegisterRequest; +import com.sshmanager.dto.UserDto; +import com.sshmanager.exception.InvalidOperationException; +import com.sshmanager.service.UserService; import com.sshmanager.entity.User; import com.sshmanager.repository.UserRepository; import com.sshmanager.security.JwtTokenProvider; import org.springframework.http.ResponseEntity; -import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; @@ -22,45 +26,74 @@ import org.springframework.web.bind.annotation.RestController; import java.time.Instant; import java.util.HashMap; import java.util.Map; - -@RestController -@RequestMapping("/api/auth") -public class AuthController { - + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + private final AuthenticationManager authenticationManager; private final JwtTokenProvider tokenProvider; private final UserRepository userRepository; private final PasswordEncoder passwordEncoder; + private final UserService userService; public AuthController(AuthenticationManager authenticationManager, JwtTokenProvider tokenProvider, UserRepository userRepository, - PasswordEncoder passwordEncoder) { + PasswordEncoder passwordEncoder, + UserService userService) { this.authenticationManager = authenticationManager; this.tokenProvider = tokenProvider; this.userRepository = userRepository; this.passwordEncoder = passwordEncoder; + this.userService = userService; } - + @PostMapping("/login") public ResponseEntity login(@RequestBody LoginRequest request) { - try { - Authentication authentication = authenticationManager.authenticate( - new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); - + try { + Authentication authentication = authenticationManager.authenticate( + new UsernamePasswordAuthenticationToken(request.getUsername(), request.getPassword())); + SecurityContextHolder.getContext().setAuthentication(authentication); String token = tokenProvider.generateToken(authentication); User user = userRepository.findByUsername(request.getUsername()).orElseThrow(() -> new IllegalStateException("User not found")); LoginResponse response = new LoginResponse(token, user.getUsername(), user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(), + user.getRole() != null ? user.getRole() : "ROLE_USER", isPasswordChangeRequired(user)); return ResponseEntity.ok(response); } catch (BadCredentialsException e) { - Map error = new HashMap<>(); - error.put("message", "Invalid username or password"); - return ResponseEntity.status(401).body(error); + Map error = new HashMap<>(); + error.put("message", "Invalid username or password"); + return ResponseEntity.status(401).body(error); + } + } + + @PostMapping("/register") + public ResponseEntity register(@RequestBody RegisterRequest request) { + try { + UserDto created = userService.createRegularUser( + request.getUsername(), request.getPassword(), request.getDisplayName()); + + // Auto-login: construct authentication directly (user already created & verified) + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken(created.getUsername(), null); + SecurityContextHolder.getContext().setAuthentication(auth); + String token = tokenProvider.generateToken(auth); + + LoginResponse response = new LoginResponse(token, created.getUsername(), + created.getDisplayName() != null ? created.getDisplayName() : created.getUsername(), + created.getRole() != null ? created.getRole() : "ROLE_USER", + false); + + return ResponseEntity.ok(response); + } catch (InvalidOperationException e) { + Map error = new HashMap<>(); + error.put("message", e.getMessage()); + return ResponseEntity.badRequest().body(error); } } @@ -75,15 +108,16 @@ public class AuthController { @GetMapping("/me") public ResponseEntity me(Authentication authentication) { - if (authentication == null || !authentication.isAuthenticated()) { - Map error = new HashMap<>(); - error.put("error", "Unauthorized"); - return ResponseEntity.status(401).body(error); - } - User user = userRepository.findByUsername(authentication.getName()).orElseThrow(() -> new IllegalStateException("User not found")); + if (authentication == null || !authentication.isAuthenticated()) { + Map error = new HashMap<>(); + error.put("error", "Unauthorized"); + return ResponseEntity.status(401).body(error); + } + User user = userRepository.findByUsername(authentication.getName()).orElseThrow(() -> new IllegalStateException("User not found")); Map data = new HashMap<>(); data.put("username", user.getUsername()); data.put("displayName", user.getDisplayName()); + data.put("role", user.getRole() != null ? user.getRole() : "ROLE_USER"); data.put("passwordChangeRequired", isPasswordChangeRequired(user)); return ResponseEntity.ok(data); } @@ -135,6 +169,7 @@ public class AuthController { } private boolean isPasswordChangeRequired(User user) { - return "admin".equals(user.getUsername()) && passwordEncoder.matches("admin123", user.getPasswordHash()); + return user.getPasswordChangedAt() == null + || !user.getPasswordChangedAt().isAfter(Instant.EPOCH); } } diff --git a/backend/src/main/java/com/sshmanager/controller/UserController.java b/backend/src/main/java/com/sshmanager/controller/UserController.java new file mode 100644 index 0000000..aab9cbe --- /dev/null +++ b/backend/src/main/java/com/sshmanager/controller/UserController.java @@ -0,0 +1,79 @@ +package com.sshmanager.controller; + +import com.sshmanager.dto.CreateUserRequest; +import com.sshmanager.dto.UpdateUserRequest; +import com.sshmanager.dto.UserDto; +import com.sshmanager.entity.User; +import com.sshmanager.repository.UserRepository; +import com.sshmanager.service.UserService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; +import org.springframework.web.bind.annotation.*; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/users") +public class UserController { + + private final UserService userService; + private final UserRepository userRepository; + + public UserController(UserService userService, UserRepository userRepository) { + this.userService = userService; + this.userRepository = userRepository; + } + + @GetMapping + public ResponseEntity> listUsers() { + return ResponseEntity.ok(userService.listUsers()); + } + + @GetMapping("/{id}") + public ResponseEntity getUser(@PathVariable Long id) { + return ResponseEntity.ok(userService.getUser(id)); + } + + @PostMapping + public ResponseEntity createUser(@RequestBody CreateUserRequest request) { + return ResponseEntity.ok(userService.createUser(request)); + } + + @PutMapping("/{id}") + public ResponseEntity updateUser(@PathVariable Long id, + @RequestBody UpdateUserRequest request, + Authentication authentication) { + Long currentUserId = getCurrentUserId(authentication); + return ResponseEntity.ok(userService.updateUser(id, request, currentUserId)); + } + + @DeleteMapping("/{id}") + public ResponseEntity> deleteUser(@PathVariable Long id, + Authentication authentication) { + Long currentUserId = getCurrentUserId(authentication); + userService.deleteUser(id, currentUserId); + Map result = new HashMap<>(); + result.put("message", "Deleted"); + return ResponseEntity.ok(result); + } + + @PostMapping("/{id}/reset-password") + public ResponseEntity> resetPassword(@PathVariable Long id, + @RequestBody Map body) { + String newPassword = body.get("newPassword"); + userService.resetPassword(id, newPassword); + + Map result = new HashMap<>(); + result.put("message", "Password reset successfully"); + result.put("passwordChangeRequired", true); + return ResponseEntity.ok(result); + } + + private Long getCurrentUserId(Authentication auth) { + User user = userRepository.findByUsername(auth.getName()) + .orElseThrow(() -> new IllegalStateException("User not found")); + return user.getId(); + } +} diff --git a/backend/src/main/java/com/sshmanager/dto/CreateUserRequest.java b/backend/src/main/java/com/sshmanager/dto/CreateUserRequest.java new file mode 100644 index 0000000..4f8847e --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/CreateUserRequest.java @@ -0,0 +1,11 @@ +package com.sshmanager.dto; + +import lombok.Data; + +@Data +public class CreateUserRequest { + private String username; + private String password; + private String displayName; + private String role; +} diff --git a/backend/src/main/java/com/sshmanager/dto/LoginResponse.java b/backend/src/main/java/com/sshmanager/dto/LoginResponse.java index a12e7a1..99bf698 100644 --- a/backend/src/main/java/com/sshmanager/dto/LoginResponse.java +++ b/backend/src/main/java/com/sshmanager/dto/LoginResponse.java @@ -1,15 +1,16 @@ -package com.sshmanager.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor +package com.sshmanager.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@NoArgsConstructor +@AllArgsConstructor public class LoginResponse { private String token; private String username; private String displayName; + private String role; private boolean passwordChangeRequired; } diff --git a/backend/src/main/java/com/sshmanager/dto/RegisterRequest.java b/backend/src/main/java/com/sshmanager/dto/RegisterRequest.java new file mode 100644 index 0000000..b2b88cf --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/RegisterRequest.java @@ -0,0 +1,10 @@ +package com.sshmanager.dto; + +import lombok.Data; + +@Data +public class RegisterRequest { + private String username; + private String password; + private String displayName; +} diff --git a/backend/src/main/java/com/sshmanager/dto/UpdateUserRequest.java b/backend/src/main/java/com/sshmanager/dto/UpdateUserRequest.java new file mode 100644 index 0000000..1d236e0 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/UpdateUserRequest.java @@ -0,0 +1,10 @@ +package com.sshmanager.dto; + +import lombok.Data; + +@Data +public class UpdateUserRequest { + private String displayName; + private String role; + private Boolean enabled; +} diff --git a/backend/src/main/java/com/sshmanager/dto/UserDto.java b/backend/src/main/java/com/sshmanager/dto/UserDto.java new file mode 100644 index 0000000..901feba --- /dev/null +++ b/backend/src/main/java/com/sshmanager/dto/UserDto.java @@ -0,0 +1,21 @@ +package com.sshmanager.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.Instant; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class UserDto { + private Long id; + private String username; + private String displayName; + private String role; + private Boolean enabled; + private Instant createdAt; + private Instant updatedAt; + private Instant passwordChangedAt; +} diff --git a/backend/src/main/java/com/sshmanager/entity/User.java b/backend/src/main/java/com/sshmanager/entity/User.java index cd533a6..3d2e702 100644 --- a/backend/src/main/java/com/sshmanager/entity/User.java +++ b/backend/src/main/java/com/sshmanager/entity/User.java @@ -35,4 +35,9 @@ public class User { @Column(nullable = false) private Instant passwordChangedAt; + + @Column(length = 20) + private String role = "ROLE_USER"; + + private Boolean enabled = true; } diff --git a/backend/src/main/java/com/sshmanager/security/CustomUserDetailsService.java b/backend/src/main/java/com/sshmanager/security/CustomUserDetailsService.java index 4e268c8..7f1a21b 100644 --- a/backend/src/main/java/com/sshmanager/security/CustomUserDetailsService.java +++ b/backend/src/main/java/com/sshmanager/security/CustomUserDetailsService.java @@ -26,7 +26,9 @@ public class CustomUserDetailsService implements UserDetailsService { return new org.springframework.security.core.userdetails.User( user.getUsername(), user.getPasswordHash(), - Collections.emptyList() + user.getEnabled() != null ? user.getEnabled() : true, + true, true, true, + Collections.singletonList(() -> user.getRole() != null ? user.getRole() : "ROLE_USER") ); } } diff --git a/backend/src/main/java/com/sshmanager/security/JwtAuthenticationFilter.java b/backend/src/main/java/com/sshmanager/security/JwtAuthenticationFilter.java index 0b5b366..24e320f 100644 --- a/backend/src/main/java/com/sshmanager/security/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/sshmanager/security/JwtAuthenticationFilter.java @@ -36,6 +36,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { String username = tokenProvider.getUsernameFromToken(jwt); UserDetails userDetails = userDetailsService.loadUserByUsername(username); + // Reject disabled accounts + if (!userDetails.isEnabled()) { + SecurityContextHolder.clearContext(); + filterChain.doFilter(request, response); + return; + } + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); diff --git a/backend/src/main/java/com/sshmanager/service/UserService.java b/backend/src/main/java/com/sshmanager/service/UserService.java new file mode 100644 index 0000000..7134cf8 --- /dev/null +++ b/backend/src/main/java/com/sshmanager/service/UserService.java @@ -0,0 +1,180 @@ +package com.sshmanager.service; + +import com.sshmanager.dto.CreateUserRequest; +import com.sshmanager.dto.UpdateUserRequest; +import com.sshmanager.dto.UserDto; +import com.sshmanager.entity.User; +import com.sshmanager.exception.InvalidOperationException; +import com.sshmanager.exception.NotFoundException; +import com.sshmanager.repository.UserRepository; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class UserService { + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + + public UserService(UserRepository userRepository, PasswordEncoder passwordEncoder) { + this.userRepository = userRepository; + this.passwordEncoder = passwordEncoder; + } + + public List listUsers() { + return userRepository.findAll().stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + public UserDto getUser(Long id) { + User user = userRepository.findById(id) + .orElseThrow(() -> new NotFoundException("User not found: " + id)); + return toDto(user); + } + + public UserDto createUser(CreateUserRequest request) { + if (request.getUsername() == null || request.getUsername().trim().isEmpty()) { + throw new InvalidOperationException("Username is required"); + } + if (request.getPassword() == null || request.getPassword().length() < 8) { + throw new InvalidOperationException("Password must be at least 8 characters"); + } + if (userRepository.existsByUsername(request.getUsername().trim())) { + throw new InvalidOperationException("Username already exists: " + request.getUsername()); + } + + User user = new User(); + user.setUsername(request.getUsername().trim()); + user.setPasswordHash(passwordEncoder.encode(request.getPassword())); + user.setDisplayName(request.getDisplayName() != null ? request.getDisplayName().trim() : request.getUsername().trim()); + user.setRole(request.getRole() != null ? request.getRole() : "ROLE_USER"); + if (!"ROLE_ADMIN".equals(user.getRole()) && !"ROLE_USER".equals(user.getRole())) { + user.setRole("ROLE_USER"); + } + user.setEnabled(true); + user.setPasswordChangedAt(Instant.now()); + user.setCreatedAt(Instant.now()); + user.setUpdatedAt(Instant.now()); + + User saved = userRepository.save(user); + return toDto(saved); + } + + public UserDto updateUser(Long id, UpdateUserRequest request, Long currentUserId) { + User user = userRepository.findById(id) + .orElseThrow(() -> new NotFoundException("User not found: " + id)); + + if (request.getDisplayName() != null) { + user.setDisplayName(request.getDisplayName().trim()); + } + if (request.getRole() != null) { + if ("ROLE_ADMIN".equals(request.getRole()) || "ROLE_USER".equals(request.getRole())) { + // Cannot change the last admin to a regular user + if ("ROLE_ADMIN".equals(user.getRole()) && "ROLE_USER".equals(request.getRole()) && isLastAdmin(id)) { + throw new InvalidOperationException("Cannot change the last admin user to a regular user"); + } + user.setRole(request.getRole()); + } + } + if (request.getEnabled() != null) { + // Cannot disable yourself + if (user.getId().equals(currentUserId) && !request.getEnabled()) { + throw new InvalidOperationException("You cannot disable your own account"); + } + // Cannot disable the last admin + if (!request.getEnabled() && "ROLE_ADMIN".equals(user.getRole()) && isLastAdmin(id)) { + throw new InvalidOperationException("Cannot disable the last admin user"); + } + user.setEnabled(request.getEnabled()); + } + + user.setUpdatedAt(Instant.now()); + User saved = userRepository.save(user); + return toDto(saved); + } + + public void deleteUser(Long id, Long currentUserId) { + if (id.equals(currentUserId)) { + throw new InvalidOperationException("You cannot delete your own account"); + } + + User user = userRepository.findById(id) + .orElseThrow(() -> new NotFoundException("User not found: " + id)); + + // Cannot delete the last admin + if ("ROLE_ADMIN".equals(user.getRole()) && isLastAdmin(id)) { + throw new InvalidOperationException("Cannot delete the last admin user"); + } + + userRepository.delete(user); + } + + public void resetPassword(Long id, String newPassword) { + if (newPassword == null || newPassword.length() < 8) { + throw new InvalidOperationException("New password must be at least 8 characters"); + } + + User user = userRepository.findById(id) + .orElseThrow(() -> new NotFoundException("User not found: " + id)); + + user.setPasswordHash(passwordEncoder.encode(newPassword)); + user.setPasswordChangedAt(Instant.EPOCH); + user.setUpdatedAt(Instant.now()); + userRepository.save(user); + } + + public UserDto createRegularUser(String username, String password, String displayName) { + if (username == null || username.trim().isEmpty()) { + throw new InvalidOperationException("Username is required"); + } + if (password == null || password.length() < 8) { + throw new InvalidOperationException("Password must be at least 8 characters"); + } + if (userRepository.existsByUsername(username.trim())) { + throw new InvalidOperationException("Username already exists: " + username); + } + + User user = new User(); + user.setUsername(username.trim()); + user.setPasswordHash(passwordEncoder.encode(password)); + user.setDisplayName(displayName != null && !displayName.trim().isEmpty() + ? displayName.trim() : username.trim()); + user.setRole("ROLE_USER"); + user.setEnabled(true); + user.setPasswordChangedAt(Instant.now()); + user.setCreatedAt(Instant.now()); + user.setUpdatedAt(Instant.now()); + + User saved = userRepository.save(user); + return toDto(saved); + } + + private boolean isLastAdmin(Long id) { + User user = userRepository.findById(id).orElse(null); + if (user == null || !"ROLE_ADMIN".equals(user.getRole())) { + return false; + } + long adminCount = userRepository.findAll().stream() + .filter(u -> "ROLE_ADMIN".equals(u.getRole())) + .count(); + return adminCount <= 1; + } + + private UserDto toDto(User user) { + return new UserDto( + user.getId(), + user.getUsername(), + user.getDisplayName(), + user.getRole(), + user.getEnabled(), + user.getCreatedAt(), + user.getUpdatedAt(), + user.getPasswordChangedAt() + ); + } +} diff --git a/backend/src/test/java/com/sshmanager/controller/AuthControllerTest.java b/backend/src/test/java/com/sshmanager/controller/AuthControllerTest.java index 13ec83e..2e8faa8 100644 --- a/backend/src/test/java/com/sshmanager/controller/AuthControllerTest.java +++ b/backend/src/test/java/com/sshmanager/controller/AuthControllerTest.java @@ -3,9 +3,13 @@ package com.sshmanager.controller; import com.sshmanager.dto.ChangePasswordRequest; import com.sshmanager.dto.LoginRequest; import com.sshmanager.dto.LoginResponse; +import com.sshmanager.dto.RegisterRequest; +import com.sshmanager.dto.UserDto; import com.sshmanager.entity.User; +import com.sshmanager.exception.InvalidOperationException; import com.sshmanager.repository.UserRepository; import com.sshmanager.security.JwtTokenProvider; +import com.sshmanager.service.UserService; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -43,13 +47,16 @@ class AuthControllerTest { @Mock private UserRepository userRepository; + @Mock + private UserService userService; + private PasswordEncoder passwordEncoder; private AuthController authController; @BeforeEach void setUp() { passwordEncoder = new BCryptPasswordEncoder(); - authController = new AuthController(authenticationManager, tokenProvider, userRepository, passwordEncoder); + authController = new AuthController(authenticationManager, tokenProvider, userRepository, passwordEncoder, userService); } @Test @@ -137,6 +144,47 @@ class AuthControllerTest { assertEquals("Current password is incorrect", body.get("message")); } + @Test + void registerCreatesUserAndReturnsToken() { + RegisterRequest request = new RegisterRequest(); + request.setUsername("newuser"); + request.setPassword("password123"); + request.setDisplayName("New User"); + + UserDto created = new UserDto(10L, "newuser", "New User", "ROLE_USER", true, + Instant.now(), Instant.now(), Instant.now()); + when(userService.createRegularUser("newuser", "password123", "New User")).thenReturn(created); + + when(tokenProvider.generateToken(any())).thenReturn("reg-token"); + + ResponseEntity response = authController.register(request); + assertEquals(200, response.getStatusCodeValue()); + LoginResponse body = (LoginResponse) response.getBody(); + assertNotNull(body); + assertEquals("newuser", body.getUsername()); + assertEquals("New User", body.getDisplayName()); + assertEquals("ROLE_USER", body.getRole()); + assertEquals("reg-token", body.getToken()); + assertFalse(body.isPasswordChangeRequired()); + } + + @Test + void registerReturns400ForDuplicateUsername() { + RegisterRequest request = new RegisterRequest(); + request.setUsername("existing"); + request.setPassword("password123"); + + when(userService.createRegularUser("existing", "password123", null)) + .thenThrow(new InvalidOperationException("Username already exists: existing")); + + ResponseEntity response = authController.register(request); + assertEquals(400, response.getStatusCodeValue()); + @SuppressWarnings("unchecked") + Map body = (Map) response.getBody(); + assertNotNull(body); + assertTrue(body.get("message").contains("already exists")); + } + @Test void meReturnsPasswordChangeRequiredFlag() { Authentication authentication = mock(Authentication.class); diff --git a/backend/src/test/java/com/sshmanager/controller/UserControllerTest.java b/backend/src/test/java/com/sshmanager/controller/UserControllerTest.java new file mode 100644 index 0000000..db6f024 --- /dev/null +++ b/backend/src/test/java/com/sshmanager/controller/UserControllerTest.java @@ -0,0 +1,135 @@ +package com.sshmanager.controller; + +import com.sshmanager.dto.CreateUserRequest; +import com.sshmanager.dto.UpdateUserRequest; +import com.sshmanager.dto.UserDto; +import com.sshmanager.entity.User; +import com.sshmanager.repository.UserRepository; +import com.sshmanager.service.UserService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.Authentication; + +import java.time.Instant; +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserControllerTest { + + @Mock + private UserService userService; + + @Mock + private UserRepository userRepository; + + @Mock + private Authentication authentication; + + private UserController userController; + + @BeforeEach + void setUp() { + userController = new UserController(userService, userRepository); + } + + @Test + void listUsersReturnsAllUsers() { + UserDto user1 = new UserDto(1L, "admin", "Admin", "ROLE_ADMIN", true, Instant.now(), Instant.now(), Instant.now()); + UserDto user2 = new UserDto(2L, "user1", "User One", "ROLE_USER", true, Instant.now(), Instant.now(), Instant.now()); + when(userService.listUsers()).thenReturn(Arrays.asList(user1, user2)); + + ResponseEntity> response = userController.listUsers(); + + assertEquals(200, response.getStatusCodeValue()); + assertEquals(2, response.getBody().size()); + } + + @Test + void getUserReturnsUser() { + UserDto user = new UserDto(1L, "admin", "Admin", "ROLE_ADMIN", true, Instant.now(), Instant.now(), Instant.now()); + when(userService.getUser(1L)).thenReturn(user); + + ResponseEntity response = userController.getUser(1L); + + assertEquals(200, response.getStatusCodeValue()); + assertEquals("admin", response.getBody().getUsername()); + } + + @Test + void createUserDelegatesToService() { + CreateUserRequest request = new CreateUserRequest(); + request.setUsername("newuser"); + request.setPassword("password123"); + + UserDto created = new UserDto(3L, "newuser", "newuser", "ROLE_USER", true, Instant.now(), Instant.now(), Instant.now()); + when(userService.createUser(request)).thenReturn(created); + + ResponseEntity response = userController.createUser(request); + + assertEquals(200, response.getStatusCodeValue()); + assertEquals("newuser", response.getBody().getUsername()); + verify(userService).createUser(request); + } + + @Test + void updateUserReturnsUpdatedUser() { + UpdateUserRequest request = new UpdateUserRequest(); + request.setDisplayName("Updated"); + + User admin = new User(); + admin.setId(1L); + admin.setUsername("admin"); + admin.setRole("ROLE_ADMIN"); + when(authentication.getName()).thenReturn("admin"); + when(userRepository.findByUsername("admin")).thenReturn(Optional.of(admin)); + + UserDto updated = new UserDto(2L, "user1", "Updated", "ROLE_USER", true, Instant.now(), Instant.now(), Instant.now()); + when(userService.updateUser(2L, request, 1L)).thenReturn(updated); + + ResponseEntity response = userController.updateUser(2L, request, authentication); + + assertEquals(200, response.getStatusCodeValue()); + assertEquals("Updated", response.getBody().getDisplayName()); + } + + @Test + void deleteUserReturnsOk() { + User admin = new User(); + admin.setId(1L); + admin.setUsername("admin"); + admin.setRole("ROLE_ADMIN"); + when(authentication.getName()).thenReturn("admin"); + when(userRepository.findByUsername("admin")).thenReturn(Optional.of(admin)); + + doNothing().when(userService).deleteUser(2L, 1L); + + ResponseEntity> response = userController.deleteUser(2L, authentication); + + assertEquals(200, response.getStatusCodeValue()); + assertEquals("Deleted", response.getBody().get("message")); + } + + @Test + void resetPasswordReturnsSuccess() { + Map body = new HashMap<>(); + body.put("newPassword", "newPass123"); + doNothing().when(userService).resetPassword(1L, "newPass123"); + + ResponseEntity> response = userController.resetPassword(1L, body); + + assertEquals(200, response.getStatusCodeValue()); + assertEquals("Password reset successfully", response.getBody().get("message")); + assertEquals(true, response.getBody().get("passwordChangeRequired")); + } +} diff --git a/backend/src/test/java/com/sshmanager/service/UserServiceTest.java b/backend/src/test/java/com/sshmanager/service/UserServiceTest.java new file mode 100644 index 0000000..57c4c81 --- /dev/null +++ b/backend/src/test/java/com/sshmanager/service/UserServiceTest.java @@ -0,0 +1,312 @@ +package com.sshmanager.service; + +import com.sshmanager.dto.CreateUserRequest; +import com.sshmanager.dto.UpdateUserRequest; +import com.sshmanager.dto.UserDto; +import com.sshmanager.entity.User; +import com.sshmanager.exception.InvalidOperationException; +import com.sshmanager.exception.NotFoundException; +import com.sshmanager.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class UserServiceTest { + + @Mock + private UserRepository userRepository; + + private PasswordEncoder passwordEncoder; + private UserService userService; + + @BeforeEach + void setUp() { + passwordEncoder = new BCryptPasswordEncoder(); + userService = new UserService(userRepository, passwordEncoder); + } + + // --- listUsers --- + + @Test + void listUsersReturnsAllUsers() { + User admin = createUser(1L, "admin", "ROLE_ADMIN", "Administrator", true); + User regular = createUser(2L, "user1", "ROLE_USER", "User One", true); + when(userRepository.findAll()).thenReturn(Arrays.asList(admin, regular)); + + List result = userService.listUsers(); + + assertEquals(2, result.size()); + assertEquals("admin", result.get(0).getUsername()); + assertEquals("ROLE_ADMIN", result.get(0).getRole()); + assertTrue(result.get(0).getEnabled()); + } + + // --- getUser --- + + @Test + void getUserReturnsUserWhenFound() { + User user = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + + UserDto result = userService.getUser(1L); + + assertEquals("admin", result.getUsername()); + assertEquals("ROLE_ADMIN", result.getRole()); + } + + @Test + void getUserThrowsWhenNotFound() { + when(userRepository.findById(99L)).thenReturn(Optional.empty()); + assertThrows(NotFoundException.class, () -> userService.getUser(99L)); + } + + // --- createUser --- + + @Test + void createUserSucceedsWithValidRequest() { + CreateUserRequest request = new CreateUserRequest(); + request.setUsername("newuser"); + request.setPassword("password123"); + request.setDisplayName("New User"); + + when(userRepository.existsByUsername("newuser")).thenReturn(false); + when(userRepository.save(any())).thenAnswer(invocation -> { + User saved = invocation.getArgument(0); + saved.setId(10L); + return saved; + }); + + UserDto result = userService.createUser(request); + + assertNotNull(result); + assertEquals("newuser", result.getUsername()); + assertEquals("ROLE_USER", result.getRole()); + assertTrue(result.getEnabled()); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + User saved = captor.getValue(); + assertTrue(passwordEncoder.matches("password123", saved.getPasswordHash())); + } + + @Test + void createUserDefaultsDisplayNameToUsername() { + CreateUserRequest request = new CreateUserRequest(); + request.setUsername("minimal"); + request.setPassword("password123"); + + when(userRepository.existsByUsername("minimal")).thenReturn(false); + when(userRepository.save(any())).thenAnswer(invocation -> { + User saved = invocation.getArgument(0); + saved.setId(11L); + return saved; + }); + + UserDto result = userService.createUser(request); + assertEquals("minimal", result.getDisplayName()); + } + + @Test + void createUserRejectsDuplicateUsername() { + CreateUserRequest request = new CreateUserRequest(); + request.setUsername("existing"); + request.setPassword("password123"); + + when(userRepository.existsByUsername("existing")).thenReturn(true); + + assertThrows(InvalidOperationException.class, () -> userService.createUser(request)); + verify(userRepository, never()).save(any()); + } + + @Test + void createUserRejectsShortPassword() { + CreateUserRequest request = new CreateUserRequest(); + request.setUsername("user"); + request.setPassword("short"); + + assertThrows(InvalidOperationException.class, () -> userService.createUser(request)); + verify(userRepository, never()).save(any()); + } + + @Test + void createUserRejectsEmptyUsername() { + CreateUserRequest request = new CreateUserRequest(); + request.setUsername(" "); + request.setPassword("password123"); + + assertThrows(InvalidOperationException.class, () -> userService.createUser(request)); + } + + // --- updateUser --- + + @Test + void updateUserChangesDisplayNameAndRole() { + User user = createUser(1L, "admin", "ROLE_ADMIN", "Old Name", true); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + UpdateUserRequest request = new UpdateUserRequest(); + request.setDisplayName("New Name"); + request.setRole("ROLE_USER"); + // We need another admin to avoid the "last admin" guard + User otherAdmin = createUser(2L, "admin2", "ROLE_ADMIN", "Admin 2", true); + when(userRepository.findAll()).thenReturn(Arrays.asList(user, otherAdmin)); + + UserDto result = userService.updateUser(1L, request, 2L); + + assertEquals("New Name", result.getDisplayName()); + assertEquals("ROLE_USER", result.getRole()); + } + + @Test + void updateUserCannotDisableLastAdmin() { + User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true); + when(userRepository.findById(1L)).thenReturn(Optional.of(admin)); + when(userRepository.findAll()).thenReturn(Arrays.asList(admin)); + + UpdateUserRequest request = new UpdateUserRequest(); + request.setEnabled(false); + + assertThrows(InvalidOperationException.class, () -> userService.updateUser(1L, request, 2L)); + } + + @Test + void updateUserCannotChangeLastAdminRole() { + User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true); + when(userRepository.findById(1L)).thenReturn(Optional.of(admin)); + when(userRepository.findAll()).thenReturn(Arrays.asList(admin)); + + UpdateUserRequest request = new UpdateUserRequest(); + request.setRole("ROLE_USER"); + + assertThrows(InvalidOperationException.class, () -> userService.updateUser(1L, request, 2L)); + } + + @Test + void updateUserCannotDisableSelf() { + User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true); + when(userRepository.findById(1L)).thenReturn(Optional.of(admin)); + + UpdateUserRequest request = new UpdateUserRequest(); + request.setEnabled(false); + + assertThrows(InvalidOperationException.class, () -> userService.updateUser(1L, request, 1L)); + } + + // --- deleteUser --- + + @Test + void deleteUserSucceedsWhenNotLastAdmin() { + User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true); + User other = createUser(2L, "admin2", "ROLE_ADMIN", "Admin 2", true); + when(userRepository.findById(2L)).thenReturn(Optional.of(other)); + when(userRepository.findAll()).thenReturn(Arrays.asList(admin, other)); + + userService.deleteUser(2L, 1L); + + verify(userRepository).delete(other); + } + + @Test + void deleteUserRefusesSelfDeletion() { + assertThrows(InvalidOperationException.class, () -> userService.deleteUser(1L, 1L)); + verify(userRepository, never()).delete(any()); + } + + @Test + void deleteUserRefusesLastAdmin() { + User admin = createUser(1L, "admin", "ROLE_ADMIN", "Admin", true); + when(userRepository.findById(1L)).thenReturn(Optional.of(admin)); + when(userRepository.findAll()).thenReturn(Arrays.asList(admin)); + + assertThrows(InvalidOperationException.class, () -> userService.deleteUser(1L, 2L)); + verify(userRepository, never()).delete(any()); + } + + // --- resetPassword --- + + @Test + void resetPasswordUpdatesHashAndSetsEpoch() { + User user = createUser(1L, "user1", "ROLE_USER", "User", true); + user.setPasswordChangedAt(Instant.now()); + when(userRepository.findById(1L)).thenReturn(Optional.of(user)); + when(userRepository.save(any())).thenAnswer(invocation -> invocation.getArgument(0)); + + userService.resetPassword(1L, "newPassword123"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(User.class); + verify(userRepository).save(captor.capture()); + User saved = captor.getValue(); + assertTrue(passwordEncoder.matches("newPassword123", saved.getPasswordHash())); + assertEquals(Instant.EPOCH, saved.getPasswordChangedAt()); + } + + // --- createRegularUser --- + + @Test + void createRegularUserSetsRoleUser() { + when(userRepository.existsByUsername("newguy")).thenReturn(false); + when(userRepository.save(any())).thenAnswer(invocation -> { + User saved = invocation.getArgument(0); + saved.setId(20L); + return saved; + }); + + UserDto result = userService.createRegularUser("newguy", "password123", "New Guy"); + + assertEquals("ROLE_USER", result.getRole()); + assertTrue(result.getEnabled()); + assertFalse(result.getPasswordChangedAt().equals(Instant.EPOCH)); + } + + @Test + void createRegularUserRejectsShortPassword() { + assertThrows(InvalidOperationException.class, + () -> userService.createRegularUser("user", "short", null)); + verify(userRepository, never()).save(any()); + } + + @Test + void createRegularUserRejectsDuplicateUsername() { + when(userRepository.existsByUsername("taken")).thenReturn(true); + + assertThrows(InvalidOperationException.class, + () -> userService.createRegularUser("taken", "password123", null)); + verify(userRepository, never()).save(any()); + } + + // --- helpers --- + + private User createUser(Long id, String username, String role, String displayName, boolean enabled) { + User user = new User(); + user.setId(id); + user.setUsername(username); + user.setPasswordHash(passwordEncoder.encode("password123")); + user.setDisplayName(displayName); + user.setRole(role); + user.setEnabled(enabled); + user.setCreatedAt(Instant.now()); + user.setUpdatedAt(Instant.now()); + user.setPasswordChangedAt(Instant.now()); + return user; + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 180ae02..8970ad3 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -2,11 +2,13 @@ import { Navigate, Route, Routes, useLocation, useNavigate } from 'react-router- import { AuthProvider, useAuth } from './context/AuthContext' import LoginPage from './pages/LoginPage' import WorkspacePage from './pages/WorkspacePage' +import UserManagementPage from './pages/UserManagementPage' function AppRoutes() { - const { isAuthenticated, loading } = useAuth() + const { isAuthenticated, user, loading } = useAuth() const location = useLocation() const navigate = useNavigate() + const isAdmin = user?.role === 'ROLE_ADMIN' if (loading) { return ( @@ -37,6 +39,18 @@ function AppRoutes() { ) } /> + navigate('/workspace')} /> + ) : isAuthenticated ? ( + + ) : ( + + ) + } + /> } /> } /> diff --git a/frontend/src/context/AuthContext.tsx b/frontend/src/context/AuthContext.tsx index 337170f..2adcd50 100644 --- a/frontend/src/context/AuthContext.tsx +++ b/frontend/src/context/AuthContext.tsx @@ -1,6 +1,6 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react' -import { changePassword as changePasswordApi, getMe, login as loginApi } from '../services/auth' -import type { CurrentUser, LoginResponse } from '../types' +import { changePassword as changePasswordApi, getMe, login as loginApi, register as registerApi } from '../services/auth' +import type { CurrentUser, LoginResponse, RegisterRequest } from '../types' interface AuthContextValue { token: string | null @@ -8,6 +8,7 @@ interface AuthContextValue { loading: boolean isAuthenticated: boolean login: (username: string, password: string) => Promise + register: (payload: RegisterRequest) => Promise logout: () => void refreshMe: () => Promise changePassword: (currentPassword: string, newPassword: string) => Promise @@ -52,6 +53,20 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { setUser({ username: response.data.username, displayName: response.data.displayName, + role: response.data.role, + passwordChangeRequired: response.data.passwordChangeRequired, + }) + return response.data + }, []) + + const register = useCallback(async (payload: RegisterRequest) => { + const response = await registerApi(payload) + localStorage.setItem('token', response.data.token) + setToken(response.data.token) + setUser({ + username: response.data.username, + displayName: response.data.displayName, + role: response.data.role, passwordChangeRequired: response.data.passwordChangeRequired, }) return response.data @@ -69,11 +84,12 @@ export function AuthProvider({ children }: { children: React.ReactNode }) { loading, isAuthenticated: !!token, login, + register, logout, refreshMe, changePassword, }), - [token, user, loading, login, logout, refreshMe, changePassword], + [token, user, loading, login, register, logout, refreshMe, changePassword], ) return {children} diff --git a/frontend/src/pages/LoginPage.tsx b/frontend/src/pages/LoginPage.tsx index b99e96b..52628a9 100644 --- a/frontend/src/pages/LoginPage.tsx +++ b/frontend/src/pages/LoginPage.tsx @@ -1,15 +1,25 @@ import { useState } from 'react' -import { Lock, Terminal, User } from 'lucide-react' +import { Lock, Terminal, User, UserPlus } from 'lucide-react' import { useAuth } from '../context/AuthContext' export default function LoginPage({ onSuccess }: { onSuccess: () => void }) { - const { login } = useAuth() + const { login, register } = useAuth() + const [mode, setMode] = useState<'login' | 'register'>('login') + + // Login fields const [username, setUsername] = useState('admin') const [password, setPassword] = useState('admin123') + + // Register fields + const [regUsername, setRegUsername] = useState('') + const [regDisplayName, setRegDisplayName] = useState('') + const [regPassword, setRegPassword] = useState('') + const [regConfirmPassword, setRegConfirmPassword] = useState('') + const [submitting, setSubmitting] = useState(false) const [error, setError] = useState(null) - async function handleSubmit(event: React.FormEvent) { + async function handleLogin(event: React.FormEvent) { event.preventDefault() setSubmitting(true) setError(null) @@ -26,6 +36,42 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) { } } + async function handleRegister(event: React.FormEvent) { + event.preventDefault() + setError(null) + + if (regPassword.length < 8) { + setError('密码至少需要 8 个字符') + return + } + if (regPassword !== regConfirmPassword) { + setError('两次输入的密码不一致') + return + } + + setSubmitting(true) + try { + await register({ + username: regUsername.trim(), + password: regPassword, + displayName: regDisplayName.trim() || undefined, + }) + onSuccess() + } catch (err) { + const message = + (err as { response?: { data?: { message?: string; error?: string } } }).response?.data?.message || + '注册失败' + setError(message) + } finally { + setSubmitting(false) + } + } + + function switchMode() { + setMode(mode === 'login' ? 'register' : 'login') + setError(null) + } + return (
@@ -43,46 +89,136 @@ export default function LoginPage({ onSuccess }: { onSuccess: () => void }) {
-
-