From be026d9827e1da79f08f73d68d6fc02dfe379f87 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Tue, 25 Nov 2025 15:16:51 +0000 Subject: [PATCH] Add error handling and validation to Spring Boot banking application - Add custom exception classes (AccountNotFoundException, InsufficientFundsException, DuplicateUsernameException, InvalidAmountException, InvalidInputException) - Add global exception handler with @ControllerAdvice for centralized error handling - Add validation DTOs with Bean Validation annotations (RegistrationRequest, TransferRequest, AmountRequest) - Add input validation with regex patterns similar to codeCheckout.groovy (alphanumeric, underscores, dots, hyphens) - Update AccountService with proper exception handling, logging, and @Transactional annotations - Add custom security handlers (CustomAuthenticationFailureHandler, CustomAccessDeniedHandler, CustomAuthenticationEntryPoint) - Update SecurityConfig to use custom security handlers - Add error.html template for displaying error messages - Add spring-boot-starter-validation dependency to pom.xml - Remove -DskipTests=true flag from Dockerfile to enable test execution in CI/CD Co-Authored-By: Cindy Huang --- Dockerfile | 4 +- pom.xml | 4 + .../config/CustomAccessDeniedHandler.java | 37 +++ .../CustomAuthenticationEntryPoint.java | 40 +++ .../CustomAuthenticationFailureHandler.java | 49 ++++ .../bankapp/config/SecurityConfig.java | 26 +- .../example/bankapp/dto/AmountRequest.java | 28 ++ .../bankapp/dto/RegistrationRequest.java | 41 +++ .../example/bankapp/dto/TransferRequest.java | 43 +++ .../exception/AccountNotFoundException.java | 12 + .../exception/DuplicateUsernameException.java | 12 + .../bankapp/exception/ErrorResponse.java | 64 +++++ .../exception/GlobalExceptionHandler.java | 152 +++++++++++ .../exception/InsufficientFundsException.java | 12 + .../exception/InvalidAmountException.java | 12 + .../exception/InvalidInputException.java | 12 + .../bankapp/service/AccountService.java | 254 +++++++++++++----- src/main/resources/templates/error.html | 142 ++++++++++ 18 files changed, 873 insertions(+), 71 deletions(-) create mode 100644 src/main/java/com/example/bankapp/config/CustomAccessDeniedHandler.java create mode 100644 src/main/java/com/example/bankapp/config/CustomAuthenticationEntryPoint.java create mode 100644 src/main/java/com/example/bankapp/config/CustomAuthenticationFailureHandler.java create mode 100644 src/main/java/com/example/bankapp/dto/AmountRequest.java create mode 100644 src/main/java/com/example/bankapp/dto/RegistrationRequest.java create mode 100644 src/main/java/com/example/bankapp/dto/TransferRequest.java create mode 100644 src/main/java/com/example/bankapp/exception/AccountNotFoundException.java create mode 100644 src/main/java/com/example/bankapp/exception/DuplicateUsernameException.java create mode 100644 src/main/java/com/example/bankapp/exception/ErrorResponse.java create mode 100644 src/main/java/com/example/bankapp/exception/GlobalExceptionHandler.java create mode 100644 src/main/java/com/example/bankapp/exception/InsufficientFundsException.java create mode 100644 src/main/java/com/example/bankapp/exception/InvalidAmountException.java create mode 100644 src/main/java/com/example/bankapp/exception/InvalidInputException.java create mode 100644 src/main/resources/templates/error.html diff --git a/Dockerfile b/Dockerfile index 079acabe..e0b53ba7 100644 --- a/Dockerfile +++ b/Dockerfile @@ -17,8 +17,8 @@ WORKDIR /src # Copy source code from local to container COPY . /src -# Build application and skip test cases -RUN mvn clean install -DskipTests=true +# Build application and run tests +RUN mvn clean install #-------------------------------------- # Stage 2 diff --git a/pom.xml b/pom.xml index fc5bfeac..775a24d7 100644 --- a/pom.xml +++ b/pom.xml @@ -46,6 +46,10 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-validation + org.thymeleaf.extras thymeleaf-extras-springsecurity6 diff --git a/src/main/java/com/example/bankapp/config/CustomAccessDeniedHandler.java b/src/main/java/com/example/bankapp/config/CustomAccessDeniedHandler.java new file mode 100644 index 00000000..9eb77cbb --- /dev/null +++ b/src/main/java/com/example/bankapp/config/CustomAccessDeniedHandler.java @@ -0,0 +1,37 @@ +package com.example.bankapp.config; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.access.AccessDeniedHandler; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAccessDeniedHandler implements AccessDeniedHandler { + + private static final Logger logger = LoggerFactory.getLogger(CustomAccessDeniedHandler.class); + + @Override + public void handle(HttpServletRequest request, + HttpServletResponse response, + AccessDeniedException accessDeniedException) throws IOException, ServletException { + + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + String username = auth != null ? auth.getName() : "anonymous"; + String requestUri = request.getRequestURI(); + + logger.error("Access denied for user: {} - Attempted to access: {} - Error: {}", + username, requestUri, accessDeniedException.getMessage()); + + request.setAttribute("error", "You do not have permission to access this resource"); + request.setAttribute("status", HttpServletResponse.SC_FORBIDDEN); + request.getRequestDispatcher("/error").forward(request, response); + } +} diff --git a/src/main/java/com/example/bankapp/config/CustomAuthenticationEntryPoint.java b/src/main/java/com/example/bankapp/config/CustomAuthenticationEntryPoint.java new file mode 100644 index 00000000..93650f68 --- /dev/null +++ b/src/main/java/com/example/bankapp/config/CustomAuthenticationEntryPoint.java @@ -0,0 +1,40 @@ +package com.example.bankapp.config; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.core.AuthenticationException; +import org.springframework.security.web.AuthenticationEntryPoint; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +public class CustomAuthenticationEntryPoint implements AuthenticationEntryPoint { + + private static final Logger logger = LoggerFactory.getLogger(CustomAuthenticationEntryPoint.class); + + @Override + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException authException) throws IOException, ServletException { + + String requestUri = request.getRequestURI(); + logger.warn("Unauthorized access attempt to: {} - Error: {}", requestUri, authException.getMessage()); + + if (isAjaxRequest(request)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.setContentType("application/json"); + response.getWriter().write("{\"error\": \"Unauthorized\", \"message\": \"Please log in to access this resource\"}"); + } else { + response.sendRedirect("/login"); + } + } + + private boolean isAjaxRequest(HttpServletRequest request) { + String ajaxHeader = request.getHeader("X-Requested-With"); + return "XMLHttpRequest".equals(ajaxHeader); + } +} diff --git a/src/main/java/com/example/bankapp/config/CustomAuthenticationFailureHandler.java b/src/main/java/com/example/bankapp/config/CustomAuthenticationFailureHandler.java new file mode 100644 index 00000000..422ab357 --- /dev/null +++ b/src/main/java/com/example/bankapp/config/CustomAuthenticationFailureHandler.java @@ -0,0 +1,49 @@ +package com.example.bankapp.config; + +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +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; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; + +@Component +public class CustomAuthenticationFailureHandler implements AuthenticationFailureHandler { + + private static final Logger logger = LoggerFactory.getLogger(CustomAuthenticationFailureHandler.class); + + @Override + public void onAuthenticationFailure(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException, ServletException { + + String username = request.getParameter("username"); + String errorMessage; + + if (exception instanceof BadCredentialsException) { + logger.error("Authentication failed - bad credentials for user: {}", username); + errorMessage = "Invalid username or password"; + } else if (exception instanceof DisabledException) { + logger.error("Authentication failed - account disabled for user: {}", username); + errorMessage = "Your account has been disabled"; + } else if (exception instanceof LockedException) { + logger.error("Authentication failed - account locked for user: {}", username); + errorMessage = "Your account has been locked"; + } else { + logger.error("Authentication failed for user: {} - Error: {}", username, exception.getMessage()); + errorMessage = "Authentication failed. Please try again."; + } + + String encodedMessage = URLEncoder.encode(errorMessage, StandardCharsets.UTF_8); + response.sendRedirect("/login?error=true&message=" + encodedMessage); + } +} diff --git a/src/main/java/com/example/bankapp/config/SecurityConfig.java b/src/main/java/com/example/bankapp/config/SecurityConfig.java index 4dbd1572..d9b503c2 100644 --- a/src/main/java/com/example/bankapp/config/SecurityConfig.java +++ b/src/main/java/com/example/bankapp/config/SecurityConfig.java @@ -1,6 +1,8 @@ package com.example.bankapp.config; import com.example.bankapp.service.AccountService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -16,9 +18,20 @@ @EnableWebSecurity public class SecurityConfig { + private static final Logger logger = LoggerFactory.getLogger(SecurityConfig.class); + @Autowired AccountService accountService; + @Autowired + private CustomAuthenticationFailureHandler authenticationFailureHandler; + + @Autowired + private CustomAccessDeniedHandler accessDeniedHandler; + + @Autowired + private CustomAuthenticationEntryPoint authenticationEntryPoint; + @Bean public static PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); @@ -26,16 +39,19 @@ public static PasswordEncoder passwordEncoder() { @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + logger.info("Configuring security filter chain"); + http .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(authz -> authz - .requestMatchers("/register").permitAll() + .requestMatchers("/register", "/error", "/images/**").permitAll() .anyRequest().authenticated() ) .formLogin(form -> form .loginPage("/login") .loginProcessingUrl("/login") .defaultSuccessUrl("/dashboard", true) + .failureHandler(authenticationFailureHandler) .permitAll() ) .logout(logout -> logout @@ -45,6 +61,10 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .logoutSuccessUrl("/login?logout") .permitAll() ) + .exceptionHandling(exception -> exception + .accessDeniedHandler(accessDeniedHandler) + .authenticationEntryPoint(authenticationEntryPoint) + ) .headers(headers -> headers .frameOptions(frameOptions -> frameOptions.sameOrigin()) ); @@ -54,7 +74,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti @Autowired public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception { + logger.info("Configuring global authentication"); auth.userDetailsService(accountService).passwordEncoder(passwordEncoder()); - } -} \ No newline at end of file +} diff --git a/src/main/java/com/example/bankapp/dto/AmountRequest.java b/src/main/java/com/example/bankapp/dto/AmountRequest.java new file mode 100644 index 00000000..6a49431b --- /dev/null +++ b/src/main/java/com/example/bankapp/dto/AmountRequest.java @@ -0,0 +1,28 @@ +package com.example.bankapp.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotNull; + +import java.math.BigDecimal; + +public class AmountRequest { + + @NotNull(message = "Amount is required") + @DecimalMin(value = "0.01", message = "Amount must be greater than zero") + private BigDecimal amount; + + public AmountRequest() { + } + + public AmountRequest(BigDecimal amount) { + this.amount = amount; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } +} diff --git a/src/main/java/com/example/bankapp/dto/RegistrationRequest.java b/src/main/java/com/example/bankapp/dto/RegistrationRequest.java new file mode 100644 index 00000000..fe8e94c6 --- /dev/null +++ b/src/main/java/com/example/bankapp/dto/RegistrationRequest.java @@ -0,0 +1,41 @@ +package com.example.bankapp.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public class RegistrationRequest { + + @NotBlank(message = "Username is required") + @Size(min = 3, max = 50, message = "Username must be between 3 and 50 characters") + @Pattern(regexp = "^[\\w.-]+$", message = "Username can only contain alphanumeric characters, underscores, dots, and hyphens") + private String username; + + @NotBlank(message = "Password is required") + @Size(min = 6, max = 100, message = "Password must be between 6 and 100 characters") + private String password; + + public RegistrationRequest() { + } + + public RegistrationRequest(String username, String password) { + this.username = username; + this.password = password; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } +} diff --git a/src/main/java/com/example/bankapp/dto/TransferRequest.java b/src/main/java/com/example/bankapp/dto/TransferRequest.java new file mode 100644 index 00000000..f119ec7d --- /dev/null +++ b/src/main/java/com/example/bankapp/dto/TransferRequest.java @@ -0,0 +1,43 @@ +package com.example.bankapp.dto; + +import jakarta.validation.constraints.DecimalMin; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; + +import java.math.BigDecimal; + +public class TransferRequest { + + @NotBlank(message = "Recipient username is required") + @Pattern(regexp = "^[\\w.-]+$", message = "Recipient username can only contain alphanumeric characters, underscores, dots, and hyphens") + private String toUsername; + + @NotNull(message = "Amount is required") + @DecimalMin(value = "0.01", message = "Amount must be greater than zero") + private BigDecimal amount; + + public TransferRequest() { + } + + public TransferRequest(String toUsername, BigDecimal amount) { + this.toUsername = toUsername; + this.amount = amount; + } + + public String getToUsername() { + return toUsername; + } + + public void setToUsername(String toUsername) { + this.toUsername = toUsername; + } + + public BigDecimal getAmount() { + return amount; + } + + public void setAmount(BigDecimal amount) { + this.amount = amount; + } +} diff --git a/src/main/java/com/example/bankapp/exception/AccountNotFoundException.java b/src/main/java/com/example/bankapp/exception/AccountNotFoundException.java new file mode 100644 index 00000000..8d3bd77b --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/AccountNotFoundException.java @@ -0,0 +1,12 @@ +package com.example.bankapp.exception; + +public class AccountNotFoundException extends RuntimeException { + + public AccountNotFoundException(String message) { + super(message); + } + + public AccountNotFoundException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/example/bankapp/exception/DuplicateUsernameException.java b/src/main/java/com/example/bankapp/exception/DuplicateUsernameException.java new file mode 100644 index 00000000..3926dee8 --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/DuplicateUsernameException.java @@ -0,0 +1,12 @@ +package com.example.bankapp.exception; + +public class DuplicateUsernameException extends RuntimeException { + + public DuplicateUsernameException(String message) { + super(message); + } + + public DuplicateUsernameException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/example/bankapp/exception/ErrorResponse.java b/src/main/java/com/example/bankapp/exception/ErrorResponse.java new file mode 100644 index 00000000..2bc36d82 --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/ErrorResponse.java @@ -0,0 +1,64 @@ +package com.example.bankapp.exception; + +import java.time.LocalDateTime; + +public class ErrorResponse { + + private LocalDateTime timestamp; + private int status; + private String error; + private String message; + private String path; + + public ErrorResponse() { + this.timestamp = LocalDateTime.now(); + } + + public ErrorResponse(int status, String error, String message, String path) { + this.timestamp = LocalDateTime.now(); + this.status = status; + this.error = error; + this.message = message; + this.path = path; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } + + public int getStatus() { + return status; + } + + public void setStatus(int status) { + this.status = status; + } + + public String getError() { + return error; + } + + public void setError(String error) { + this.error = error; + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getPath() { + return path; + } + + public void setPath(String path) { + this.path = path; + } +} diff --git a/src/main/java/com/example/bankapp/exception/GlobalExceptionHandler.java b/src/main/java/com/example/bankapp/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..c59f4730 --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/GlobalExceptionHandler.java @@ -0,0 +1,152 @@ +package com.example.bankapp.exception; + +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.AccessDeniedException; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.core.AuthenticationException; +import org.springframework.ui.Model; +import org.springframework.validation.BindException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; + +import java.util.stream.Collectors; + +@ControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + @ExceptionHandler(AccountNotFoundException.class) + @ResponseStatus(HttpStatus.NOT_FOUND) + public String handleAccountNotFoundException(AccountNotFoundException ex, + HttpServletRequest request, + Model model) { + logger.error("Account not found error: {} - Path: {}", ex.getMessage(), request.getRequestURI()); + model.addAttribute("error", ex.getMessage()); + model.addAttribute("status", HttpStatus.NOT_FOUND.value()); + return "error"; + } + + @ExceptionHandler(InsufficientFundsException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String handleInsufficientFundsException(InsufficientFundsException ex, + HttpServletRequest request, + Model model) { + logger.error("Insufficient funds error: {} - Path: {}", ex.getMessage(), request.getRequestURI()); + model.addAttribute("error", ex.getMessage()); + model.addAttribute("status", HttpStatus.BAD_REQUEST.value()); + return "error"; + } + + @ExceptionHandler(DuplicateUsernameException.class) + @ResponseStatus(HttpStatus.CONFLICT) + public String handleDuplicateUsernameException(DuplicateUsernameException ex, + HttpServletRequest request, + Model model) { + logger.error("Duplicate username error: {} - Path: {}", ex.getMessage(), request.getRequestURI()); + model.addAttribute("error", ex.getMessage()); + model.addAttribute("status", HttpStatus.CONFLICT.value()); + return "error"; + } + + @ExceptionHandler(InvalidAmountException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String handleInvalidAmountException(InvalidAmountException ex, + HttpServletRequest request, + Model model) { + logger.error("Invalid amount error: {} - Path: {}", ex.getMessage(), request.getRequestURI()); + model.addAttribute("error", ex.getMessage()); + model.addAttribute("status", HttpStatus.BAD_REQUEST.value()); + return "error"; + } + + @ExceptionHandler(InvalidInputException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String handleInvalidInputException(InvalidInputException ex, + HttpServletRequest request, + Model model) { + logger.error("Invalid input error: {} - Path: {}", ex.getMessage(), request.getRequestURI()); + model.addAttribute("error", ex.getMessage()); + model.addAttribute("status", HttpStatus.BAD_REQUEST.value()); + return "error"; + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String handleValidationException(MethodArgumentNotValidException ex, + HttpServletRequest request, + Model model) { + String errorMessage = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + logger.error("Validation error: {} - Path: {}", errorMessage, request.getRequestURI()); + model.addAttribute("error", errorMessage); + model.addAttribute("status", HttpStatus.BAD_REQUEST.value()); + return "error"; + } + + @ExceptionHandler(BindException.class) + @ResponseStatus(HttpStatus.BAD_REQUEST) + public String handleBindException(BindException ex, + HttpServletRequest request, + Model model) { + String errorMessage = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + logger.error("Binding error: {} - Path: {}", errorMessage, request.getRequestURI()); + model.addAttribute("error", errorMessage); + model.addAttribute("status", HttpStatus.BAD_REQUEST.value()); + return "error"; + } + + @ExceptionHandler(AccessDeniedException.class) + @ResponseStatus(HttpStatus.FORBIDDEN) + public String handleAccessDeniedException(AccessDeniedException ex, + HttpServletRequest request, + Model model) { + logger.error("Access denied error: {} - Path: {}", ex.getMessage(), request.getRequestURI()); + model.addAttribute("error", "You do not have permission to access this resource"); + model.addAttribute("status", HttpStatus.FORBIDDEN.value()); + return "error"; + } + + @ExceptionHandler(AuthenticationException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public String handleAuthenticationException(AuthenticationException ex, + HttpServletRequest request, + Model model) { + logger.error("Authentication error: {} - Path: {}", ex.getMessage(), request.getRequestURI()); + model.addAttribute("error", "Authentication failed. Please check your credentials."); + model.addAttribute("status", HttpStatus.UNAUTHORIZED.value()); + return "error"; + } + + @ExceptionHandler(BadCredentialsException.class) + @ResponseStatus(HttpStatus.UNAUTHORIZED) + public String handleBadCredentialsException(BadCredentialsException ex, + HttpServletRequest request, + Model model) { + logger.error("Bad credentials error: {} - Path: {}", ex.getMessage(), request.getRequestURI()); + model.addAttribute("error", "Invalid username or password"); + model.addAttribute("status", HttpStatus.UNAUTHORIZED.value()); + return "error"; + } + + @ExceptionHandler(Exception.class) + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + public String handleGenericException(Exception ex, + HttpServletRequest request, + Model model) { + logger.error("Unexpected error occurred: {} - Path: {} - Exception: {}", + ex.getMessage(), request.getRequestURI(), ex.getClass().getName(), ex); + model.addAttribute("error", "An unexpected error occurred. Please try again later."); + model.addAttribute("status", HttpStatus.INTERNAL_SERVER_ERROR.value()); + return "error"; + } +} diff --git a/src/main/java/com/example/bankapp/exception/InsufficientFundsException.java b/src/main/java/com/example/bankapp/exception/InsufficientFundsException.java new file mode 100644 index 00000000..03010e7e --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/InsufficientFundsException.java @@ -0,0 +1,12 @@ +package com.example.bankapp.exception; + +public class InsufficientFundsException extends RuntimeException { + + public InsufficientFundsException(String message) { + super(message); + } + + public InsufficientFundsException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/example/bankapp/exception/InvalidAmountException.java b/src/main/java/com/example/bankapp/exception/InvalidAmountException.java new file mode 100644 index 00000000..b42f14c6 --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/InvalidAmountException.java @@ -0,0 +1,12 @@ +package com.example.bankapp.exception; + +public class InvalidAmountException extends RuntimeException { + + public InvalidAmountException(String message) { + super(message); + } + + public InvalidAmountException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/example/bankapp/exception/InvalidInputException.java b/src/main/java/com/example/bankapp/exception/InvalidInputException.java new file mode 100644 index 00000000..b74c48d6 --- /dev/null +++ b/src/main/java/com/example/bankapp/exception/InvalidInputException.java @@ -0,0 +1,12 @@ +package com.example.bankapp.exception; + +public class InvalidInputException extends RuntimeException { + + public InvalidInputException(String message) { + super(message); + } + + public InvalidInputException(String message, Throwable cause) { + super(message, cause); + } +} diff --git a/src/main/java/com/example/bankapp/service/AccountService.java b/src/main/java/com/example/bankapp/service/AccountService.java index 5d7d90ec..e9c39e72 100644 --- a/src/main/java/com/example/bankapp/service/AccountService.java +++ b/src/main/java/com/example/bankapp/service/AccountService.java @@ -1,9 +1,16 @@ package com.example.bankapp.service; +import com.example.bankapp.exception.AccountNotFoundException; +import com.example.bankapp.exception.DuplicateUsernameException; +import com.example.bankapp.exception.InsufficientFundsException; +import com.example.bankapp.exception.InvalidAmountException; +import com.example.bankapp.exception.InvalidInputException; import com.example.bankapp.model.Account; import com.example.bankapp.model.Transaction; import com.example.bankapp.repository.AccountRepository; import com.example.bankapp.repository.TransactionRepository; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -12,16 +19,21 @@ import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.Arrays; import java.util.Collection; import java.util.List; +import java.util.regex.Pattern; @Service public class AccountService implements UserDetailsService { + private static final Logger logger = LoggerFactory.getLogger(AccountService.class); + private static final Pattern USERNAME_PATTERN = Pattern.compile("^[\\w.-]+$"); + @Autowired PasswordEncoder passwordEncoder; @@ -32,106 +44,216 @@ public class AccountService implements UserDetailsService { private TransactionRepository transactionRepository; public Account findAccountByUsername(String username) { - return accountRepository.findByUsername(username).orElseThrow(() -> new RuntimeException("Account not found")); + logger.debug("Finding account by username: {}", username); + validateUsername(username); + return accountRepository.findByUsername(username) + .orElseThrow(() -> { + logger.error("Account not found for username: {}", username); + return new AccountNotFoundException("Account not found for username: " + username); + }); } + @Transactional public Account registerAccount(String username, String password) { + logger.info("Attempting to register new account for username: {}", username); + + validateUsername(username); + validatePassword(password); + if (accountRepository.findByUsername(username).isPresent()) { - throw new RuntimeException("Username already exists"); + logger.error("Registration failed - username already exists: {}", username); + throw new DuplicateUsernameException("Username already exists: " + username); } - Account account = new Account(); - account.setUsername(username); - account.setPassword(passwordEncoder.encode(password)); // Encrypt password - account.setBalance(BigDecimal.ZERO); // Initial balance set to 0 - return accountRepository.save(account); + try { + Account account = new Account(); + account.setUsername(username); + account.setPassword(passwordEncoder.encode(password)); + account.setBalance(BigDecimal.ZERO); + Account savedAccount = accountRepository.save(account); + logger.info("Successfully registered new account for username: {}", username); + return savedAccount; + } catch (Exception e) { + logger.error("Failed to register account for username: {} - Error: {}", username, e.getMessage(), e); + throw new RuntimeException("Failed to register account: " + e.getMessage(), e); + } } - + @Transactional public void deposit(Account account, BigDecimal amount) { - account.setBalance(account.getBalance().add(amount)); - accountRepository.save(account); - - Transaction transaction = new Transaction( - amount, - "Deposit", - LocalDateTime.now(), - account - ); - transactionRepository.save(transaction); + logger.info("Processing deposit for account: {} - Amount: {}", account.getUsername(), amount); + + validateAmount(amount); + + try { + account.setBalance(account.getBalance().add(amount)); + accountRepository.save(account); + + Transaction transaction = new Transaction( + amount, + "Deposit", + LocalDateTime.now(), + account + ); + transactionRepository.save(transaction); + logger.info("Deposit successful for account: {} - New balance: {}", + account.getUsername(), account.getBalance()); + } catch (Exception e) { + logger.error("Deposit failed for account: {} - Amount: {} - Error: {}", + account.getUsername(), amount, e.getMessage(), e); + throw new RuntimeException("Failed to process deposit: " + e.getMessage(), e); + } } + @Transactional public void withdraw(Account account, BigDecimal amount) { + logger.info("Processing withdrawal for account: {} - Amount: {}", account.getUsername(), amount); + + validateAmount(amount); + if (account.getBalance().compareTo(amount) < 0) { - throw new RuntimeException("Insufficient funds"); + logger.error("Withdrawal failed - insufficient funds for account: {} - Balance: {} - Requested: {}", + account.getUsername(), account.getBalance(), amount); + throw new InsufficientFundsException( + "Insufficient funds. Current balance: " + account.getBalance() + ", Requested: " + amount); + } + + try { + account.setBalance(account.getBalance().subtract(amount)); + accountRepository.save(account); + + Transaction transaction = new Transaction( + amount, + "Withdrawal", + LocalDateTime.now(), + account + ); + transactionRepository.save(transaction); + logger.info("Withdrawal successful for account: {} - New balance: {}", + account.getUsername(), account.getBalance()); + } catch (Exception e) { + logger.error("Withdrawal failed for account: {} - Amount: {} - Error: {}", + account.getUsername(), amount, e.getMessage(), e); + throw new RuntimeException("Failed to process withdrawal: " + e.getMessage(), e); } - account.setBalance(account.getBalance().subtract(amount)); - accountRepository.save(account); - - Transaction transaction = new Transaction( - amount, - "Withdrawal", - LocalDateTime.now(), - account - ); - transactionRepository.save(transaction); } public List getTransactionHistory(Account account) { + logger.debug("Retrieving transaction history for account: {}", account.getUsername()); return transactionRepository.findByAccountId(account.getId()); } @Override public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { - - Account account = findAccountByUsername(username); - if (account == null) { - throw new UsernameNotFoundException("Username or Password not found"); + logger.debug("Loading user details for username: {}", username); + + try { + Account account = findAccountByUsername(username); + return new Account( + account.getUsername(), + account.getPassword(), + account.getBalance(), + account.getTransactions(), + authorities()); + } catch (AccountNotFoundException e) { + logger.error("User not found during authentication: {}", username); + throw new UsernameNotFoundException("Username or Password not found", e); } - return new Account( - account.getUsername(), - account.getPassword(), - account.getBalance(), - account.getTransactions(), - authorities()); } public Collection authorities() { return Arrays.asList(new SimpleGrantedAuthority("USER")); } + @Transactional public void transferAmount(Account fromAccount, String toUsername, BigDecimal amount) { + logger.info("Processing transfer from: {} to: {} - Amount: {}", + fromAccount.getUsername(), toUsername, amount); + + validateUsername(toUsername); + validateAmount(amount); + + if (fromAccount.getUsername().equals(toUsername)) { + logger.error("Transfer failed - cannot transfer to same account: {}", fromAccount.getUsername()); + throw new InvalidInputException("Cannot transfer to the same account"); + } + if (fromAccount.getBalance().compareTo(amount) < 0) { - throw new RuntimeException("Insufficient funds"); + logger.error("Transfer failed - insufficient funds for account: {} - Balance: {} - Requested: {}", + fromAccount.getUsername(), fromAccount.getBalance(), amount); + throw new InsufficientFundsException( + "Insufficient funds. Current balance: " + fromAccount.getBalance() + ", Requested: " + amount); } Account toAccount = accountRepository.findByUsername(toUsername) - .orElseThrow(() -> new RuntimeException("Recipient account not found")); - - // Deduct from sender's account - fromAccount.setBalance(fromAccount.getBalance().subtract(amount)); - accountRepository.save(fromAccount); - - // Add to recipient's account - toAccount.setBalance(toAccount.getBalance().add(amount)); - accountRepository.save(toAccount); - - // Create transaction records for both accounts - Transaction debitTransaction = new Transaction( - amount, - "Transfer Out to " + toAccount.getUsername(), - LocalDateTime.now(), - fromAccount - ); - transactionRepository.save(debitTransaction); - - Transaction creditTransaction = new Transaction( - amount, - "Transfer In from " + fromAccount.getUsername(), - LocalDateTime.now(), - toAccount - ); - transactionRepository.save(creditTransaction); + .orElseThrow(() -> { + logger.error("Transfer failed - recipient account not found: {}", toUsername); + return new AccountNotFoundException("Recipient account not found: " + toUsername); + }); + + try { + fromAccount.setBalance(fromAccount.getBalance().subtract(amount)); + accountRepository.save(fromAccount); + + toAccount.setBalance(toAccount.getBalance().add(amount)); + accountRepository.save(toAccount); + + Transaction debitTransaction = new Transaction( + amount, + "Transfer Out to " + toAccount.getUsername(), + LocalDateTime.now(), + fromAccount + ); + transactionRepository.save(debitTransaction); + + Transaction creditTransaction = new Transaction( + amount, + "Transfer In from " + fromAccount.getUsername(), + LocalDateTime.now(), + toAccount + ); + transactionRepository.save(creditTransaction); + + logger.info("Transfer successful from: {} to: {} - Amount: {}", + fromAccount.getUsername(), toUsername, amount); + } catch (Exception e) { + logger.error("Transfer failed from: {} to: {} - Amount: {} - Error: {}", + fromAccount.getUsername(), toUsername, amount, e.getMessage(), e); + throw new RuntimeException("Failed to process transfer: " + e.getMessage(), e); + } + } + + private void validateUsername(String username) { + if (username == null || username.trim().isEmpty()) { + logger.error("Validation failed - username is null or empty"); + throw new InvalidInputException("Username cannot be null or empty"); + } + if (!USERNAME_PATTERN.matcher(username).matches()) { + logger.error("Validation failed - invalid username format: {}", username); + throw new InvalidInputException( + "Invalid username format. Username can only contain alphanumeric characters, underscores, dots, and hyphens"); + } + } + + private void validatePassword(String password) { + if (password == null || password.trim().isEmpty()) { + logger.error("Validation failed - password is null or empty"); + throw new InvalidInputException("Password cannot be null or empty"); + } + if (password.length() < 6) { + logger.error("Validation failed - password too short"); + throw new InvalidInputException("Password must be at least 6 characters long"); + } } + private void validateAmount(BigDecimal amount) { + if (amount == null) { + logger.error("Validation failed - amount is null"); + throw new InvalidAmountException("Amount cannot be null"); + } + if (amount.compareTo(BigDecimal.ZERO) <= 0) { + logger.error("Validation failed - amount must be positive: {}", amount); + throw new InvalidAmountException("Amount must be greater than zero"); + } + } } diff --git a/src/main/resources/templates/error.html b/src/main/resources/templates/error.html new file mode 100644 index 00000000..6ecc2dc2 --- /dev/null +++ b/src/main/resources/templates/error.html @@ -0,0 +1,142 @@ + + + + Error - Wells Fargo + + + + + + +
+
+

Something Went Wrong

+ +
+ Error Code: 500 +
+ +
+ An unexpected error occurred. +
+ + +
+ + + + +