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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
}

group = 'com.flexcodelabs'
version = '0.0.28'
version = '0.0.29'
description = 'Flextuma App'

java {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,22 @@
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.security.Principal;

import com.flexcodelabs.flextuma.core.security.AuthenticatedUserCaptureFilter;

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
Expand Down Expand Up @@ -43,9 +49,6 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
logRequest(request, response, fullUri, startTime, 500, ex);
throw ex;
} finally {
// Only log in finally if we haven't already logged via the catch block
// OR we rely on the response status. Best is to extract the logging logic
// into a helper method.
if (request.getAttribute("REQUEST_LOGGED") == null) {
logRequest(request, response, fullUri, startTime, response.getStatus(), null);
}
Expand All @@ -67,7 +70,7 @@ private void logRequest(HttpServletRequest request, HttpServletResponse response
return;
}
request.setAttribute("REQUEST_LOGGED", true);
String username = getUsername();
String username = getUsername(request);
long duration = System.currentTimeMillis() - startTime;

int status = statusOverride > 0 ? statusOverride : response.getStatus();
Expand Down Expand Up @@ -95,10 +98,21 @@ private void logRequest(HttpServletRequest request, HttpServletResponse response
}
}

private String getUsername() {
private String getUsername(HttpServletRequest request) {
Object capturedUsername = request.getAttribute(AuthenticatedUserCaptureFilter.REQUEST_USERNAME_ATTRIBUTE);
if (capturedUsername instanceof String username
&& !username.trim().isEmpty()
&& !"SYSTEM".equalsIgnoreCase(username)) {
return username;
}

Principal principal = request.getUserPrincipal();
if (principal != null && principal.getName() != null && !principal.getName().trim().isEmpty()) {
return principal.getName();
}

Authentication auth = SecurityContextHolder.getContext().getAuthentication();

// Debug logging to understand the authentication context
log.debug("Authentication found: {}", auth != null);
if (auth != null) {
log.debug("Auth class: {}", auth.getClass().getSimpleName());
Expand All @@ -113,15 +127,28 @@ private String getUsername() {

if (auth != null && auth.isAuthenticated() && !"anonymousUser".equals(auth.getPrincipal())) {
String username = auth.getName();
// Don't return "SYSTEM" for actual authenticated users
if (username != null && !username.trim().isEmpty() && !"SYSTEM".equalsIgnoreCase(username)) {
log.debug("Returning username: {}", username);
return username;
}
}

// For login requests, try to extract username from request parameters
HttpServletRequest request = getCurrentRequest();
HttpSession session = request.getSession(false);
if (session != null) {
Object contextAttr = session.getAttribute(HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY);
if (contextAttr instanceof SecurityContext securityContext) {
Authentication sessionAuth = securityContext.getAuthentication();
if (sessionAuth != null && sessionAuth.isAuthenticated()
&& !"anonymousUser".equals(sessionAuth.getPrincipal())) {
String sessionUsername = sessionAuth.getName();
if (sessionUsername != null && !sessionUsername.trim().isEmpty()
&& !"SYSTEM".equalsIgnoreCase(sessionUsername)) {
return sessionUsername;
}
}
}
}

if (request != null && request.getRequestURI().contains("/login")) {
String loginUsername = request.getParameter(USERNAME_KEY);
if (loginUsername != null && !loginUsername.trim().isEmpty()) {
Expand All @@ -132,17 +159,4 @@ private String getUsername() {
log.debug("Returning SYSTEM as fallback");
return "SYSTEM";
}

private HttpServletRequest getCurrentRequest() {
try {
org.springframework.web.context.request.RequestAttributes attrs = org.springframework.web.context.request.RequestContextHolder
.getRequestAttributes();
if (attrs instanceof org.springframework.web.context.request.ServletRequestAttributes servletRequestAttributes) {
return servletRequestAttributes.getRequest();
}
} catch (Exception e) {
log.debug("❌❌❌❌ [RequestLoggingFilter] Could not get current request: {} ❌❌❌❌", e.getMessage());
}
return null;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.flexcodelabs.flextuma.core.config.auth;

import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.boot.context.properties.bind.DefaultValue;
import org.springframework.validation.annotation.Validated;

import java.util.List;

@Validated
@ConfigurationProperties(prefix = "flextuma.auth")
public record ApiKeyAuthProperties(
@DefaultValue("/api/webhooks/**,/api/external/**") List<String> apiKeyEndpoints) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,19 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse
MDC.put(TRACE_ID_KEY, traceId);
response.setHeader(TRACE_HEADER, traceId);

String username = resolveUsername();
if (username != null) {
MDC.put(USERNAME_KEY, username);
}

try {
filterChain.doFilter(request, response);

String username = resolveUsername();
if (username != null) {
MDC.put(USERNAME_KEY, username);
if (username == null) {
String resolvedAfterChain = resolveUsername();
if (resolvedAfterChain != null) {
MDC.put(USERNAME_KEY, resolvedAfterChain);
}
}
} finally {
MDC.remove(TRACE_ID_KEY);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,19 +35,16 @@ public DynamicFetchSpecification(Set<String> fieldPaths) {

@Override
public Predicate toPredicate(Root<T> root, CriteriaQuery<?> query, CriteriaBuilder cb) {
// Fetch joins are not allowed in count queries
Class<?> resultType = query.getResultType();
if (resultType == Long.class || resultType == long.class || resultType == Integer.class
|| resultType == int.class) {
return cb.conjunction();
}

// Apply fetch joins for requested paths
for (String path : fieldPaths) {
applyFetch(root, path);
}

// Use distinct to avoid duplicates when fetching collections
query.distinct(true);

return cb.conjunction();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.flexcodelabs.flextuma.core.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.security.authentication.AnonymousAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;

@Component
public class AuthenticatedUserCaptureFilter extends OncePerRequestFilter {

public static final String REQUEST_USERNAME_ATTRIBUTE = "flextuma.authenticated.username";

@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
try {
filterChain.doFilter(request, response);
} finally {
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
if (authentication != null
&& authentication.isAuthenticated()
&& !(authentication instanceof AnonymousAuthenticationToken)
&& authentication.getName() != null
&& !authentication.getName().isBlank()) {
request.setAttribute(REQUEST_USERNAME_ATTRIBUTE, authentication.getName());
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,16 @@ public class SecurityConfig {
private final CustomSecurityExceptionHandler securityExceptionHandler;
private final PatAuthenticationFilter patAuthenticationFilter;
private final PasswordChangeRequiredFilter passwordChangeRequiredFilter;
private final AuthenticatedUserCaptureFilter authenticatedUserCaptureFilter;

public SecurityConfig(CustomSecurityExceptionHandler securityExceptionHandler,
PatAuthenticationFilter patAuthenticationFilter,
PasswordChangeRequiredFilter passwordChangeRequiredFilter) {
PasswordChangeRequiredFilter passwordChangeRequiredFilter,
AuthenticatedUserCaptureFilter authenticatedUserCaptureFilter) {
this.securityExceptionHandler = securityExceptionHandler;
this.patAuthenticationFilter = patAuthenticationFilter;
this.passwordChangeRequiredFilter = passwordChangeRequiredFilter;
this.authenticatedUserCaptureFilter = authenticatedUserCaptureFilter;
}

@Bean
Expand Down Expand Up @@ -63,6 +66,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) {
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(passwordChangeRequiredFilter,
org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(authenticatedUserCaptureFilter, PasswordChangeRequiredFilter.class)
.exceptionHandling(ex -> ex
.authenticationEntryPoint(securityExceptionHandler)
.accessDeniedHandler(securityExceptionHandler))
Expand All @@ -76,4 +80,4 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) {
throw new BeanCreationException("Security Filter Chain creation failed", e);
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,7 @@
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.core.Authentication;
Expand All @@ -27,7 +25,6 @@
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;
Expand All @@ -45,7 +42,6 @@
public class AuthController {

private final UserService userService;
private final CookieService cookieService;
private final WalletService walletService;
private final AuthRateLimitService rateLimitService;
private final SecurityLogService securityLogService;
Expand Down Expand Up @@ -95,26 +91,34 @@ public ResponseEntity<Object> login(
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(authResult.user());
}

@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletRequest request) {
public ResponseEntity<Void> logout(HttpServletRequest request, HttpServletResponse response) {
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();

jakarta.servlet.http.HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate();
}
SecurityContextHolder.clearContext();

jakarta.servlet.http.Cookie cookie = new jakarta.servlet.http.Cookie("SESSION", "");
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);

return ResponseEntity.ok().build();
}

@GetMapping("/me")
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ logging.level.org.springframework.web=WARN
spring.web.error.include-message=${ERROR_INCLUDE_MESSAGE:always}

# SMS Pricing
flextuma.sms.price-per-segment=${SMS_PRICE_PER_SEGMENT:20.0}
flextuma.sms.price-per-segment=${SMS_PRICE_PER_SEGMENT:}

# App Upload Directories
flextuma.app.upload.directory=${APP_UPLOAD_DIRECTORY:/tmp/apps}
Expand Down
Loading