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/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 { 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/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/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..4af9253 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,16 +40,15 @@ public ResponseEntity handleHttpMessageNotReadable(HttpMessageNotReadabl } Throwable mostSpecificCause = ex.getMostSpecificCause(); - String detailedMessage = mostSpecificCause != null ? mostSpecificCause.getMessage() : null; + String detailedMessage = mostSpecificCause.getMessage(); 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; } } @@ -61,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; @@ -136,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) @@ -165,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.", @@ -186,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); } @@ -216,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"; } @@ -261,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/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/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/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/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/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 9d09019..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) || - (!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/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 0579d41..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 @@ -1,15 +1,15 @@ 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; -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; @@ -17,9 +17,20 @@ 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.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.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.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; @@ -31,59 +42,120 @@ @RequiredArgsConstructor public class AuthController { - private final UserService userService; - private final CookieService cookieService; - - @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); - - HttpSessionSecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository(); - securityContextRepository.saveContext(context, httpRequest, httpResponse); - - ResponseCookie cookie = cookieService.createAuthCookie(); - return ResponseEntity.ok() - .header(HttpHeaders.SET_COOKIE, cookie.toString()) - .body(user); - } - - @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).build(); + 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) { + 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") + public ResponseEntity> login( + @Valid @RequestBody LoginDto request, + HttpServletRequest httpRequest, + HttpServletResponse httpResponse) { + 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") + 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(); + } + + @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))); + } + + @PostMapping("/verify") + public ResponseEntity> verify(@Valid @RequestBody VerificationRequestDto request, + HttpServletRequest httpRequest) { + 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) { + if (rateLimitService.isBlocked(httpRequest)) { + throw new RateLimitExceededException("Too many requests"); + } + + verificationService.resendCode(request.getIdentifier()); + securityLogService.logRegistrationAttempt("", request.getIdentifier(), httpRequest, true, + "Verification code resent"); + + return ResponseEntity.ok() + .body(ApiResponse.success("Verification code sent")); } - User user = userService.findByUsername(auth.getName()); - return ResponseEntity.ok() - .body(user); - } } 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 d43b7c4..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,13 +2,23 @@ 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; 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; @@ -87,10 +97,51 @@ 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, "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/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/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/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/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..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 @@ -70,9 +70,14 @@ 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"); + 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/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 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + - + 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 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)); - } - } }