Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: build admin lock/unlock account flow #171

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
78 changes: 78 additions & 0 deletions src/main/java/com/digitalsanctuary/spring/user/api/AdminApi.java
Original file line number Diff line number Diff line change
@@ -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<JSONResponse> 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<JSONResponse> 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");
}
}
}
28 changes: 5 additions & 23 deletions src/main/java/com/digitalsanctuary/spring/user/api/UserAPI.java
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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<JSONResponse> 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<JSONResponse> buildSuccessResponse(String message, String redirectUrl) {
return ResponseEntity.ok(JSONResponse.builder().success(true).code(0).message(message).redirectUrl(redirectUrl).build());
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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");
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ public class WebSecurityConfig {
private final RolesAndPrivilegesConfig rolesAndPrivilegesConfig;
private final DSOAuth2UserService dsOAuth2UserService;
private final DSOidcUserService dsOidcUserService;
private final CustomAuthenticationFailureHandler customAuthenticationFailureHandler;

/**
*
Expand All @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down Expand Up @@ -436,7 +485,4 @@ private void storeSecurityContextInSession() {
// Store the security context in the session
session.setAttribute("SPRING_SECURITY_CONTEXT", SecurityContextHolder.getContext());
}



}
Original file line number Diff line number Diff line change
@@ -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<JSONResponse> 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<JSONResponse> buildSuccessResponse(String message, String redirectUrl) {
return ResponseEntity.ok(JSONResponse.builder().success(true).code(0).message(message).redirectUrl(redirectUrl).build());
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Loading