diff --git a/openaev-api/src/main/java/io/openaev/aop/audit_log/AccessControlAuditLogAspect.java b/openaev-api/src/main/java/io/openaev/aop/audit_log/AccessControlAuditLogAspect.java index 2eac5333880..0cbb49767ff 100644 --- a/openaev-api/src/main/java/io/openaev/aop/audit_log/AccessControlAuditLogAspect.java +++ b/openaev-api/src/main/java/io/openaev/aop/audit_log/AccessControlAuditLogAspect.java @@ -43,7 +43,7 @@ @Slf4j public class AccessControlAuditLogAspect { - private final AccessControlAuditLogger accessControlAuditLogger; + private final AuditLogger auditLogger; private final ObjectMapper objectMapper; private final ExpressionParser parser = new SpelExpressionParser(); @@ -57,9 +57,7 @@ public Object auditAround(ProceedingJoinPoint joinPoint, AccessControl accessCon try { action = accessControl.actionPerformed(); - isActive = - accessControlAuditLogger.isAuditLoggingEnabled() - && accessControlAuditLogger.isAuditLoggingValid(action); + isActive = auditLogger.isAuditLoggingEnabled() && auditLogger.isAuditLoggingValid(action); } catch (Exception ex) { log.warn("Error during audit logging", ex); } @@ -113,7 +111,7 @@ public Object auditAround(ProceedingJoinPoint joinPoint, AccessControl accessCon JsonNode resultNode = getOutputNode(result); JsonNode errorNode = buildErrorNode(resultNode, ex); - accessControlAuditLogger + auditLogger .logAccessControlEvent( eventScope, "error", @@ -134,7 +132,7 @@ public Object auditAround(ProceedingJoinPoint joinPoint, AccessControl accessCon try { JsonNode resultNode = getOutputNode(result); - accessControlAuditLogger + auditLogger .logAccessControlEvent( eventScope, "success", diff --git a/openaev-api/src/main/java/io/openaev/aop/audit_log/AccessControlAuditLogger.java b/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditLogger.java similarity index 80% rename from openaev-api/src/main/java/io/openaev/aop/audit_log/AccessControlAuditLogger.java rename to openaev-api/src/main/java/io/openaev/aop/audit_log/AuditLogger.java index e1f3b503343..340817d0bdc 100644 --- a/openaev-api/src/main/java/io/openaev/aop/audit_log/AccessControlAuditLogger.java +++ b/openaev-api/src/main/java/io/openaev/aop/audit_log/AuditLogger.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.databind.JsonNode; import io.openaev.aop.AccessControl; import io.openaev.aop.AccessControlAspect; +import io.openaev.config.ThreadPoolTaskLoggerConfig; import io.openaev.database.model.Action; import io.openaev.database.model.ResourceType; import io.openaev.service.LogService; @@ -27,7 +28,7 @@ @ConditionalOnProperty(name = "openaev.audit-logs.service.enabled", havingValue = "true") @RequiredArgsConstructor @Slf4j -public class AccessControlAuditLogger { +public class AuditLogger { private final AuditRequestValidator auditRequestValidator; @@ -42,7 +43,24 @@ public boolean isAuditLoggingValid(Action action) { } /** Wraps the audit service call in try/catch — audit must never break the business flow. */ - @Async("accessControlAuditLoggerExecutor") + @Async("taskLoggerExecutor") + public CompletableFuture logAuthEventWithRequestContext( + ThreadPoolTaskLoggerConfig.ThreadRequestContextHolder.RequestContextData rcd, + String eventScope, + String eventStatus, + String provider, + String reason, + String logUUID) { + + if (rcd != null) { + ThreadPoolTaskLoggerConfig.ThreadRequestContextHolder.setRequestContextData(rcd); + } + + return logAuthEvent(eventScope, eventStatus, provider, reason, logUUID); + } + + /** Wraps the audit service call in try/catch — audit must never break the business flow. */ + @Async("taskLoggerExecutor") public CompletableFuture logAuthEvent( String eventScope, String eventStatus, String provider, String reason, String logUUID) { boolean status = false; @@ -64,7 +82,7 @@ public CompletableFuture logAuthEvent( return CompletableFuture.completedFuture(status); } - @Async("accessControlAuditLoggerExecutor") + @Async("taskLoggerExecutor") public CompletableFuture logAccessControlEvent( String eventScope, String eventStatus, diff --git a/openaev-api/src/main/java/io/openaev/config/AppSecurityConfig.java b/openaev-api/src/main/java/io/openaev/config/AppSecurityConfig.java index 37b8b1349c1..be36db6b75b 100644 --- a/openaev-api/src/main/java/io/openaev/config/AppSecurityConfig.java +++ b/openaev-api/src/main/java/io/openaev/config/AppSecurityConfig.java @@ -5,6 +5,7 @@ import static org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestRedirectFilter.DEFAULT_AUTHORIZATION_REQUEST_BASE_URI; import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.aop.audit_log.AuditLogger; import io.openaev.config.security.OpenSamlConfig; import io.openaev.config.security.SecurityService; import io.openaev.database.model.User; @@ -18,9 +19,11 @@ import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.security.config.Customizer; @@ -64,6 +67,8 @@ public class AppSecurityConfig { private final UserEventService userEventService; private final UserMappingService userMappingService; + @Autowired @Lazy private AuditLogger auditLogger; + @Resource protected ObjectMapper mapper; @Bean @@ -117,6 +122,28 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .logout( logout -> logout + // Audit Log: audit handler fires first, then Spring Security's built-in + // SecurityContextLogoutHandler + // invalidates the session and clears cookies + .addLogoutHandler( + (request, response, authentication) -> { + ThreadPoolTaskLoggerConfig.ThreadRequestContextHolder.RequestContextData + rcd = null; + try { + rcd = + ThreadPoolTaskLoggerConfig.buildThreadRequestContextHolder( + request, authentication); + } catch (Exception e) { + // Never block the logout flow + log.error( + "Failed to prepare request context on the logout callback handler: {}", + e.getMessage(), + e); + } + + auditLogger.logAuthEventWithRequestContext( + rcd, "logout", "success", null, null, null); + }) .invalidateHttpSession(true) .deleteCookies("JSESSIONID", openAEVConfig.getCookieName()) .logoutSuccessUrl( @@ -131,9 +158,10 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { auth.authorizationRequestResolver( authorizationRequestResolver( http.getSharedObject(ClientRegistrationRepository.class)))) - .successHandler(new SsoRefererAuthenticationSuccessHandler()) + .successHandler(new SsoRefererAuthenticationSuccessHandler(this.auditLogger)) .failureHandler( - new SsoRefererAuthenticationFailureHandler(this.userEventService))); + new SsoRefererAuthenticationFailureHandler( + this.userEventService, this.auditLogger))); } if (openAEVConfig.isAuthSaml2Enable()) { diff --git a/openaev-api/src/main/java/io/openaev/config/ThreadPoolTaskLoggerConfig.java b/openaev-api/src/main/java/io/openaev/config/ThreadPoolTaskLoggerConfig.java index 03592842389..f3f2f4beb13 100644 --- a/openaev-api/src/main/java/io/openaev/config/ThreadPoolTaskLoggerConfig.java +++ b/openaev-api/src/main/java/io/openaev/config/ThreadPoolTaskLoggerConfig.java @@ -10,6 +10,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.i18n.LocaleContextHolder; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContext; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.web.context.request.RequestContextHolder; @@ -21,49 +22,36 @@ public class ThreadPoolTaskLoggerConfig { private ThreadPoolTaskExecutor createBaseExecutor() { ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); - // TODO: find a better way to configure this variables dynamically - maybe through properties - // file. + // TODO AUDIT: find a better way to configure this variables dynamically - maybe through + // properties file. executor.setCorePoolSize(10); executor.setMaxPoolSize(50); executor.setQueueCapacity(1000); - executor.setThreadNamePrefix("AuditLogger-"); + executor.setThreadNamePrefix("TaskLogger-"); return executor; } - @Bean(name = "accessControlAuditLoggerExecutor") + @Bean(name = "taskLoggerExecutor") public Executor contextAwareExecutor() { ThreadPoolTaskExecutor executor = createBaseExecutor(); executor.setTaskDecorator( runnable -> { - // CAPTURE REQUEST HEADERS AND IP, REQUEST URI and BODY (PARENT THREAD) + // CAPTURE REQUEST (PARENT THREAD) var requestAttributes = RequestContextHolder.getRequestAttributes(); HttpServletRequest request = requestAttributes instanceof ServletRequestAttributes attrs ? attrs.getRequest() : null; - Map headers; - String remoteAddress, method, url; - - if (request != null) { - headers = HttpReqRespUtils.extractHeaders(request); - remoteAddress = request.getRemoteAddr(); - method = request.getMethod(); - - url = request.getRequestURL().toString(); - } else { - headers = null; - remoteAddress = method = url = null; - } // CAPTURE LOGs CONTEXT var mdcContext = MDC.getCopyOfContextMap(); // CAPTURE AUTHENTICATION CONTEXT (PARENT THREAD) var originalSecurityContext = SecurityContextHolder.getContext(); - var authentication = originalSecurityContext.getAuthentication(); + Authentication authentication = originalSecurityContext.getAuthentication(); SecurityContext securityContextCopy = SecurityContextHolder.createEmptyContext(); // SAFE COPY (IMPORTANT) @@ -75,12 +63,14 @@ public Executor contextAwareExecutor() { // CAPTURE LOCALE CONTEXT (PARENT THREAD) var localeContext = LocaleContextHolder.getLocaleContext(); + // CREATE REQUEST CONTEXT HOLDER DATA WITH REQUEST HEADERS AND IP, REQUEST URI... + ThreadRequestContextHolder.RequestContextData rcd = + buildThreadRequestContextHolder(request, authentication); + return () -> { try { // STORE HEADERS AND REMOTE ADDRESS - ThreadRequestContextHolder.setRequestContextData( - new ThreadRequestContextHolder.RequestContextData( - headers, remoteAddress, method, url)); + ThreadRequestContextHolder.setRequestContextData(rcd); // RESTORE MDC if (mdcContext != null) { @@ -112,10 +102,36 @@ public Executor contextAwareExecutor() { return executor; } + public static ThreadRequestContextHolder.RequestContextData buildThreadRequestContextHolder( + HttpServletRequest request, Authentication authentication) { + // GET REQUEST HEADERS AND IP, REQUEST URI and BODY (PARENT THREAD) + Map headers; + String remoteAddress, method, url; + + if (request != null) { + headers = HttpReqRespUtils.extractHeaders(request); + remoteAddress = request.getRemoteAddr(); + method = request.getMethod(); + + url = request.getRequestURL().toString(); + } else { + headers = null; + remoteAddress = method = url = null; + } + + // STORE HEADERS AND REMOTE ADDRESS + return new ThreadRequestContextHolder.RequestContextData( + headers, remoteAddress, method, url, authentication); + } + public static class ThreadRequestContextHolder { public record RequestContextData( - Map headers, String remoteAddress, String method, String url) {} + Map headers, + String remoteAddress, + String method, + String url, + Authentication authentication) {} private static final ThreadLocal> CONTEXT = new ThreadLocal<>(); diff --git a/openaev-api/src/main/java/io/openaev/config/security/OpenSamlConfig.java b/openaev-api/src/main/java/io/openaev/config/security/OpenSamlConfig.java index 8e15fd693fa..78b76478745 100644 --- a/openaev-api/src/main/java/io/openaev/config/security/OpenSamlConfig.java +++ b/openaev-api/src/main/java/io/openaev/config/security/OpenSamlConfig.java @@ -5,6 +5,7 @@ import static io.openaev.database.model.User.ROLE_USER; import static org.springframework.security.saml2.provider.service.authentication.OpenSaml4AuthenticationProvider.createDefaultResponseAuthenticationConverter; +import io.openaev.aop.audit_log.AuditLogger; import io.openaev.config.OpenAEVSaml2User; import io.openaev.database.model.User; import io.openaev.security.SsoRefererAuthenticationSuccessHandler; @@ -17,6 +18,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; import org.springframework.core.env.Environment; import org.springframework.security.authentication.ProviderManager; import org.springframework.security.config.annotation.web.builders.HttpSecurity; @@ -49,6 +51,8 @@ public class OpenSamlConfig { private final UserEventService userEventService; + @Autowired @Lazy private AuditLogger auditLogger; + public void addOpenSamlConfig(@NotNull final HttpSecurity http) throws Exception { if (this.relyingPartyRegistrationRepository == null) { log.warn("No RelyingPartyRegistrationRepository found, skipping SAML2 configuration."); @@ -66,7 +70,7 @@ public void addOpenSamlConfig(@NotNull final HttpSecurity http) throws Exception saml2Login -> saml2Login .authenticationManager(new ProviderManager(authenticationProvider)) - .successHandler(new SsoRefererAuthenticationSuccessHandler())); + .successHandler(new SsoRefererAuthenticationSuccessHandler(this.auditLogger))); } // -- PRIVATE -- diff --git a/openaev-api/src/main/java/io/openaev/rest/user/UserApi.java b/openaev-api/src/main/java/io/openaev/rest/user/UserApi.java index 427dd32003c..16e7d8b7977 100644 --- a/openaev-api/src/main/java/io/openaev/rest/user/UserApi.java +++ b/openaev-api/src/main/java/io/openaev/rest/user/UserApi.java @@ -2,6 +2,7 @@ import io.openaev.aop.AccessControl; import io.openaev.aop.UserRoleDescription; +import io.openaev.aop.audit_log.AuditLogger; import io.openaev.config.SessionManager; import io.openaev.database.model.Action; import io.openaev.database.model.ResourceType; @@ -51,6 +52,7 @@ public class UserApi extends RestBehavior { private final UserRepository userRepository; private final UserService userService; private final UserEventService userEventService; + private final AuditLogger auditLogger; @Operation(description = "Endpoint to login", summary = "Endpoint to login") @ApiResponses( @@ -70,11 +72,14 @@ public User login(@Valid @RequestBody LoginUserInput input) { if (userService.isUserPasswordValid(user, input.getPassword())) { userService.createUserSession(user); userEventService.createLoginSuccessEvent(user); + auditLogger.logAuthEvent("login", "success", "local", null, null); return user; } } userEventService.createLoginFailedEvent( "local login", BadCredentialsException.class.getSimpleName()); + auditLogger.logAuthEvent( + "login", "error", "local", BadCredentialsException.class.getSimpleName(), null); throw new BadCredentialsException("Invalid credential."); } diff --git a/openaev-api/src/main/java/io/openaev/security/SsoRefererAuthenticationFailureHandler.java b/openaev-api/src/main/java/io/openaev/security/SsoRefererAuthenticationFailureHandler.java index d8ae63f2fb2..de7bf31d867 100644 --- a/openaev-api/src/main/java/io/openaev/security/SsoRefererAuthenticationFailureHandler.java +++ b/openaev-api/src/main/java/io/openaev/security/SsoRefererAuthenticationFailureHandler.java @@ -2,6 +2,7 @@ import static org.springframework.http.HttpHeaders.REFERER; +import io.openaev.aop.audit_log.AuditLogger; import io.openaev.service.user_events.UserEventService; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; @@ -18,9 +19,12 @@ public class SsoRefererAuthenticationFailureHandler extends SimpleUrlAuthenticat private RequestCache requestCache = new HttpSessionRequestCache(); private final UserEventService userEventService; + private final AuditLogger auditLogger; - public SsoRefererAuthenticationFailureHandler(UserEventService userEventService) { + public SsoRefererAuthenticationFailureHandler( + UserEventService userEventService, AuditLogger auditLogger) { this.userEventService = userEventService; + this.auditLogger = auditLogger; } @Override @@ -29,6 +33,8 @@ public void onAuthenticationFailure( throws ServletException, IOException { userEventService.createLoginFailedEvent( request.getRequestURI(), exception.getClass().getSimpleName()); + auditLogger.logAuthEvent( + "login", "error", request.getRequestURI(), exception.getClass().getSimpleName(), null); this.saveException(request, exception); SavedRequest savedRequest = this.requestCache.getRequest(request, response); diff --git a/openaev-api/src/main/java/io/openaev/security/SsoRefererAuthenticationSuccessHandler.java b/openaev-api/src/main/java/io/openaev/security/SsoRefererAuthenticationSuccessHandler.java index d6e25b67fda..672b02da4e5 100644 --- a/openaev-api/src/main/java/io/openaev/security/SsoRefererAuthenticationSuccessHandler.java +++ b/openaev-api/src/main/java/io/openaev/security/SsoRefererAuthenticationSuccessHandler.java @@ -2,12 +2,14 @@ import static org.springframework.http.HttpHeaders.REFERER; +import io.openaev.aop.audit_log.AuditLogger; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; import org.springframework.security.core.Authentication; +import org.springframework.security.oauth2.client.authentication.OAuth2AuthenticationToken; import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; import org.springframework.security.web.savedrequest.HttpSessionRequestCache; import org.springframework.security.web.savedrequest.RequestCache; @@ -16,12 +18,32 @@ public class SsoRefererAuthenticationSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { private final RequestCache requestCache = new HttpSessionRequestCache(); + private final AuditLogger auditLogger; + + public SsoRefererAuthenticationSuccessHandler(AuditLogger auditLogger) { + this.auditLogger = auditLogger; + } @Override public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws ServletException, IOException { + + // Audit: log SSO login success + String provider = "sso"; + + try { + if (authentication instanceof OAuth2AuthenticationToken oauth2Token) { + provider = oauth2Token.getAuthorizedClientRegistrationId(); + } + } catch (Exception e) { + // Never block the login flow + } + + auditLogger.logAuthEvent("login", "success", provider, null, null); + SavedRequest savedRequest = this.requestCache.getRequest(request, response); + if (savedRequest != null) { List refererValues = savedRequest.getHeaderValues(REFERER); if (refererValues.size() == 1) { diff --git a/openaev-api/src/main/java/io/openaev/service/LogService.java b/openaev-api/src/main/java/io/openaev/service/LogService.java index 4db13c44c38..318f3b578fd 100644 --- a/openaev-api/src/main/java/io/openaev/service/LogService.java +++ b/openaev-api/src/main/java/io/openaev/service/LogService.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import io.openaev.config.OpenAEVAnonymous; import io.openaev.config.OpenAEVPrincipal; import io.openaev.config.SessionHelper; import io.openaev.config.ThreadPoolTaskLoggerConfig; @@ -20,13 +21,11 @@ import io.openaev.utils.object.ObjectRedactionUtils; import jakarta.servlet.http.HttpServletRequest; import java.time.Instant; -import java.util.LinkedHashMap; -import java.util.List; -import java.util.Map; -import java.util.UUID; +import java.util.*; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; /** @@ -252,15 +251,32 @@ private LogEvent buildBaseAuditLog( /** Resolves the current user ID from the security context, or null if anonymous. */ private String resolveUserId() { + String id = null; + try { OpenAEVPrincipal principal = SessionHelper.currentUser(); - if (principal == null || "anonymous".equals(principal.getId())) { - return null; + + if (principal != null && !(principal instanceof OpenAEVAnonymous)) id = principal.getId(); + + if (id == null) { + ThreadPoolTaskLoggerConfig.ThreadRequestContextHolder.RequestContextData + requestContextData = + ThreadPoolTaskLoggerConfig.ThreadRequestContextHolder.getRequestContextData(); + Authentication auth = requestContextData.authentication(); + + if (auth != null) { + Object princ = auth.getPrincipal(); + + if (princ instanceof OpenAEVPrincipal user) { + id = user.getId(); + } + } } - return principal.getId(); } catch (Exception e) { - return null; + log.warn("[LOG] Failed to resolve user ID: {}", e.getMessage(), e); } + + return id; } /** Populates user metadata (email, IP, user agent) on the given audit log document. */