diff --git a/src/main/java/com/digitalsanctuary/spring/user/api/AdminApi.java b/src/main/java/com/digitalsanctuary/spring/user/api/AdminApi.java new file mode 100644 index 0000000..db60381 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/api/AdminApi.java @@ -0,0 +1,78 @@ +package com.digitalsanctuary.spring.user.api; + +import com.digitalsanctuary.spring.user.dto.LockAccountDto; +import com.digitalsanctuary.spring.user.service.UserService; +import com.digitalsanctuary.spring.user.util.JSONResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +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.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import static com.digitalsanctuary.spring.user.util.ResponseUtil.buildErrorResponse; +import static com.digitalsanctuary.spring.user.util.ResponseUtil.buildSuccessResponse; + +/** + * REST controller for managing admin-related operations. This class handles locking of user account. + */ + +@Slf4j +@RequiredArgsConstructor +@RestController +@RequestMapping(path = "/admin", produces = "application/json") +public class AdminApi { + private final UserService userService; + + /** + * Toggle Lock status for a user account. + * + * @param lockAccountDto the DTO containing the email of the user to be locked + * @return a ResponseEntity containing a JSONResponse stating that user account has been locked + * */ + @PostMapping("/lockAccount") + @PreAuthorize("hasAuthority('ADMIN_PRIVILEGE')") + public ResponseEntity lockAccount(@Valid @RequestBody LockAccountDto lockAccountDto) { + log.info("AdminApi.lockAccount: called with email: {}", lockAccountDto.getEmail()); + try { + validateDto(lockAccountDto); + userService.lockAccount(lockAccountDto.getEmail()); + return buildSuccessResponse("User account locked successfully", null); + } catch (UsernameNotFoundException e) { + log.warn("AdminApi.lockAccount: user not found: {}", lockAccountDto.getEmail()); + return buildErrorResponse("User not found", 2, HttpStatus.NOT_FOUND); + } catch (IllegalArgumentException e) { + log.warn("AdminApi.lockAccount: invalid argument: {}", e.getMessage()); + return buildErrorResponse(e.getMessage(), 1, HttpStatus.BAD_REQUEST); + } + } + + @PostMapping("/unlockAccount") + @PreAuthorize("hasAuthority('ADMIN_PRIVILEGE')") + public ResponseEntity unlockAccount(@Valid @RequestBody LockAccountDto lockAccountDto) { + log.info("AdminApi.unlockAccount: called with email: {}", lockAccountDto.getEmail()); + try { + validateDto(lockAccountDto); + userService.unlockAccount(lockAccountDto.getEmail()); + return buildSuccessResponse("User account unlocked successfully", null); + } catch (UsernameNotFoundException e) { + log.warn("AdminApi.unlockAccount: user not found: {}", lockAccountDto.getEmail()); + return buildErrorResponse("User not found", 2, HttpStatus.NOT_FOUND); + } catch (IllegalArgumentException e) { + log.warn("AdminApi.unlockAccount: invalid argument: {}", e.getMessage()); + return buildErrorResponse(e.getMessage(), 1, HttpStatus.BAD_REQUEST); + } + } + + + private void validateDto(LockAccountDto dto) { + if(dto.getEmail() == null || dto.getEmail().isEmpty()) { + throw new IllegalArgumentException("Email is required"); + } + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java index f8df8fe..f56b3d3 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java +++ b/src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java @@ -1,6 +1,8 @@ package com.digitalsanctuary.spring.user.api; import java.util.Locale; + +import com.digitalsanctuary.spring.user.util.ResponseUtil; import jakarta.validation.Valid; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationEventPublisher; @@ -31,6 +33,9 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import static com.digitalsanctuary.spring.user.util.ResponseUtil.buildErrorResponse; +import static com.digitalsanctuary.spring.user.util.ResponseUtil.buildSuccessResponse; + /** * REST controller for managing user-related operations. This class handles user registration, account deletion, and other user-related endpoints. */ @@ -293,27 +298,4 @@ private void logAuditEvent(String action, String status, String message, User us private boolean isNullOrEmpty(String value) { return value == null || value.isEmpty(); } - - /** - * Builds an error response. - * - * @param message - * @param code - * @param status - * @return a ResponseEntity containing a JSONResponse with the error response - */ - private ResponseEntity buildErrorResponse(String message, int code, HttpStatus status) { - return ResponseEntity.status(status).body(JSONResponse.builder().success(false).code(code).message(message).build()); - } - - /** - * Builds a success response. - * - * @param message - * @param redirectUrl - * @return a ResponseEntity containing a JSONResponse with the success response - */ - private ResponseEntity buildSuccessResponse(String message, String redirectUrl) { - return ResponseEntity.ok(JSONResponse.builder().success(true).code(0).message(message).redirectUrl(redirectUrl).build()); - } } diff --git a/src/main/java/com/digitalsanctuary/spring/user/dto/LockAccountDto.java b/src/main/java/com/digitalsanctuary/spring/user/dto/LockAccountDto.java new file mode 100644 index 0000000..bf651ed --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/dto/LockAccountDto.java @@ -0,0 +1,13 @@ +package com.digitalsanctuary.spring.user.dto; + +import lombok.Data; + +/** + * A lock account dto. This object is used for locking a user account. + */ +@Data +public class LockAccountDto { + + /** The user's email */ + private String email; +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/CustomAuthenticationFailureHandler.java b/src/main/java/com/digitalsanctuary/spring/user/security/CustomAuthenticationFailureHandler.java new file mode 100644 index 0000000..a3d451b --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/security/CustomAuthenticationFailureHandler.java @@ -0,0 +1,45 @@ +package com.digitalsanctuary.spring.user.security; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; + +import org.springframework.security.authentication.LockedException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +/** + * Custom authentication failure handler to handle different authentication exceptions. + * Specifically handles {@link LockedException} to redirect with a specific error message + * for locked accounts. For other authentication failures, it redirects with a generic + * invalid credentials message. + */ +@Slf4j +@Component +public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler{ + + /** + * Called when an authentication attempt fails. + * + * @param request the request during which the authentication attempt occurred. + * @param response the response. + * @param exception the exception which caused the authentication failure. + * @throws IOException in the event of an I/O error + * @throws ServletException in the event of a servlet related error + */ + @Override + public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + if (exception instanceof LockedException) { + request.getSession().setAttribute("error.message", "Your account is locked. Please contact support."); + response.sendRedirect("/login?error=locked"); + } else { + request.getSession().setAttribute("error.message", "Invalid username or password."); + response.sendRedirect("/login?error=true"); + } + } +} diff --git a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java index 65d3d19..153331a 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java +++ b/src/main/java/com/digitalsanctuary/spring/user/security/WebSecurityConfig.java @@ -116,6 +116,7 @@ public class WebSecurityConfig { private final RolesAndPrivilegesConfig rolesAndPrivilegesConfig; private final DSOAuth2UserService dsOAuth2UserService; private final DSOidcUserService dsOidcUserService; + private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler; /** * @@ -133,7 +134,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti log.debug("WebSecurityConfig.configure: enhanced unprotectedURIs: {}", unprotectedURIs.toString()); http.formLogin( - formLogin -> formLogin.loginPage(loginPageURI).loginProcessingUrl(loginActionURI).successHandler(loginSuccessService).permitAll()) + formLogin -> formLogin.loginPage(loginPageURI).loginProcessingUrl(loginActionURI).successHandler(loginSuccessService).failureHandler(customAuthenticationFailureHandler).permitAll()) .rememberMe(withDefaults()); http.logout(logout -> logout.logoutUrl(logoutActionURI).logoutSuccessUrl(logoutSuccessURI).invalidateHttpSession(true) diff --git a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java index 4d86db1..07dc5b6 100644 --- a/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java +++ b/src/main/java/com/digitalsanctuary/spring/user/service/UserService.java @@ -408,6 +408,55 @@ public void authWithoutPassword(User user) { log.debug("UserService.authWithoutPassword: authenticated user: {}", user.getEmail()); } + /** + * Locks a user's account. + * + * @param email The email of the user. + */ + public void lockAccount(String email) { + log.debug("UserService.lockAccount: locking user account for: {}", email); + User user = userRepository.findByEmail(email); + if (user == null) { + log.error("UserService.lockAccount: user not found: {}", email); + throw new UsernameNotFoundException("User not found: " + email); + } + + if (user.isLocked()) { + log.warn("UserService.lockAccount: user is already locked: {}", email); + return; + } + + user.setLocked(true); + user.setLockedDate(new java.util.Date()); + userRepository.save(user); + log.info("UserService.lockAccount: user account locked: {}", email); + } + + /** + * Unlocks a user's account. + * + * @param email The email of the user. + */ + public void unlockAccount(String email) { + log.debug("UserService.unlockAccount: unlocking user account for: {}", email); + User user = userRepository.findByEmail(email); + if (user == null) { + log.error("UserService.unlockAccount: user not found: {}", email); + throw new UsernameNotFoundException("User not found: " + email); + } + + if (!user.isLocked()) { + log.warn("UserService.unlockAccount: user is already unlocked: {}", email); + return; + } + + user.setLocked(false); + user.setLockedDate(null); + userRepository.save(user); + log.info("UserService.unlockAccount: user account unlocked: {}", email); + } + + /** * Authenticates the user by creating an authentication object and setting it in the security context. * @@ -436,7 +485,4 @@ private void storeSecurityContextInSession() { // Store the security context in the session session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext()); } - - - } diff --git a/src/main/java/com/digitalsanctuary/spring/user/util/ResponseUtil.java b/src/main/java/com/digitalsanctuary/spring/user/util/ResponseUtil.java new file mode 100644 index 0000000..d6d6370 --- /dev/null +++ b/src/main/java/com/digitalsanctuary/spring/user/util/ResponseUtil.java @@ -0,0 +1,33 @@ +package com.digitalsanctuary.spring.user.util; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; + +/** + * Utility class for creating JSON response. + */ +public class ResponseUtil { + + /** + * Builds an error response. + * + * @param message + * @param code + * @param status + * @return a ResponseEntity containing a JSONResponse with the error response + */ + public static ResponseEntity buildErrorResponse(String message, int code, HttpStatus status) { + return ResponseEntity.status(status).body(JSONResponse.builder().success(false).code(code).message(message).build()); + } + + /** + * Builds a success response. + * + * @param message + * @param redirectUrl + * @return a ResponseEntity containing a JSONResponse with the success response + */ + public static ResponseEntity buildSuccessResponse(String message, String redirectUrl) { + return ResponseEntity.ok(JSONResponse.builder().success(true).code(0).message(message).redirectUrl(redirectUrl).build()); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/AdminApiTest.java b/src/test/java/com/digitalsanctuary/spring/user/api/AdminApiTest.java new file mode 100644 index 0000000..c9df94a --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/api/AdminApiTest.java @@ -0,0 +1,80 @@ +package com.digitalsanctuary.spring.user.api; + +import com.digitalsanctuary.spring.user.api.data.ApiTestData; +import com.digitalsanctuary.spring.user.api.data.DataStatus; +import com.digitalsanctuary.spring.user.api.data.Response; +import com.digitalsanctuary.spring.user.api.helper.AssertionsHelper; +import com.digitalsanctuary.spring.user.api.provider.ApiTestAccountLockingArgumentsProvider; +import com.digitalsanctuary.spring.user.api.provider.holder.ApiTestArgumentsHolder; +import com.digitalsanctuary.spring.user.dto.UserDto; +import com.digitalsanctuary.spring.user.jdbc.Jdbc; +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ArgumentsSource; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.test.web.servlet.request.MockMvcRequestBuilders; + +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +// Disabling the test cases for now because of java.lang.NoClassDefFoundError: org/springframework/security/oauth2/core/user/OAuth2User +//@Disabled("Temporarily disabled due to OAuth2 dependency issues") +public class AdminApiTest extends BaseApiTest { + private static final String URL = "/admin"; + private static final UserDto baseAdminUser = ApiTestData.BASE_ADMIN_USER; + private static final UserDto baseTestUser = ApiTestData.BASE_TEST_USER; + + @AfterAll + public static void afterAll() { + Jdbc.deleteTestUser(baseAdminUser); + Jdbc.deleteTestUser(baseTestUser); + } + + /** + * + * @param argumentsHolder + * @throws Exception testing with 3 params: existing email, non-existing email, no email + */ + @ParameterizedTest + @ArgumentsSource(ApiTestAccountLockingArgumentsProvider.class) + public void toggleLockStatusOfUser(ApiTestArgumentsHolder argumentsHolder) throws Exception { + ResultActions action = perform(MockMvcRequestBuilders.post(URL + "/lockAccount").contentType(MediaType.APPLICATION_FORM_URLENCODED) + .content(String.valueOf(argumentsHolder.getLockAccountDto()))); + + verifyResponse(action, argumentsHolder); + } + + /** + * Test unlocking a user account with different conditions (valid, not found, invalid). + * + * @param argumentsHolder Test data including valid, not found, and invalid cases. + * @throws Exception when the test fails + */ + @ParameterizedTest + @ArgumentsSource(ApiTestAccountLockingArgumentsProvider.class) + public void unlockUserAccount(ApiTestArgumentsHolder argumentsHolder) throws Exception { + ResultActions action = perform(MockMvcRequestBuilders.post(URL + "/unlockAccount") + .contentType(MediaType.APPLICATION_JSON) + .content(String.valueOf(argumentsHolder.getLockAccountDto()))); + + verifyResponse(action, argumentsHolder); + } + + /** + * Helper method to verify API responses based on test data. + */ + private void verifyResponse(ResultActions action, ApiTestArgumentsHolder argumentsHolder) throws Exception { + if (argumentsHolder.getStatus() == DataStatus.VALID) { + action.andExpect(status().isOk()); + } else if (argumentsHolder.getStatus() == DataStatus.NOT_FOUND) { + action.andExpect(status().isNotFound()); + } else if (argumentsHolder.getStatus() == DataStatus.INVALID) { + action.andExpect(status().isBadRequest()); + } + + MockHttpServletResponse actual = action.andReturn().getResponse(); + Response expected = argumentsHolder.getResponse(); + AssertionsHelper.compareResponses(actual, expected); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/data/ApiTestData.java b/src/test/java/com/digitalsanctuary/spring/user/api/data/ApiTestData.java index 191d0f2..4fa58c9 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/data/ApiTestData.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/data/ApiTestData.java @@ -1,12 +1,17 @@ package com.digitalsanctuary.spring.user.api.data; +import com.digitalsanctuary.spring.user.dto.LockAccountDto; import com.digitalsanctuary.spring.user.dto.PasswordDto; import com.digitalsanctuary.spring.user.dto.UserDto; +import com.digitalsanctuary.spring.user.persistence.model.Role; import com.digitalsanctuary.spring.user.service.DSUserDetails; +import java.util.Collections; + public class ApiTestData { public static final UserDto BASE_TEST_USER = getUserDto(); + public static final UserDto BASE_ADMIN_USER = getAdminUserDto(); public static final DSUserDetails DEFAULT_DETAILS = new DSUserDetails(null, null); public static PasswordDto getPasswordDto() { @@ -35,6 +40,32 @@ public static UserDto getUserDto() { public static UserDto getEmptyUserDto() { return new UserDto(); } + public static UserDto getAdminUserDto() { + UserDto userDto = new UserDto(); + userDto.setFirstName("testApiAdmin"); + userDto.setLastName("userApiTest"); + userDto.setEmail("testApiAdmin@bk.com"); + userDto.setPassword("testApiAdminPassword"); + userDto.setMatchingPassword(userDto.getPassword()); + userDto.setRole(2); + return userDto; + } + + public static LockAccountDto getLockAccountDto() { + LockAccountDto lockAccountDto = new LockAccountDto(); + lockAccountDto.setEmail("testApiUser@bk.com"); + return lockAccountDto; + } + + public static LockAccountDto getEmptyLockAccountDto() { + return new LockAccountDto(); + } + + public static LockAccountDto getLockAccountDtoForMissingUser() { + LockAccountDto lockAccountDto = new LockAccountDto(); + lockAccountDto.setEmail("testRandom@bk.com"); + return lockAccountDto; + } public static Response successRegistration() { return new Response( @@ -92,4 +123,19 @@ public static Response deleteAccountFailry() { new String[]{"Error Occurred"}, null ); } + public static Response successLockAccount() { + return new Response(true, null, null, + new String[]{"Account Locked"}, null + ); + } + public static Response lockAccountFailry() { + return new Response(false, null, null, + new String[]{"User not found"}, null + ); + } + public static Response invalidBodyLockAccountFailry() { + return new Response(false, 1, null, + new String[]{"Email is required"}, null + ); + } } diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/data/DataStatus.java b/src/test/java/com/digitalsanctuary/spring/user/api/data/DataStatus.java index f1731b3..e7e0e18 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/data/DataStatus.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/data/DataStatus.java @@ -6,5 +6,6 @@ public enum DataStatus { INVALID, VALID, LOGGED, - NOT_LOGGED + NOT_LOGGED, + NOT_FOUND } diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/provider/ApiTestAccountLockingArgumentsProvider.java b/src/test/java/com/digitalsanctuary/spring/user/api/provider/ApiTestAccountLockingArgumentsProvider.java new file mode 100644 index 0000000..ef53edc --- /dev/null +++ b/src/test/java/com/digitalsanctuary/spring/user/api/provider/ApiTestAccountLockingArgumentsProvider.java @@ -0,0 +1,35 @@ +package com.digitalsanctuary.spring.user.api.provider; + +import com.digitalsanctuary.spring.user.api.data.ApiTestData; +import com.digitalsanctuary.spring.user.api.data.DataStatus; +import com.digitalsanctuary.spring.user.api.provider.holder.ApiTestArgumentsHolder; +import org.junit.jupiter.api.extension.ExtensionContext; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; + +import java.util.stream.Stream; + +public class ApiTestAccountLockingArgumentsProvider implements ArgumentsProvider { + @Override + public Stream provideArguments(ExtensionContext context) throws Exception { + return Stream.of( + new ApiTestArgumentsHolder( + ApiTestData.getLockAccountDto(), + DataStatus.VALID, + ApiTestData.successLockAccount() + ), + + new ApiTestArgumentsHolder( + ApiTestData.getEmptyLockAccountDto(), + DataStatus.INVALID, + ApiTestData.invalidBodyLockAccountFailry() + ), + + new ApiTestArgumentsHolder( + ApiTestData.getLockAccountDtoForMissingUser(), + DataStatus.NOT_FOUND, + ApiTestData.lockAccountFailry() + ) + ).map(Arguments::of); + } +} diff --git a/src/test/java/com/digitalsanctuary/spring/user/api/provider/holder/ApiTestArgumentsHolder.java b/src/test/java/com/digitalsanctuary/spring/user/api/provider/holder/ApiTestArgumentsHolder.java index fde8ee8..18915c4 100644 --- a/src/test/java/com/digitalsanctuary/spring/user/api/provider/holder/ApiTestArgumentsHolder.java +++ b/src/test/java/com/digitalsanctuary/spring/user/api/provider/holder/ApiTestArgumentsHolder.java @@ -2,6 +2,7 @@ import com.digitalsanctuary.spring.user.api.data.DataStatus; import com.digitalsanctuary.spring.user.api.data.Response; +import com.digitalsanctuary.spring.user.dto.LockAccountDto; import com.digitalsanctuary.spring.user.dto.PasswordDto; import com.digitalsanctuary.spring.user.dto.UserDto; @@ -9,6 +10,7 @@ public class ApiTestArgumentsHolder { private UserDto userDto; private PasswordDto passwordDto; + private LockAccountDto lockAccountDto; private final DataStatus status; private final Response response; @@ -30,6 +32,12 @@ public ApiTestArgumentsHolder(DataStatus status, Response response) { this.response = response; } + public ApiTestArgumentsHolder(LockAccountDto lockAccountDto, DataStatus status, Response response) { + this.lockAccountDto = lockAccountDto; + this.status = status; + this.response = response; + } + public UserDto getUserDto() { return userDto; } @@ -38,6 +46,10 @@ public DataStatus getStatus() { return status; } + public LockAccountDto getLockAccountDto() { + return lockAccountDto; + } + public Response getResponse() { return response; }