From 76b05d93c9c2b132a0a72e0b64d168197d43b0bd Mon Sep 17 00:00:00 2001 From: Bennett Date: Sat, 14 Mar 2026 19:00:41 +0300 Subject: [PATCH 1/5] release: Add wallet module for user wallets --- .../core/entities/contact/Contact.java | 9 +- .../exceptions/GlobalExceptionHandler.java | 5 +- .../core/repositories/WalletRepository.java | 4 +- .../core/security/SecurityConfig.java | 1 + .../flextuma/core/services/BaseService.java | 2 +- .../auth/controllers/AuthController.java | 21 +++++ .../modules/auth/services/UserService.java | 17 ++++ .../finance/services/WalletService.java | 87 +++++++++++++++++-- .../controllers/NotificationController.java | 2 +- .../services/NotificationService.java | 2 +- .../auth/controllers/AuthControllerTest.java | 6 +- 11 files changed, 138 insertions(+), 18 deletions(-) diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/contact/Contact.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/contact/Contact.java index 28a78e6..2ecae6c 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/contact/Contact.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/contact/Contact.java @@ -41,10 +41,11 @@ public class Contact extends Owner { public static final String PLURAL = "contacts"; public static final String NAME_PLURAL = "Contacts"; public static final String NAME_SINGULAR = "Contact"; - public static final String READ = "READ_CONTACTS"; - public static final String ADD = "ADD_CONTACTS"; - public static final String DELETE = "DELETE_CONTACTS"; - public static final String UPDATE = "UPDATE_CONTACTS"; + public static final String ALL = "ALL"; + public static final String READ = ALL; + public static final String ADD = ALL; + public static final String DELETE = ALL; + public static final String UPDATE = ALL; @Column(name = "firstname", nullable = false, updatable = true) private String firstName; diff --git a/src/main/java/com/flexcodelabs/flextuma/core/exceptions/GlobalExceptionHandler.java b/src/main/java/com/flexcodelabs/flextuma/core/exceptions/GlobalExceptionHandler.java index e0941c7..faa73a5 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/exceptions/GlobalExceptionHandler.java @@ -48,12 +48,11 @@ public ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadabl String message = defaultMessage; - if (detailedMessage != null && !detailedMessage.isBlank()) { + if (detailedMessage != null && !detailedMessage.isBlank() + && detailedMessage.contains("not one of the values accepted for Enum class")) { String enumMessage = tryBuildEnumErrorMessage(detailedMessage); if (enumMessage != null) { message = enumMessage; - } else { - message = defaultMessage + " Details: " + detailedMessage; } } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java index 6029b83..116a461 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletRepository.java @@ -3,11 +3,13 @@ import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.entities.finance.Wallet; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.stereotype.Repository; import java.util.Optional; +import java.util.UUID; @Repository -public interface WalletRepository extends JpaRepository { +public interface WalletRepository extends JpaRepository, JpaSpecificationExecutor { Optional findByCreatedBy(User user); } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java index 5d04cc2..26405c7 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/security/SecurityConfig.java @@ -51,6 +51,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) { .csrf(csrf -> csrf.disable()) .authorizeHttpRequests(auth -> auth .requestMatchers("/api/login").permitAll() + .requestMatchers("/api/register").permitAll() .anyRequest().authenticated()) .httpBasic(Customizer.withDefaults()) .addFilterBefore(patAuthenticationFilter, diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java index 9d09019..3d5fb2c 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java @@ -83,7 +83,7 @@ protected void checkPermission(String requiredPermission) { boolean isAuthorized = authorities.contains("SUPER_ADMIN") || authorities.contains(requiredPermission) || - (!isAdminEntity() && authorities.contains("ALL")); + requiredPermission.equals("ALL"); if (!isAuthorized) { throw new AccessDeniedException("You have no permission to access " + getEntityPlural()); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java index 0579d41..c720275 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java @@ -1,6 +1,10 @@ package com.flexcodelabs.flextuma.modules.auth.controllers; +import java.math.BigDecimal; + +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AnonymousAuthenticationToken; @@ -17,8 +21,10 @@ import org.springframework.web.bind.annotation.RestController; import com.flexcodelabs.flextuma.core.dtos.LoginDto; +import com.flexcodelabs.flextuma.core.dtos.RegisterDto; import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.services.CookieService; +import com.flexcodelabs.flextuma.modules.finance.services.WalletService; import com.flexcodelabs.flextuma.modules.auth.services.UserService; import jakarta.servlet.http.HttpServletRequest; @@ -33,6 +39,21 @@ public class AuthController { private final UserService userService; private final CookieService cookieService; + private final WalletService walletService; + + @Value("${flextuma.sms.price-per-segment:1.0}") + private BigDecimal pricePerSegment; + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterDto request) { + + User user = userService.register(request); + + BigDecimal creditAmount = pricePerSegment.multiply(BigDecimal.TEN); + walletService.credit(user, creditAmount, "Registration test SMS credits", "REGISTRATION_BONUS"); + + return ResponseEntity.status(HttpStatus.CREATED).body(user); + } @PostMapping("/login") public ResponseEntity login( diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java index d43b7c4..c13bf8b 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java @@ -9,6 +9,7 @@ import org.springframework.web.server.ResponseStatusException; import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.dtos.RegisterDto; import com.flexcodelabs.flextuma.core.repositories.UserRepository; import com.flexcodelabs.flextuma.core.services.BaseService; @@ -93,4 +94,20 @@ public User findByUsername(String username) { "User with username " + username + " not found")); } + public User register(RegisterDto request) { + repository.findByUsername(request.getUsername()).ifPresent(u -> { + throw new ResponseStatusException(HttpStatus.CONFLICT, + "User with username " + request.getUsername() + " already exists"); + }); + + User user = new User(); + user.setName(request.getName()); + user.setUsername(request.getUsername()); + user.setPassword(request.getPassword()); + user.setPhoneNumber(request.getPhoneNumber()); + user.setEmail(request.getEmail()); + + return repository.save(user); + } + } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java b/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java index 75a12b0..2d6d1f7 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/finance/services/WalletService.java @@ -6,7 +6,12 @@ import com.flexcodelabs.flextuma.core.enums.TransactionType; import com.flexcodelabs.flextuma.core.repositories.WalletRepository; import com.flexcodelabs.flextuma.core.repositories.WalletTransactionRepository; +import com.flexcodelabs.flextuma.core.services.BaseService; + import lombok.RequiredArgsConstructor; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -14,16 +19,19 @@ import java.math.BigDecimal; import java.util.Optional; +import java.util.UUID; @Service @RequiredArgsConstructor -public class WalletService { +public class WalletService extends BaseService { - private final WalletRepository walletRepository; + private final WalletRepository repository; private final WalletTransactionRepository transactionRepository; + + public Wallet getOrCreateWallet(User user) { - Optional optionalWallet = walletRepository.findByCreatedBy(user); + Optional optionalWallet = repository.findByCreatedBy(user); if (optionalWallet.isPresent()) { return optionalWallet.get(); } @@ -33,7 +41,7 @@ public Wallet getOrCreateWallet(User user) { newWallet.setCurrency("TZS"); newWallet.setCreatedBy(user); - return walletRepository.save(newWallet); + return repository.save(newWallet); } @Transactional @@ -49,7 +57,7 @@ public WalletTransaction debit(User user, BigDecimal amount, String description, } wallet.setBalance(wallet.getBalance().subtract(amount)); - Wallet savedWallet = walletRepository.save(wallet); + Wallet savedWallet = repository.save(wallet); WalletTransaction transaction = new WalletTransaction(); transaction.setWallet(savedWallet); @@ -71,7 +79,7 @@ public WalletTransaction credit(User user, BigDecimal amount, String description Wallet wallet = getOrCreateWallet(user); wallet.setBalance(wallet.getBalance().add(amount)); - Wallet savedWallet = walletRepository.save(wallet); + Wallet savedWallet = repository.save(wallet); WalletTransaction transaction = new WalletTransaction(); transaction.setWallet(savedWallet); @@ -83,4 +91,71 @@ public WalletTransaction credit(User user, BigDecimal amount, String description return transactionRepository.save(transaction); } + + + @Override + protected boolean isAdminEntity() { + return false; + } + + @Override + protected JpaRepository getRepository() { + return repository; + } + + @Override + protected JpaSpecificationExecutor getRepositoryAsExecutor() { + return repository; + } + + @Override + protected String getReadPermission() { + return Wallet.READ; + } + + @Override + protected String getAddPermission() { + return Wallet.ADD; + } + + @Override + protected String getUpdatePermission() { + return Wallet.UPDATE; + } + + @Override + protected String getDeletePermission() { + return Wallet.DELETE; + } + + @Override + public String getEntityPlural() { + return Wallet.NAME_PLURAL; + } + + @Override + public String getPropertyName() { + return Wallet.PLURAL; + } + + @Override + protected String getEntitySingular() { + return Wallet.NAME_SINGULAR; + } + + @Override + protected void validateDelete(Wallet entity) throws ResponseStatusException { + throw new ResponseStatusException(HttpStatus.NOT_ACCEPTABLE, "Wallet cannot be deleted"); + + } + + @Override + protected void onPreSave(Wallet entity) throws ResponseStatusException { + throw new ResponseStatusException(HttpStatus.NOT_ACCEPTABLE, "You cannot create a wallet manually"); + } + + @Override + protected Wallet onPreUpdate(Wallet newEntity, Wallet oldEntity) throws ResponseStatusException { + throw new ResponseStatusException(HttpStatus.NOT_ACCEPTABLE, "You cannot update a wallet manually"); + } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java index 12abd02..d2b36b2 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/controllers/NotificationController.java @@ -16,7 +16,7 @@ public class NotificationController { private final NotificationService notificationService; - @PostMapping("") + @PostMapping() public ResponseEntity send( @RequestBody Map variables, java.security.Principal principal) { diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index 812bbbd..c9d5cf0 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -70,7 +70,7 @@ public SmsLog queueRawSms(Map payload, String username) { checkRateLimit(currentUser); String providerValue = getRequiredField(payload, "provider"); - String content = getRequiredField(payload, "content"); + String content = getRequiredField(payload, "message"); String phoneNumber = getRequiredField(payload, "phoneNumber"); SmsConnector connector = getConnector(currentUser, providerValue); diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthControllerTest.java index 3e7a9a3..a422bb2 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthControllerTest.java @@ -23,6 +23,7 @@ import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.services.CookieService; import com.flexcodelabs.flextuma.modules.auth.services.UserService; +import com.flexcodelabs.flextuma.modules.finance.services.WalletService; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.core.Authentication; @@ -38,6 +39,9 @@ class AuthControllerTest { @Mock private CookieService cookieService; + @Mock + private WalletService walletService; + @Mock private SecurityContext securityContext; @@ -48,7 +52,7 @@ class AuthControllerTest { @BeforeEach void setUp() { - AuthController controller = new AuthController(userService, cookieService); + AuthController controller = new AuthController(userService, cookieService, walletService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } From 2c018fbe538cc8d8904d4e43c7fe3a5df683eba9 Mon Sep 17 00:00:00 2001 From: Bennett Date: Sat, 14 Mar 2026 23:51:03 +0300 Subject: [PATCH 2/5] Add rate limiting and improve validation on auth --- .../flextuma/core/dto/ApiResponse.java | 63 +++++ .../flextuma/core/dto/ErrorResponse.java | 85 ++++++ .../flextuma/core/dto/SuccessResponse.java | 23 ++ .../flextuma/core/dtos/RegisterDto.java | 63 +++++ .../flextuma/core/dtos/UserResponseDto.java | 136 ++++++++++ .../core/dtos/VerificationRequestDto.java | 28 ++ .../flextuma/core/entities/auth/User.java | 5 +- .../WalletTransactionRepository.java | 5 +- .../core/services/AuthRateLimitService.java | 129 +++++++++ .../flextuma/core/services/BaseService.java | 2 +- .../core/services/SecurityLogService.java | 110 ++++++++ .../core/services/VerificationService.java | 92 +++++++ .../core/validation/PasswordValidator.java | 44 +++ .../core/validation/ValidPassword.java | 33 +++ .../auth/controllers/AuthController.java | 252 +++++++++++++----- .../finance/controllers/WalletController.java | 16 ++ .../auth/controllers/AuthControllerTest.java | 36 ++- .../sms/services/SmsLogServiceTest.java | 12 - 18 files changed, 1047 insertions(+), 87 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/dto/ApiResponse.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/dto/ErrorResponse.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/dto/SuccessResponse.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/dtos/RegisterDto.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/dtos/UserResponseDto.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/dtos/VerificationRequestDto.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/services/AuthRateLimitService.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/services/SecurityLogService.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/services/VerificationService.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/validation/PasswordValidator.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/validation/ValidPassword.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/finance/controllers/WalletController.java diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dto/ApiResponse.java b/src/main/java/com/flexcodelabs/flextuma/core/dto/ApiResponse.java new file mode 100644 index 0000000..90f1b0d --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/dto/ApiResponse.java @@ -0,0 +1,63 @@ +package com.flexcodelabs.flextuma.core.dto; + +public class ApiResponse { + private boolean success; + private T data; + private ErrorResponse error; + private String message; + + private ApiResponse(boolean success, T data, ErrorResponse error, String message) { + this.success = success; + this.data = data; + this.error = error; + this.message = message; + } + + public static ApiResponse success(T data) { + return new ApiResponse<>(true, data, null, null); + } + + public static ApiResponse success(T data, String message) { + return new ApiResponse<>(true, data, null, message); + } + + public static ApiResponse error(ErrorResponse error) { + return new ApiResponse<>(false, null, error, null); + } + + public static ApiResponse error(ErrorResponse error, String message) { + return new ApiResponse<>(false, null, error, message); + } + + public boolean isSuccess() { + return success; + } + + public T getData() { + return data; + } + + public ErrorResponse getError() { + return error; + } + + public String getMessage() { + return message; + } + + public void setSuccess(boolean success) { + this.success = success; + } + + public void setData(T data) { + this.data = data; + } + + public void setError(ErrorResponse error) { + this.error = error; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dto/ErrorResponse.java b/src/main/java/com/flexcodelabs/flextuma/core/dto/ErrorResponse.java new file mode 100644 index 0000000..c7489e5 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/dto/ErrorResponse.java @@ -0,0 +1,85 @@ +package com.flexcodelabs.flextuma.core.dto; + +import java.time.LocalDateTime; + +public class ErrorResponse { + private String message; + private String errorCode; + private int status; + private LocalDateTime timestamp; + + public ErrorResponse() { + this.timestamp = LocalDateTime.now(); + } + + public ErrorResponse(String message, String errorCode, int status) { + this(); + this.message = message; + this.errorCode = errorCode; + this.status = status; + } + + public static ErrorResponse of(String message, String errorCode, int status) { + return new ErrorResponse(message, errorCode, status); + } + + public static ErrorResponse badRequest(String message) { + return new ErrorResponse(message, "BAD_REQUEST", 400); + } + + public static ErrorResponse unauthorized(String message) { + return new ErrorResponse(message, "UNAUTHORIZED", 401); + } + + public static ErrorResponse forbidden(String message) { + return new ErrorResponse(message, "FORBIDDEN", 403); + } + + public static ErrorResponse notFound(String message) { + return new ErrorResponse(message, "NOT_FOUND", 404); + } + + public static ErrorResponse conflict(String message) { + return new ErrorResponse(message, "CONFLICT", 409); + } + + public static ErrorResponse tooManyRequests(String message) { + return new ErrorResponse(message, "TOO_MANY_REQUESTS", 429); + } + + public static ErrorResponse internalServerError(String message) { + return new ErrorResponse(message, "INTERNAL_SERVER_ERROR", 500); + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } + + public String getErrorCode() { + return errorCode; + } + + public void setErrorCode(String errorCode) { + this.errorCode = errorCode; + } + + public int getStatus() { + return status; + } + + public void setStatus(int status) { + this.status = status; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } + + public void setTimestamp(LocalDateTime timestamp) { + this.timestamp = timestamp; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dto/SuccessResponse.java b/src/main/java/com/flexcodelabs/flextuma/core/dto/SuccessResponse.java new file mode 100644 index 0000000..6d70e50 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/dto/SuccessResponse.java @@ -0,0 +1,23 @@ +package com.flexcodelabs.flextuma.core.dto; + +public class SuccessResponse { + private String message; + + public SuccessResponse() {} + + public SuccessResponse(String message) { + this.message = message; + } + + public static SuccessResponse of(String message) { + return new SuccessResponse(message); + } + + public String getMessage() { + return message; + } + + public void setMessage(String message) { + this.message = message; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dtos/RegisterDto.java b/src/main/java/com/flexcodelabs/flextuma/core/dtos/RegisterDto.java new file mode 100644 index 0000000..6225a99 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/dtos/RegisterDto.java @@ -0,0 +1,63 @@ +package com.flexcodelabs.flextuma.core.dtos; + +import com.flexcodelabs.flextuma.core.validation.ValidPassword; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public class RegisterDto { + + @NotBlank(message = "Name cannot be empty") + private String name; + + @NotBlank(message = "Username cannot be empty") + private String username; + + @ValidPassword + private String password; + + @NotBlank(message = "Phone number cannot be empty") + private String phoneNumber; + + @Email(message = "Email must be valid") + private String email; + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + 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; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dtos/UserResponseDto.java b/src/main/java/com/flexcodelabs/flextuma/core/dtos/UserResponseDto.java new file mode 100644 index 0000000..07c38f9 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/dtos/UserResponseDto.java @@ -0,0 +1,136 @@ +package com.flexcodelabs.flextuma.core.dtos; + +import java.time.LocalDateTime; +import java.util.Set; +import java.util.UUID; +import java.util.stream.Collectors; + +import com.flexcodelabs.flextuma.core.entities.auth.Role; + +public class UserResponseDto { + private UUID id; + private String name; + private String username; + private String phoneNumber; + private String email; + private LocalDateTime lastLogin; + private Boolean verified; + private Boolean active; + private Set roles; + private LocalDateTime created; + private LocalDateTime updated; + + public UserResponseDto() { + // Default constructor for JSON deserialization + } + + public static UserResponseDto fromUser(com.flexcodelabs.flextuma.core.entities.auth.User user) { + UserResponseDto dto = new UserResponseDto(); + dto.setId(user.getId()); + dto.setName(user.getName()); + dto.setUsername(user.getUsername()); + dto.setPhoneNumber(user.getPhoneNumber()); + dto.setEmail(user.getEmail()); + dto.setLastLogin(user.getLastLogin()); + dto.setVerified(user.getVerified()); + dto.setActive(user.getActive()); + dto.setCreated(user.getCreated()); + dto.setUpdated(user.getUpdated()); + + if (user.getRoles() != null) { + dto.setRoles(user.getRoles().stream() + .map(Role::getName) + .collect(Collectors.toSet())); + } + + return dto; + } + + public UUID getId() { + return id; + } + + public void setId(UUID id) { + this.id = id; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public String getUsername() { + return username; + } + + public void setUsername(String username) { + this.username = username; + } + + public String getPhoneNumber() { + return phoneNumber; + } + + public void setPhoneNumber(String phoneNumber) { + this.phoneNumber = phoneNumber; + } + + public String getEmail() { + return email; + } + + public void setEmail(String email) { + this.email = email; + } + + public LocalDateTime getLastLogin() { + return lastLogin; + } + + public void setLastLogin(LocalDateTime lastLogin) { + this.lastLogin = lastLogin; + } + + public Boolean getVerified() { + return verified; + } + + public void setVerified(Boolean verified) { + this.verified = verified; + } + + public Boolean getActive() { + return active; + } + + public void setActive(Boolean active) { + this.active = active; + } + + public Set getRoles() { + return roles; + } + + public void setRoles(Set roles) { + this.roles = roles; + } + + public LocalDateTime getCreated() { + return created; + } + + public void setCreated(LocalDateTime created) { + this.created = created; + } + + public LocalDateTime getUpdated() { + return updated; + } + + public void setUpdated(LocalDateTime updated) { + this.updated = updated; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/dtos/VerificationRequestDto.java b/src/main/java/com/flexcodelabs/flextuma/core/dtos/VerificationRequestDto.java new file mode 100644 index 0000000..836c499 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/dtos/VerificationRequestDto.java @@ -0,0 +1,28 @@ +package com.flexcodelabs.flextuma.core.dtos; + +import jakarta.validation.constraints.NotBlank; + +public class VerificationRequestDto { + + @NotBlank(message = "Identifier cannot be empty") + private String identifier; // email or phone number + + @NotBlank(message = "Code cannot be empty") + private String code; + + public String getIdentifier() { + return identifier; + } + + public void setIdentifier(String identifier) { + this.identifier = identifier; + } + + public String getCode() { + return code; + } + + public void setCode(String code) { + this.code = code; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/User.java b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/User.java index 42fc914..12e2306 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/User.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/entities/auth/User.java @@ -116,9 +116,8 @@ public void onPrePersist() { } this.setActive(true); - if (this.password != null && (this.salt == null)) { - this.salt = BCrypt.gensalt(); - this.password = BCrypt.hashpw(this.password, this.salt); + if (this.password != null) { + this.password = BCrypt.hashpw(this.password, BCrypt.gensalt()); } this.phoneNumber = validateUserPhone(this.phoneNumber); diff --git a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletTransactionRepository.java b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletTransactionRepository.java index 7243bd5..544da9a 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletTransactionRepository.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/repositories/WalletTransactionRepository.java @@ -1,9 +1,12 @@ package com.flexcodelabs.flextuma.core.repositories; import com.flexcodelabs.flextuma.core.entities.finance.WalletTransaction; + +import java.util.UUID; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @Repository -public interface WalletTransactionRepository extends JpaRepository { +public interface WalletTransactionRepository extends JpaRepository { } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/AuthRateLimitService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/AuthRateLimitService.java new file mode 100644 index 0000000..888e454 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/AuthRateLimitService.java @@ -0,0 +1,129 @@ +package com.flexcodelabs.flextuma.core.services; + +import java.time.LocalDateTime; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import jakarta.servlet.http.HttpServletRequest; + +@Service +public class AuthRateLimitService { + + private final ConcurrentHashMap attemptCounts = new ConcurrentHashMap<>(); + private final ConcurrentHashMap blockTimestamps = new ConcurrentHashMap<>(); + private final ConcurrentHashMap lastAttemptTimes = new ConcurrentHashMap<>(); + + @Value("${flextuma.auth.max-attempts:5}") + private int maxAttempts; + + @Value("${flextuma.auth.block-duration-minutes:15}") + private int blockDurationMinutes; + + @Value("${flextuma.auth.window-minutes:5}") + private int windowMinutes; + + public boolean isBlocked(HttpServletRequest request) { + String clientKey = getClientKey(request); + + // Check if client is currently blocked + LocalDateTime blockEndTime = blockTimestamps.get(clientKey); + if (blockEndTime != null && LocalDateTime.now().isBefore(blockEndTime)) { + return true; + } + + // Clear expired block + if (blockEndTime != null && LocalDateTime.now().isAfter(blockEndTime)) { + blockTimestamps.remove(clientKey); + attemptCounts.remove(clientKey); + lastAttemptTimes.remove(clientKey); + } + + return false; + } + + public void recordFailedAttempt(HttpServletRequest request) { + String clientKey = getClientKey(request); + LocalDateTime now = LocalDateTime.now(); + + // Clean up old attempts outside the window + LocalDateTime windowStart = now.minusMinutes(windowMinutes); + LocalDateTime lastAttempt = lastAttemptTimes.get(clientKey); + + if (lastAttempt == null || lastAttempt.isBefore(windowStart)) { + // Reset counter if outside window + attemptCounts.put(clientKey, new AtomicInteger(1)); + } else { + // Increment counter + attemptCounts.computeIfAbsent(clientKey, k -> new AtomicInteger(0)).incrementAndGet(); + } + + lastAttemptTimes.put(clientKey, now); + + // Check if should block + int attempts = attemptCounts.get(clientKey).get(); + if (attempts >= maxAttempts) { + LocalDateTime blockEndTime = now.plusMinutes(blockDurationMinutes); + blockTimestamps.put(clientKey, blockEndTime); + } + } + + public void recordSuccessfulAttempt(HttpServletRequest request) { + String clientKey = getClientKey(request); + + // Clear all tracking on successful attempt + attemptCounts.remove(clientKey); + blockTimestamps.remove(clientKey); + lastAttemptTimes.remove(clientKey); + } + + public int getRemainingAttempts(HttpServletRequest request) { + String clientKey = getClientKey(request); + AtomicInteger attempts = attemptCounts.get(clientKey); + if (attempts == null) + return maxAttempts; + + return Math.max(0, maxAttempts - attempts.get()); + } + + public long getBlockTimeRemainingSeconds(HttpServletRequest request) { + String clientKey = getClientKey(request); + LocalDateTime blockEndTime = blockTimestamps.get(clientKey); + if (blockEndTime == null) + return 0; + + long secondsRemaining = java.time.Duration.between(LocalDateTime.now(), blockEndTime).getSeconds(); + return Math.max(0L, secondsRemaining); + } + + private String getClientKey(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty()) { + return xForwardedFor.split(",")[0].trim(); + } + + String xRealIp = request.getHeader("X-Real-IP"); + if (xRealIp != null && !xRealIp.isEmpty()) { + return xRealIp; + } + + return request.getRemoteAddr(); + } + + // Cleanup method to prevent memory leaks (can be called periodically) + public void cleanup() { + LocalDateTime cutoff = LocalDateTime.now().minusMinutes((long) blockDurationMinutes + windowMinutes); + + blockTimestamps.entrySet().removeIf(entry -> entry.getValue().isBefore(cutoff)); + lastAttemptTimes.entrySet().removeIf(entry -> entry.getValue().isBefore(cutoff)); + + // Also clean up attempt counts for clients without recent activity + lastAttemptTimes.keySet().forEach(key -> { + if (!attemptCounts.containsKey(key)) { + attemptCounts.remove(key); + } + }); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java index 3d5fb2c..8e7d509 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/BaseService.java @@ -83,7 +83,7 @@ protected void checkPermission(String requiredPermission) { boolean isAuthorized = authorities.contains("SUPER_ADMIN") || authorities.contains(requiredPermission) || - requiredPermission.equals("ALL"); + requiredPermission.equals("ALL"); if (!isAuthorized) { throw new AccessDeniedException("You have no permission to access " + getEntityPlural()); diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/SecurityLogService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/SecurityLogService.java new file mode 100644 index 0000000..8ee5483 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/SecurityLogService.java @@ -0,0 +1,110 @@ +package com.flexcodelabs.flextuma.core.services; + +import java.time.LocalDateTime; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; + +import jakarta.servlet.http.HttpServletRequest; + +@Service +public class SecurityLogService { + + private static final Logger logger = LoggerFactory.getLogger(SecurityLogService.class); + + public void logLoginAttempt(String username, HttpServletRequest request, boolean success, String reason) { + String clientInfo = getClientInfo(request); + + if (success) { + if (logger.isInfoEnabled()) { + logger.info("LOGIN_SUCCESS - User: {}, IP: {}, UserAgent: {}, Time: {}", + username, clientInfo, getUserAgent(request), LocalDateTime.now()); + } + } else { + if (logger.isWarnEnabled()) { + logger.warn("LOGIN_FAILED - User: {}, IP: {}, UserAgent: {}, Reason: {}, Time: {}", + username, clientInfo, getUserAgent(request), reason, LocalDateTime.now()); + } + } + } + + public void logRegistrationAttempt(String username, String email, HttpServletRequest request, boolean success, + String reason) { + String clientInfo = getClientInfo(request); + + if (success) { + if (logger.isInfoEnabled()) { + logger.info("REGISTRATION_SUCCESS - User: {}, Email: {}, IP: {}, UserAgent: {}, Time: {}", + username, email, clientInfo, getUserAgent(request), LocalDateTime.now()); + } + } else { + if (logger.isWarnEnabled()) { + logger.warn("REGISTRATION_FAILED - User: {}, Email: {}, IP: {}, UserAgent: {}, Reason: {}, Time: {}", + username, email, clientInfo, getUserAgent(request), reason, LocalDateTime.now()); + } + } + } + + public void logRateLimitExceeded(HttpServletRequest request, String endpoint) { + if (logger.isWarnEnabled()) { + String clientInfo = getClientInfo(request); + String userAgent = getUserAgent(request); + logger.warn("RATE_LIMIT_EXCEEDED - IP: {}, Endpoint: {}, UserAgent: {}, Time: {}", + clientInfo, endpoint, userAgent, LocalDateTime.now()); + } + } + + public void logSuspiciousActivity(String activity, String details, HttpServletRequest request) { + if (logger.isErrorEnabled()) { + String clientInfo = getClientInfo(request); + String userAgent = getUserAgent(request); + logger.error("SUSPICIOUS_ACTIVITY - Activity: {}, Details: {}, IP: {}, UserAgent: {}, Time: {}", + activity, details, clientInfo, userAgent, LocalDateTime.now()); + } + } + + public void logPasswordChange(String username, HttpServletRequest request, boolean success) { + String clientInfo = getClientInfo(request); + + if (success) { + if (logger.isInfoEnabled()) { + logger.info("PASSWORD_CHANGE_SUCCESS - User: {}, IP: {}, UserAgent: {}, Time: {}", + username, clientInfo, getUserAgent(request), LocalDateTime.now()); + } + } else { + if (logger.isWarnEnabled()) { + logger.warn("PASSWORD_CHANGE_FAILED - User: {}, IP: {}, UserAgent: {}, Time: {}", + username, clientInfo, getUserAgent(request), LocalDateTime.now()); + } + } + } + + public void logLogout(String username, HttpServletRequest request) { + if (logger.isInfoEnabled()) { + String clientInfo = getClientInfo(request); + String userAgent = getUserAgent(request); + logger.info("LOGOUT - User: {}, IP: {}, UserAgent: {}, Time: {}", + username, clientInfo, userAgent, LocalDateTime.now()); + } + } + + private String getClientInfo(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty()) { + return xForwardedFor.split(",")[0].trim(); + } + + String xRealIp = request.getHeader("X-Real-IP"); + if (xRealIp != null && !xRealIp.isEmpty()) { + return xRealIp; + } + + return request.getRemoteAddr(); + } + + private String getUserAgent(HttpServletRequest request) { + String userAgent = request.getHeader("User-Agent"); + return userAgent != null ? userAgent : "Unknown"; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/services/VerificationService.java b/src/main/java/com/flexcodelabs/flextuma/core/services/VerificationService.java new file mode 100644 index 0000000..5a24d92 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/services/VerificationService.java @@ -0,0 +1,92 @@ +package com.flexcodelabs.flextuma.core.services; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.concurrent.ConcurrentHashMap; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +@Service +public class VerificationService { + + private final ConcurrentHashMap verificationCodes = new ConcurrentHashMap<>(); + private final SecureRandom secureRandom = new SecureRandom(); + + @Value("${flextuma.verification.code-expiry-minutes:10}") + private int codeExpiryMinutes; + + @Value("${flextuma.verification.code-length:6}") + private int codeLength; + + public String generateVerificationCode(String identifier) { + String code = generateNumericCode(codeLength); + VerificationData data = new VerificationData(code, LocalDateTime.now().plusMinutes(codeExpiryMinutes)); + verificationCodes.put(identifier, data); + return code; + } + + public boolean verifyCode(String identifier, String providedCode) { + VerificationData data = verificationCodes.get(identifier); + if (data == null) { + return false; + } + + if (LocalDateTime.now().isAfter(data.expiryTime)) { + verificationCodes.remove(identifier); + return false; + } + + boolean isValid = data.code.equals(providedCode); + if (isValid) { + verificationCodes.remove(identifier); + } + + return isValid; + } + + public boolean resendCode(String identifier) { + // Remove existing code if any + verificationCodes.remove(identifier); + // Generate new code + generateVerificationCode(identifier); + return true; + } + + public boolean isCodeExpired(String identifier) { + VerificationData data = verificationCodes.get(identifier); + return data == null || LocalDateTime.now().isAfter(data.expiryTime); + } + + public long getRemainingTimeMinutes(String identifier) { + VerificationData data = verificationCodes.get(identifier); + if (data == null) + return 0; + + long remainingMinutes = java.time.Duration.between(LocalDateTime.now(), data.expiryTime).toMinutes(); + return Math.max(0, remainingMinutes); + } + + private String generateNumericCode(int length) { + StringBuilder code = new StringBuilder(); + for (int i = 0; i < length; i++) { + code.append(secureRandom.nextInt(10)); + } + return code.toString(); + } + + public void cleanupExpiredCodes() { + LocalDateTime now = LocalDateTime.now(); + verificationCodes.entrySet().removeIf(entry -> now.isAfter(entry.getValue().expiryTime)); + } + + private static class VerificationData { + final String code; + final LocalDateTime expiryTime; + + VerificationData(String code, LocalDateTime expiryTime) { + this.code = code; + this.expiryTime = expiryTime; + } + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/validation/PasswordValidator.java b/src/main/java/com/flexcodelabs/flextuma/core/validation/PasswordValidator.java new file mode 100644 index 0000000..44ea6b6 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/validation/PasswordValidator.java @@ -0,0 +1,44 @@ +package com.flexcodelabs.flextuma.core.validation; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class PasswordValidator implements ConstraintValidator { + + private int minLength; + private boolean requireUppercase; + private boolean requireLowercase; + private boolean requireDigit; + private boolean requireSpecialChar; + private String specialChars; + + @Override + public void initialize(ValidPassword constraintAnnotation) { + this.minLength = constraintAnnotation.minLength(); + this.requireUppercase = constraintAnnotation.requireUppercase(); + this.requireLowercase = constraintAnnotation.requireLowercase(); + this.requireDigit = constraintAnnotation.requireDigit(); + this.requireSpecialChar = constraintAnnotation.requireSpecialChar(); + this.specialChars = constraintAnnotation.specialChars(); + } + + @Override + public boolean isValid(String password, ConstraintValidatorContext context) { + if (password == null) { + return false; + } + + // Check minimum length + if (password.length() < minLength) { + return false; + } + + boolean hasUppercase = !requireUppercase || password.chars().anyMatch(Character::isUpperCase); + boolean hasLowercase = !requireLowercase || password.chars().anyMatch(Character::isLowerCase); + boolean hasDigit = !requireDigit || password.chars().anyMatch(Character::isDigit); + boolean hasSpecialChar = !requireSpecialChar || password.chars() + .anyMatch(ch -> specialChars.indexOf(ch) >= 0); + + return hasUppercase && hasLowercase && hasDigit && hasSpecialChar; + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/validation/ValidPassword.java b/src/main/java/com/flexcodelabs/flextuma/core/validation/ValidPassword.java new file mode 100644 index 0000000..c4ce2d7 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/validation/ValidPassword.java @@ -0,0 +1,33 @@ +package com.flexcodelabs.flextuma.core.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; + +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = PasswordValidator.class) +public @interface ValidPassword { + + String message() default "Password must be at least 8 characters long and contain at least one uppercase letter, one lowercase letter, one digit, and one special character"; + + Class[] groups() default {}; + + Class[] payload() default {}; + + int minLength() default 8; + + boolean requireUppercase() default true; + + boolean requireLowercase() default true; + + boolean requireDigit() default true; + + boolean requireSpecialChar() default true; + + String specialChars() default "!@#$%^&*()_+-=[]{}|;:,.<>?"; +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java index c720275..eab3c45 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java @@ -19,11 +19,19 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.server.ResponseStatusException; import com.flexcodelabs.flextuma.core.dtos.LoginDto; import com.flexcodelabs.flextuma.core.dtos.RegisterDto; +import com.flexcodelabs.flextuma.core.dto.ApiResponse; +import com.flexcodelabs.flextuma.core.dto.ErrorResponse; +import com.flexcodelabs.flextuma.core.dtos.UserResponseDto; +import com.flexcodelabs.flextuma.core.dtos.VerificationRequestDto; import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.services.AuthRateLimitService; import com.flexcodelabs.flextuma.core.services.CookieService; +import com.flexcodelabs.flextuma.core.services.SecurityLogService; +import com.flexcodelabs.flextuma.core.services.VerificationService; import com.flexcodelabs.flextuma.modules.finance.services.WalletService; import com.flexcodelabs.flextuma.modules.auth.services.UserService; @@ -37,74 +45,190 @@ @RequiredArgsConstructor public class AuthController { - private final UserService userService; - private final CookieService cookieService; - private final WalletService walletService; - - @Value("${flextuma.sms.price-per-segment:1.0}") - private BigDecimal pricePerSegment; - - @PostMapping("/register") - public ResponseEntity register(@Valid @RequestBody RegisterDto request) { - - User user = userService.register(request); - - BigDecimal creditAmount = pricePerSegment.multiply(BigDecimal.TEN); - walletService.credit(user, creditAmount, "Registration test SMS credits", "REGISTRATION_BONUS"); - - return ResponseEntity.status(HttpStatus.CREATED).body(user); - } - - @PostMapping("/login") - public ResponseEntity login( - @Valid @RequestBody LoginDto request, - HttpServletRequest httpRequest, - HttpServletResponse httpResponse) { - - User user = userService.login(request.getUsername(), request.getPassword()); - - java.util.Set authorities = user.getRoles() - .stream() - .flatMap(role -> role.getPrivileges().stream()) - .map(privilege -> new SimpleGrantedAuthority(privilege.getValue())) - .collect(java.util.stream.Collectors.toSet()); - - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - user.getUsername(), null, authorities); - authentication.setDetails(new org.springframework.security.web.authentication.WebAuthenticationDetailsSource() - .buildDetails(httpRequest)); - - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(authentication); - SecurityContextHolder.setContext(context); + private final UserService userService; + private final CookieService cookieService; + private final WalletService walletService; + private final AuthRateLimitService rateLimitService; + private final SecurityLogService securityLogService; + private final VerificationService verificationService; + + @Value("${flextuma.sms.price-per-segment:1.0}") + private BigDecimal pricePerSegment; + + @PostMapping("/register") + public ResponseEntity> register(@Valid @RequestBody RegisterDto request, + HttpServletRequest httpRequest) { + try { + if (rateLimitService.isBlocked(httpRequest)) { + securityLogService.logRateLimitExceeded(httpRequest, "/register"); + long remainingTime = rateLimitService.getBlockTimeRemainingSeconds(httpRequest); + return ResponseEntity.status(429) + .body(ApiResponse.error(ErrorResponse.tooManyRequests( + "Too many registration attempts. Try again in " + + remainingTime + " seconds"))); + } + + User user = userService.register(request); + + verificationService.generateVerificationCode(user.getEmail()); + verificationService.generateVerificationCode(user.getPhoneNumber()); + + securityLogService.logRegistrationAttempt(user.getUsername(), user.getEmail(), httpRequest, + true, "Registration successful"); + + BigDecimal creditAmount = pricePerSegment.multiply(BigDecimal.TEN); + walletService.credit(user, creditAmount, "Registration test SMS credits", "REGISTRATION_BONUS"); + + rateLimitService.recordSuccessfulAttempt(httpRequest); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(UserResponseDto.fromUser(user))); + + } catch (ResponseStatusException e) { + rateLimitService.recordFailedAttempt(httpRequest); + securityLogService.logRegistrationAttempt(request.getUsername(), request.getEmail(), + httpRequest, false, e.getReason()); + return ResponseEntity.status(e.getStatusCode()) + .body(ApiResponse.error(ErrorResponse.conflict(e.getReason()))); + } catch (Exception e) { + rateLimitService.recordFailedAttempt(httpRequest); + securityLogService.logRegistrationAttempt(request.getUsername(), request.getEmail(), + httpRequest, false, "Internal error"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(ErrorResponse.internalServerError( + "Registration failed due to internal error"))); + } + } - HttpSessionSecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository(); - securityContextRepository.saveContext(context, httpRequest, httpResponse); + @PostMapping("/login") + public ResponseEntity> login( + @Valid @RequestBody LoginDto request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + try { + if (rateLimitService.isBlocked(httpRequest)) { + securityLogService.logRateLimitExceeded(httpRequest, "/login"); + long remainingTime = rateLimitService.getBlockTimeRemainingSeconds(httpRequest); + return ResponseEntity.status(429) + .body(ApiResponse.error(ErrorResponse.tooManyRequests( + "Too many login attempts. Try again in " + remainingTime + + " seconds"))); + } + + User user = userService.login(request.getUsername(), request.getPassword()); + + java.util.Set authorities = user.getRoles() + .stream() + .flatMap(role -> role.getPrivileges().stream()) + .map(privilege -> new SimpleGrantedAuthority(privilege.getValue())) + .collect(java.util.stream.Collectors.toSet()); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + user.getUsername(), null, authorities); + authentication.setDetails( + new org.springframework.security.web.authentication.WebAuthenticationDetailsSource() + .buildDetails(httpRequest)); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + + HttpSessionSecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository(); + securityContextRepository.saveContext(context, httpRequest, httpResponse); + + ResponseCookie cookie = cookieService.createAuthCookie(); + + rateLimitService.recordSuccessfulAttempt(httpRequest); + securityLogService.logLoginAttempt(user.getUsername(), httpRequest, true, "Login successful"); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(ApiResponse.success(UserResponseDto.fromUser(user))); + + } catch (ResponseStatusException e) { + rateLimitService.recordFailedAttempt(httpRequest); + securityLogService.logLoginAttempt(request.getUsername(), httpRequest, false, e.getReason()); + return ResponseEntity.status(e.getStatusCode()) + .body(ApiResponse.error( + ErrorResponse.unauthorized("Invalid username or password"))); + } catch (Exception e) { + rateLimitService.recordFailedAttempt(httpRequest); + securityLogService.logLoginAttempt(request.getUsername(), httpRequest, false, "Internal error"); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(ErrorResponse + .internalServerError("Login failed due to internal error"))); + } + } - ResponseCookie cookie = cookieService.createAuthCookie(); - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .body(user); - } + @PostMapping("/logout") + public ResponseEntity logout(HttpServletRequest request) { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + if (auth != null && auth.isAuthenticated() && !(auth instanceof AnonymousAuthenticationToken)) { + securityLogService.logLogout(auth.getName(), request); + } + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookieService.deleteAuthCookie().toString()) + .build(); + } - @PostMapping("/logout") - public ResponseEntity logout() { - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookieService.deleteAuthCookie().toString()) - .build(); - } + @GetMapping("/me") + public ResponseEntity> me() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + if (auth == null || + !auth.isAuthenticated() || + auth instanceof AnonymousAuthenticationToken) { + return ResponseEntity.status(401) + .body(ApiResponse.error(ErrorResponse.unauthorized("Unauthorized"))); + } + User user = userService.findByUsername(auth.getName()); + return ResponseEntity.ok() + .body(ApiResponse.success(UserResponseDto.fromUser(user))); + } - @GetMapping("/me") - public ResponseEntity me() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + @PostMapping("/verify") + public ResponseEntity> verify(@Valid @RequestBody VerificationRequestDto request, + HttpServletRequest httpRequest) { + try { + if (verificationService.verifyCode(request.getIdentifier(), request.getCode())) { + securityLogService.logRegistrationAttempt("", request.getIdentifier(), httpRequest, + true, "Verification successful"); + return ResponseEntity.ok() + .body(ApiResponse.success("Verification successful")); + } else { + securityLogService.logRegistrationAttempt("", request.getIdentifier(), httpRequest, + false, "Invalid verification code"); + return ResponseEntity.badRequest() + .body(ApiResponse.error(ErrorResponse + .badRequest("Invalid or expired verification code"))); + } + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error( + ErrorResponse.internalServerError("Verification failed"))); + } + } - if (auth == null || - !auth.isAuthenticated() || - auth instanceof AnonymousAuthenticationToken) { - return ResponseEntity.status(401).build(); + @PostMapping("/resendVerification") + public ResponseEntity> resendVerification(@RequestBody VerificationRequestDto request, + HttpServletRequest httpRequest) { + try { + if (rateLimitService.isBlocked(httpRequest)) { + return ResponseEntity.status(429) + .body(ApiResponse.error(ErrorResponse.tooManyRequests( + "Too many requests. Try again later"))); + } + + verificationService.resendCode(request.getIdentifier()); + securityLogService.logRegistrationAttempt("", request.getIdentifier(), httpRequest, true, + "Verification code resent"); + + return ResponseEntity.ok() + .body(ApiResponse.success("Verification code sent")); + } catch (Exception e) { + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(ApiResponse.error(ErrorResponse + .internalServerError("Failed to send verification code"))); + } } - User user = userService.findByUsername(auth.getName()); - return ResponseEntity.ok() - .body(user); - } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/finance/controllers/WalletController.java b/src/main/java/com/flexcodelabs/flextuma/modules/finance/controllers/WalletController.java new file mode 100644 index 0000000..9e20480 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/finance/controllers/WalletController.java @@ -0,0 +1,16 @@ +package com.flexcodelabs.flextuma.modules.finance.controllers; + +import com.flexcodelabs.flextuma.core.controllers.BaseController; +import com.flexcodelabs.flextuma.core.entities.finance.Wallet; +import com.flexcodelabs.flextuma.modules.finance.services.WalletService; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/" + Wallet.PLURAL) +public class WalletController extends BaseController { + + public WalletController(WalletService service) { + super(service); + } +} diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthControllerTest.java index a422bb2..0f33b7d 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthControllerTest.java @@ -21,7 +21,10 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.flexcodelabs.flextuma.core.dtos.LoginDto; import com.flexcodelabs.flextuma.core.entities.auth.User; +import com.flexcodelabs.flextuma.core.services.AuthRateLimitService; import com.flexcodelabs.flextuma.core.services.CookieService; +import com.flexcodelabs.flextuma.core.services.SecurityLogService; +import com.flexcodelabs.flextuma.core.services.VerificationService; import com.flexcodelabs.flextuma.modules.auth.services.UserService; import com.flexcodelabs.flextuma.modules.finance.services.WalletService; import org.springframework.security.core.context.SecurityContext; @@ -42,6 +45,15 @@ class AuthControllerTest { @Mock private WalletService walletService; + @Mock + private AuthRateLimitService rateLimitService; + + @Mock + private SecurityLogService securityLogService; + + @Mock + private VerificationService verificationService; + @Mock private SecurityContext securityContext; @@ -52,7 +64,8 @@ class AuthControllerTest { @BeforeEach void setUp() { - AuthController controller = new AuthController(userService, cookieService, walletService); + AuthController controller = new AuthController(userService, cookieService, walletService, rateLimitService, + securityLogService, verificationService); mockMvc = MockMvcBuilders.standaloneSetup(controller).build(); } @@ -70,13 +83,16 @@ void login_shouldReturnUserAndSetCookie_whenCredentialsValid() throws Exception when(userService.login("user", "password")).thenReturn(user); when(cookieService.createAuthCookie()).thenReturn(cookie); + when(rateLimitService.isBlocked(any())).thenReturn(false); + doNothing().when(rateLimitService).recordSuccessfulAttempt(any()); + doNothing().when(securityLogService).logLoginAttempt(any(), any(), eq(true), any()); mockMvc.perform(post("/api/login") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(loginDto))) .andExpect(status().isOk()) .andExpect(header().exists("Set-Cookie")) - .andExpect(jsonPath("$.username").value("user")); + .andExpect(jsonPath("$.data.username").value("user")); } @Test @@ -84,9 +100,17 @@ void logout_shouldDeleteCookie() throws Exception { ResponseCookie cookie = ResponseCookie.from("SESSION", "").maxAge(0).build(); when(cookieService.deleteAuthCookie()).thenReturn(cookie); - mockMvc.perform(post("/api/logout")) - .andExpect(status().isOk()) - .andExpect(header().string("Set-Cookie", containsString("Max-Age=0"))); + // Mock authentication context + when(authentication.getName()).thenReturn("testuser"); + when(authentication.isAuthenticated()).thenReturn(true); + when(securityContext.getAuthentication()).thenReturn(authentication); + try (var mocked = mockStatic(SecurityContextHolder.class)) { + mocked.when(SecurityContextHolder::getContext).thenReturn(securityContext); + + mockMvc.perform(post("/api/logout")) + .andExpect(status().isOk()) + .andExpect(header().string("Set-Cookie", containsString("Max-Age=0"))); + } } @Test @@ -115,7 +139,7 @@ void me_shouldReturnUser_whenAuthenticated() throws Exception { mockMvc.perform(get("/api/me")) .andExpect(status().isOk()) - .andExpect(jsonPath("$.username").value("user")); + .andExpect(jsonPath("$.data.username").value("user")); } } diff --git a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java index 0754ae4..99dc1c2 100644 --- a/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/modules/sms/services/SmsLogServiceTest.java @@ -11,7 +11,6 @@ import org.mockito.MockedStatic; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.http.HttpStatus; -import org.springframework.security.access.AccessDeniedException; import org.springframework.web.server.ResponseStatusException; import java.util.Optional; @@ -100,15 +99,4 @@ void retryFailedMessage_NotFailedStatus() { assertTrue(ex.getReason().contains("Only failed messages")); } } - - @Test - void retryFailedMessage_NoPermission() { - try (MockedStatic utils = mockStatic( - com.flexcodelabs.flextuma.core.security.SecurityUtils.class)) { - utils.when(com.flexcodelabs.flextuma.core.security.SecurityUtils::getCurrentUserAuthorities) - .thenReturn(Set.of("READ_SMS_LOGS")); - - assertThrows(AccessDeniedException.class, () -> smsLogService.retryFailedMessage(logId)); - } - } } From 5c0742e94e7eb9704d234e309ea32d942814fb21 Mon Sep 17 00:00:00 2001 From: Bennett Date: Sun, 15 Mar 2026 02:30:32 +0300 Subject: [PATCH 3/5] Rate limiting improvement and dynamic errors for conflicts --- .gitignore | 4 +- .../exceptions/GlobalExceptionHandler.java | 23 ++- .../RateLimitExceededException.java | 15 ++ .../auth/controllers/AuthController.java | 187 ++++++------------ .../auth/services/AuthenticationResult.java | 7 + .../modules/auth/services/UserService.java | 34 ++++ src/main/resources/application.properties | 28 ++- src/main/resources/logback-spring.xml | 42 +++- 8 files changed, 184 insertions(+), 156 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/exceptions/RateLimitExceededException.java create mode 100644 src/main/java/com/flexcodelabs/flextuma/modules/auth/services/AuthenticationResult.java diff --git a/.gitignore b/.gitignore index 5a8224f..a88be00 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,6 @@ http ROADMAP -.env \ No newline at end of file +.env + +sonar.txt \ No newline at end of file diff --git a/src/main/java/com/flexcodelabs/flextuma/core/exceptions/GlobalExceptionHandler.java b/src/main/java/com/flexcodelabs/flextuma/core/exceptions/GlobalExceptionHandler.java index faa73a5..5441621 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/exceptions/GlobalExceptionHandler.java @@ -25,10 +25,6 @@ @RestControllerAdvice public class GlobalExceptionHandler { - - // S5852 Fix: Use negated character classes [^)]+ and [^\"]+ instead of .*? to - // prevent ReDoS - // Also pre-compiling for performance private static final Pattern UNIQUE_CONSTRAINT_PATTERN = Pattern .compile("Key \\(([^)]+)\\)=\\(([^)]+)\\) already exists"); @@ -44,7 +40,7 @@ public ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadabl } Throwable mostSpecificCause = ex.getMostSpecificCause(); - String detailedMessage = mostSpecificCause != null ? mostSpecificCause.getMessage() : null; + String detailedMessage = mostSpecificCause.getMessage(); String message = defaultMessage; @@ -60,11 +56,6 @@ public ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadabl } private String tryBuildEnumErrorMessage(String detailedMessage) { - // Example detailedMessage: - // "JSON parse error: Cannot deserialize value of type - // `com.flexcodelabs.flextuma.core.enums.SmsCampaignStatus` from String \"Running\": - // not one of the values accepted for Enum class: [CANCELLED, COMPLETED, SCHEDULED, - // DRAFT, PROCESSING]" if (!detailedMessage.contains("not one of the values accepted for Enum class")) { return null; @@ -135,7 +126,7 @@ public ResponseEntity handleConstraintViolationException(ConstraintViola public ResponseEntity handleDatabaseError(DataIntegrityViolationException ex) { Throwable rootCause = ex.getRootCause(); String detail = (rootCause != null) ? rootCause.getMessage() : ex.getMessage(); - return buildResponse(sanitizeDatabaseError(detail), HttpStatus.BAD_REQUEST); + return buildResponse(sanitizeDatabaseError(detail), getResponseStatus(detail, HttpStatus.BAD_REQUEST)); } @ExceptionHandler(MethodArgumentNotValidException.class) @@ -164,6 +155,11 @@ public ResponseEntity handleNotFound() { return buildResponse("Oops 😢! Route not available.", HttpStatus.NOT_FOUND); } + @ExceptionHandler(RateLimitExceededException.class) + public ResponseEntity handleRateLimitExceeded(RateLimitExceededException ex) { + return buildResponse(ex.getMessage(), HttpStatus.TOO_MANY_REQUESTS); + } + @ExceptionHandler(InvalidEnumValueException.class) public ResponseEntity handleEnumDeserializationError(InvalidEnumValueException ex) { String message = String.format("Invalid value provided for '%s'. Allowed values are: %s.", @@ -185,6 +181,9 @@ public ResponseEntity handleTransactionException(TransactionSystemExcept if (cause instanceof ConstraintViolationException constraintEx) { return handleConstraintViolationException(constraintEx); } + if (cause instanceof DataIntegrityViolationException dataEx) { + return handleDatabaseError(dataEx); + } if (cause != null) { return buildResponse(sanitizeGeneralMessage(cause.getMessage()), HttpStatus.BAD_REQUEST); } @@ -215,7 +214,7 @@ private String sanitizeDatabaseError(String message) { Matcher matcher = UNIQUE_CONSTRAINT_PATTERN.matcher(message); if (matcher.find()) { String fields = matcher.group(1).replace("_", " "); - return fields + " combination already exists"; + return fields + " already exists"; } return "A record with these details already exists"; } diff --git a/src/main/java/com/flexcodelabs/flextuma/core/exceptions/RateLimitExceededException.java b/src/main/java/com/flexcodelabs/flextuma/core/exceptions/RateLimitExceededException.java new file mode 100644 index 0000000..8ff2138 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/exceptions/RateLimitExceededException.java @@ -0,0 +1,15 @@ +package com.flexcodelabs.flextuma.core.exceptions; + +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus(HttpStatus.TOO_MANY_REQUESTS) +public class RateLimitExceededException extends RuntimeException { + public RateLimitExceededException(String message) { + super(message); + } + + public RateLimitExceededException(String message, long remainingTimeSeconds) { + super(message + " Try again in " + remainingTimeSeconds + " seconds"); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java index eab3c45..0bc46e2 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/controllers/AuthController.java @@ -8,18 +8,13 @@ import org.springframework.http.ResponseCookie; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.AnonymousAuthenticationToken; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; -import org.springframework.security.core.authority.SimpleGrantedAuthority; -import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.web.bind.annotation.GetMapping; 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 org.springframework.web.server.ResponseStatusException; import com.flexcodelabs.flextuma.core.dtos.LoginDto; import com.flexcodelabs.flextuma.core.dtos.RegisterDto; @@ -27,13 +22,15 @@ import com.flexcodelabs.flextuma.core.dto.ErrorResponse; import com.flexcodelabs.flextuma.core.dtos.UserResponseDto; import com.flexcodelabs.flextuma.core.dtos.VerificationRequestDto; +import com.flexcodelabs.flextuma.core.exceptions.RateLimitExceededException; import com.flexcodelabs.flextuma.core.entities.auth.User; import com.flexcodelabs.flextuma.core.services.AuthRateLimitService; import com.flexcodelabs.flextuma.core.services.CookieService; import com.flexcodelabs.flextuma.core.services.SecurityLogService; import com.flexcodelabs.flextuma.core.services.VerificationService; -import com.flexcodelabs.flextuma.modules.finance.services.WalletService; +import com.flexcodelabs.flextuma.modules.auth.services.AuthenticationResult; import com.flexcodelabs.flextuma.modules.auth.services.UserService; +import com.flexcodelabs.flextuma.modules.finance.services.WalletService; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; @@ -58,46 +55,26 @@ public class AuthController { @PostMapping("/register") public ResponseEntity> register(@Valid @RequestBody RegisterDto request, HttpServletRequest httpRequest) { - try { - if (rateLimitService.isBlocked(httpRequest)) { - securityLogService.logRateLimitExceeded(httpRequest, "/register"); - long remainingTime = rateLimitService.getBlockTimeRemainingSeconds(httpRequest); - return ResponseEntity.status(429) - .body(ApiResponse.error(ErrorResponse.tooManyRequests( - "Too many registration attempts. Try again in " - + remainingTime + " seconds"))); - } - - User user = userService.register(request); - - verificationService.generateVerificationCode(user.getEmail()); - verificationService.generateVerificationCode(user.getPhoneNumber()); - - securityLogService.logRegistrationAttempt(user.getUsername(), user.getEmail(), httpRequest, - true, "Registration successful"); - - BigDecimal creditAmount = pricePerSegment.multiply(BigDecimal.TEN); - walletService.credit(user, creditAmount, "Registration test SMS credits", "REGISTRATION_BONUS"); - - rateLimitService.recordSuccessfulAttempt(httpRequest); - - return ResponseEntity.status(HttpStatus.CREATED) - .body(ApiResponse.success(UserResponseDto.fromUser(user))); - - } catch (ResponseStatusException e) { - rateLimitService.recordFailedAttempt(httpRequest); - securityLogService.logRegistrationAttempt(request.getUsername(), request.getEmail(), - httpRequest, false, e.getReason()); - return ResponseEntity.status(e.getStatusCode()) - .body(ApiResponse.error(ErrorResponse.conflict(e.getReason()))); - } catch (Exception e) { - rateLimitService.recordFailedAttempt(httpRequest); - securityLogService.logRegistrationAttempt(request.getUsername(), request.getEmail(), - httpRequest, false, "Internal error"); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error(ErrorResponse.internalServerError( - "Registration failed due to internal error"))); + if (rateLimitService.isBlocked(httpRequest)) { + long remainingTime = rateLimitService.getBlockTimeRemainingSeconds(httpRequest); + throw new RateLimitExceededException("Too many registration attempts", remainingTime); } + + User user = userService.register(request); + + verificationService.generateVerificationCode(user.getEmail()); + verificationService.generateVerificationCode(user.getPhoneNumber()); + + securityLogService.logRegistrationAttempt(user.getUsername(), user.getEmail(), httpRequest, + true, "Registration successful"); + + BigDecimal creditAmount = pricePerSegment.multiply(BigDecimal.TEN); + walletService.credit(user, creditAmount, "Registration test SMS credits", "REGISTRATION_BONUS"); + + rateLimitService.recordSuccessfulAttempt(httpRequest); + + return ResponseEntity.status(HttpStatus.CREATED) + .body(ApiResponse.success(UserResponseDto.fromUser(user))); } @PostMapping("/login") @@ -105,59 +82,23 @@ public ResponseEntity> login( @Valid @RequestBody LoginDto request, HttpServletRequest httpRequest, HttpServletResponse httpResponse) { - try { - if (rateLimitService.isBlocked(httpRequest)) { - securityLogService.logRateLimitExceeded(httpRequest, "/login"); - long remainingTime = rateLimitService.getBlockTimeRemainingSeconds(httpRequest); - return ResponseEntity.status(429) - .body(ApiResponse.error(ErrorResponse.tooManyRequests( - "Too many login attempts. Try again in " + remainingTime - + " seconds"))); - } - - User user = userService.login(request.getUsername(), request.getPassword()); - - java.util.Set authorities = user.getRoles() - .stream() - .flatMap(role -> role.getPrivileges().stream()) - .map(privilege -> new SimpleGrantedAuthority(privilege.getValue())) - .collect(java.util.stream.Collectors.toSet()); - - UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( - user.getUsername(), null, authorities); - authentication.setDetails( - new org.springframework.security.web.authentication.WebAuthenticationDetailsSource() - .buildDetails(httpRequest)); - - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(authentication); - SecurityContextHolder.setContext(context); - - HttpSessionSecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository(); - securityContextRepository.saveContext(context, httpRequest, httpResponse); - - ResponseCookie cookie = cookieService.createAuthCookie(); - - rateLimitService.recordSuccessfulAttempt(httpRequest); - securityLogService.logLoginAttempt(user.getUsername(), httpRequest, true, "Login successful"); - - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .body(ApiResponse.success(UserResponseDto.fromUser(user))); - - } catch (ResponseStatusException e) { - rateLimitService.recordFailedAttempt(httpRequest); - securityLogService.logLoginAttempt(request.getUsername(), httpRequest, false, e.getReason()); - return ResponseEntity.status(e.getStatusCode()) - .body(ApiResponse.error( - ErrorResponse.unauthorized("Invalid username or password"))); - } catch (Exception e) { - rateLimitService.recordFailedAttempt(httpRequest); - securityLogService.logLoginAttempt(request.getUsername(), httpRequest, false, "Internal error"); - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error(ErrorResponse - .internalServerError("Login failed due to internal error"))); + if (rateLimitService.isBlocked(httpRequest)) { + long remainingTime = rateLimitService.getBlockTimeRemainingSeconds(httpRequest); + throw new RateLimitExceededException("Too many login attempts", remainingTime); } + + AuthenticationResult authResult = userService.authenticateAndCreateContext( + request.getUsername(), request.getPassword(), httpRequest, httpResponse); + + ResponseCookie cookie = cookieService.createAuthCookie(); + + rateLimitService.recordSuccessfulAttempt(httpRequest); + securityLogService.logLoginAttempt(authResult.user().getUsername(), httpRequest, true, + "Login successful"); + + return ResponseEntity.ok() + .header(HttpHeaders.SET_COOKIE, cookie.toString()) + .body(ApiResponse.success(UserResponseDto.fromUser(authResult.user()))); } @PostMapping("/logout") @@ -189,46 +130,32 @@ public ResponseEntity> me() { @PostMapping("/verify") public ResponseEntity> verify(@Valid @RequestBody VerificationRequestDto request, HttpServletRequest httpRequest) { - try { - if (verificationService.verifyCode(request.getIdentifier(), request.getCode())) { - securityLogService.logRegistrationAttempt("", request.getIdentifier(), httpRequest, - true, "Verification successful"); - return ResponseEntity.ok() - .body(ApiResponse.success("Verification successful")); - } else { - securityLogService.logRegistrationAttempt("", request.getIdentifier(), httpRequest, - false, "Invalid verification code"); - return ResponseEntity.badRequest() - .body(ApiResponse.error(ErrorResponse - .badRequest("Invalid or expired verification code"))); - } - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error( - ErrorResponse.internalServerError("Verification failed"))); + if (verificationService.verifyCode(request.getIdentifier(), request.getCode())) { + securityLogService.logRegistrationAttempt("", request.getIdentifier(), httpRequest, + true, "Verification successful"); + return ResponseEntity.ok() + .body(ApiResponse.success("Verification successful")); + } else { + securityLogService.logRegistrationAttempt("", request.getIdentifier(), httpRequest, + false, "Invalid verification code"); + return ResponseEntity.badRequest() + .body(ApiResponse.error(ErrorResponse + .badRequest("Invalid or expired verification code"))); } } @PostMapping("/resendVerification") public ResponseEntity> resendVerification(@RequestBody VerificationRequestDto request, HttpServletRequest httpRequest) { - try { - if (rateLimitService.isBlocked(httpRequest)) { - return ResponseEntity.status(429) - .body(ApiResponse.error(ErrorResponse.tooManyRequests( - "Too many requests. Try again later"))); - } + if (rateLimitService.isBlocked(httpRequest)) { + throw new RateLimitExceededException("Too many requests"); + } - verificationService.resendCode(request.getIdentifier()); - securityLogService.logRegistrationAttempt("", request.getIdentifier(), httpRequest, true, - "Verification code resent"); + verificationService.resendCode(request.getIdentifier()); + securityLogService.logRegistrationAttempt("", request.getIdentifier(), httpRequest, true, + "Verification code resent"); - return ResponseEntity.ok() - .body(ApiResponse.success("Verification code sent")); - } catch (Exception e) { - return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) - .body(ApiResponse.error(ErrorResponse - .internalServerError("Failed to send verification code"))); - } + return ResponseEntity.ok() + .body(ApiResponse.success("Verification code sent")); } } diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/AuthenticationResult.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/AuthenticationResult.java new file mode 100644 index 0000000..3e7600c --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/AuthenticationResult.java @@ -0,0 +1,7 @@ +package com.flexcodelabs.flextuma.modules.auth.services; + +import org.springframework.security.core.Authentication; +import com.flexcodelabs.flextuma.core.entities.auth.User; + +public record AuthenticationResult(User user, Authentication authentication) { +} diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java index c13bf8b..09036bd 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/auth/services/UserService.java @@ -2,9 +2,18 @@ import java.util.UUID; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.http.HttpStatus; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; import org.springframework.stereotype.Service; import org.springframework.web.server.ResponseStatusException; @@ -88,6 +97,31 @@ public User login(String username, String password) { return user; } + public AuthenticationResult authenticateAndCreateContext(String username, String password, + HttpServletRequest httpRequest, HttpServletResponse httpResponse) { + User user = login(username, password); + + java.util.Set authorities = user.getRoles() + .stream() + .flatMap(role -> role.getPrivileges().stream()) + .map(privilege -> new SimpleGrantedAuthority(privilege.getValue())) + .collect(java.util.stream.Collectors.toSet()); + + UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken( + user.getUsername(), null, authorities); + authentication.setDetails( + new WebAuthenticationDetailsSource().buildDetails(httpRequest)); + + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + + HttpSessionSecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository(); + securityContextRepository.saveContext(context, httpRequest, httpResponse); + + return new AuthenticationResult(user, authentication); + } + public User findByUsername(String username) { return repository.findByUsername(username) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index f154155..a1a04fc 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -17,12 +17,28 @@ server.servlet.session.timeout=${SESSION_TIMEOUT:30m} spring.jpa.open-in-view=${JPA_OPEN_IN_VIEW:false} spring.jpa.properties.hibernate.default_batch_fetch_size=${BATCH_SIZE:100} -spring.jpa.show-sql=${JPA_SHOW_SQL:true} -logging.level.org.hibernate.SQL=${HIBERNATE_SQL_LEVEL:DEBUG} -logging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LEVEL:TRACE} -logging.level.org.springframework.orm.jpa=${JPA_LEVEL:DEBUG} -logging.level.org.springframework.transaction=${TRANSACTION_LEVEL:DEBUG} -logging.level.org.hibernate.engine.jdbc.spi.SqlExceptionHelper=${SQL_EXCEPTION_HELPER_LEVEL:DEBUG} +spring.jpa.show-sql=${JPA_SHOW_SQL:false} +logging.level.org.hibernate.SQL=${HIBERNATE_SQL_LEVEL:WARN} +logging.level.org.hibernate.type.descriptor.sql.BasicBinder=${HIBERNATE_BINDER_LEVEL:WARN} +logging.level.org.springframework.orm.jpa=${JPA_LEVEL:WARN} +logging.level.org.springframework.transaction=${TRANSACTION_LEVEL:WARN} +logging.level.org.hibernate.engine.jdbc.spi.SqlExceptionHelper=${SQL_EXCEPTION_HELPER_LEVEL:WARN} + +# Disable verbose Spring exception resolver logs +logging.level.org.springframework.web.servlet.mvc.method.annotation.ExceptionHandlerExceptionResolver=WARN + +# Disable Hibernate constraint violation warnings +logging.level.org.hibernate.orm.jdbc.warn=${HIBERNATE_JDBC_WARN_LEVEL:ERROR} + +# Clean up Spring Boot logging format +logging.pattern.console=%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n + +# Disable specific verbose loggers +logging.level.org.apache.catalina.core=WARN +logging.level.org.apache.catalina=WARN +logging.level.org.springframework.boot=WARN +logging.level.org.springframework.web=WARN + spring.web.error.include-message=${ERROR_INCLUDE_MESSAGE:always} # SMS Pricing diff --git a/src/main/resources/logback-spring.xml b/src/main/resources/logback-spring.xml index fddd1d4..9c7246e 100644 --- a/src/main/resources/logback-spring.xml +++ b/src/main/resources/logback-spring.xml @@ -1,11 +1,16 @@ - - - %d{yyyy-MM-dd HH:mm:ss.SSS} %clr(%5p) %clr(---){faint} %clr([%X{traceId:-no-trace}]){yellow} %clr([%X{user:-SYSTEM}]){magenta} %clr([%logger{0}]){cyan} : %m%n + %d{yyyy-MM-dd HH:mm:ss.SSS} %clr(%5p): %m%n + utf8 + + + + + + %d{yyyy-MM-dd HH:mm:ss.SSS} %clr(%5p): %m%n utf8 @@ -16,11 +21,34 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - + From 3d529e7fbe47203bc746c2bf8917919ecd33efec Mon Sep 17 00:00:00 2001 From: Bennett Date: Sun, 15 Mar 2026 16:19:47 +0300 Subject: [PATCH 4/5] release: Improve pagination --- .../flextuma/core/config/WebConfig.java | 19 +++++++ .../exceptions/GlobalExceptionHandler.java | 56 ++++++++++++++----- .../controllers/SystemLogController.java | 2 +- .../services/NotificationService.java | 11 ++++ .../core/controllers/BaseControllerTest.java | 4 +- 5 files changed, 77 insertions(+), 15 deletions(-) create mode 100644 src/main/java/com/flexcodelabs/flextuma/core/config/WebConfig.java diff --git a/src/main/java/com/flexcodelabs/flextuma/core/config/WebConfig.java b/src/main/java/com/flexcodelabs/flextuma/core/config/WebConfig.java new file mode 100644 index 0000000..cd0e254 --- /dev/null +++ b/src/main/java/com/flexcodelabs/flextuma/core/config/WebConfig.java @@ -0,0 +1,19 @@ +package com.flexcodelabs.flextuma.core.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.web.method.support.HandlerMethodArgumentResolver; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +import java.util.List; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addArgumentResolvers(List resolvers) { + PageableHandlerMethodArgumentResolver resolver = new PageableHandlerMethodArgumentResolver(); + resolver.setOneIndexedParameters(true); + resolvers.add(resolver); + } +} diff --git a/src/main/java/com/flexcodelabs/flextuma/core/exceptions/GlobalExceptionHandler.java b/src/main/java/com/flexcodelabs/flextuma/core/exceptions/GlobalExceptionHandler.java index 5441621..4af9253 100644 --- a/src/main/java/com/flexcodelabs/flextuma/core/exceptions/GlobalExceptionHandler.java +++ b/src/main/java/com/flexcodelabs/flextuma/core/exceptions/GlobalExceptionHandler.java @@ -259,30 +259,60 @@ private String capitalize(String str) { return str; StringBuilder result = new StringBuilder(); - boolean insideBrackets = false; - boolean firstCharFound = false; + CapitalizeState state = new CapitalizeState(); for (int i = 0; i < str.length(); i++) { char c = str.charAt(i); - if (c == '[') - insideBrackets = true; - if (insideBrackets) { + boolean shouldAppendDirectly = updateBracketState(c, state) || + updateDotSpaceState(str, i, c, state); + + if (shouldAppendDirectly) { result.append(c); } else { - if (!firstCharFound && Character.isLetterOrDigit(c)) { - result.append(Character.toUpperCase(c)); - firstCharFound = true; - } else { - result.append(Character.toLowerCase(c)); - } + appendProcessedChar(c, state, result); } - if (c == ']') - insideBrackets = false; } + return result.toString().replace("_", " "); } + private boolean updateBracketState(char c, CapitalizeState state) { + if (c == '[') { + state.insideBrackets = true; + } else if (c == ']') { + state.insideBrackets = false; + } + return state.insideBrackets; + } + + private boolean updateDotSpaceState(String str, int i, char c, CapitalizeState state) { + if (i > 0 && str.charAt(i - 1) == '.' && c == ' ') { + state.previousWasDotSpace = true; + return true; + } + return false; + } + + private void appendProcessedChar(char c, CapitalizeState state, StringBuilder result) { + if (!state.firstCharFound && Character.isLetterOrDigit(c)) { + result.append(Character.toUpperCase(c)); + state.firstCharFound = true; + } else if (state.previousWasDotSpace && Character.isLetter(c)) { + result.append(Character.toUpperCase(c)); + state.previousWasDotSpace = false; + } else { + result.append(Character.toLowerCase(c)); + state.previousWasDotSpace = false; + } + } + + private static class CapitalizeState { + boolean insideBrackets = false; + boolean firstCharFound = false; + boolean previousWasDotSpace = false; + } + private HttpStatus getResponseStatus(String message, HttpStatus defaultStatus) { if (message == null) return defaultStatus; diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/logging/controllers/SystemLogController.java b/src/main/java/com/flexcodelabs/flextuma/modules/logging/controllers/SystemLogController.java index 3a2860b..108f037 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/logging/controllers/SystemLogController.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/logging/controllers/SystemLogController.java @@ -37,7 +37,7 @@ public Map getAll( Page page = systemLogService.findAll(pageable, level, source, traceId, from, to); Map response = new LinkedHashMap<>(); - response.put("page", page.getNumber()); + response.put("page", page.getNumber() + 1); response.put("total", page.getTotalElements()); response.put("pageSize", page.getSize()); response.put("systemLog", page.getContent()); diff --git a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java index c9d5cf0..a5dfd47 100644 --- a/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java +++ b/src/main/java/com/flexcodelabs/flextuma/modules/notification/services/NotificationService.java @@ -73,6 +73,11 @@ public SmsLog queueRawSms(Map payload, String username) { String content = getRequiredField(payload, "message"); String phoneNumber = getRequiredField(payload, "phoneNumber"); + if (containsUnreplacedVariables(content)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, + "Message contains unreplaced template variables. Please ensure all variables like {{variable}} are properly replaced."); + } + SmsConnector connector = getConnector(currentUser, providerValue); return processAndSaveSms(currentUser, connector, phoneNumber, content, null, payload); @@ -98,6 +103,12 @@ private String getRequiredField(Map data, String key) { key + " is missing")); } + private boolean containsUnreplacedVariables(String content) { + java.util.regex.Pattern pattern = java.util.regex.Pattern.compile("\\{[^}]*\\}"); + java.util.regex.Matcher matcher = pattern.matcher(content); + return matcher.find(); + } + private SmsConnector getConnector(User user, String provider) { return connectorRepository.findByCreatedByAndProviderAndActiveTrue(user, provider) .orElseThrow(() -> new ResponseStatusException(HttpStatus.BAD_REQUEST, diff --git a/src/test/java/com/flexcodelabs/flextuma/core/controllers/BaseControllerTest.java b/src/test/java/com/flexcodelabs/flextuma/core/controllers/BaseControllerTest.java index 8c585be..a2a679f 100644 --- a/src/test/java/com/flexcodelabs/flextuma/core/controllers/BaseControllerTest.java +++ b/src/test/java/com/flexcodelabs/flextuma/core/controllers/BaseControllerTest.java @@ -45,8 +45,10 @@ public abstract class BaseControllerTest Date: Sun, 15 Mar 2026 13:20:24 +0000 Subject: [PATCH 5/5] Release v0.0.5 [skip ci] --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index f1ce337..8688f99 100644 --- a/build.gradle +++ b/build.gradle @@ -8,7 +8,7 @@ plugins { } group = 'com.flexcodelabs' -version = '0.0.4' +version = '0.0.5' description = 'Flextuma App' java {