diff --git a/pom.xml b/pom.xml
index 934c266..3917f8f 100644
--- a/pom.xml
+++ b/pom.xml
@@ -6,7 +6,7 @@
org.springframework.boot
spring-boot-starter-parent
- 3.5.11
+ 3.5.13
it.cnr.isti
diff --git a/src/main/java/it/cnr/isti/workflow/manager/HumainFlowApplication.java b/src/main/java/it/cnr/isti/workflow/manager/HumainFlowApplication.java
index fde3b3c..c6e52b1 100644
--- a/src/main/java/it/cnr/isti/workflow/manager/HumainFlowApplication.java
+++ b/src/main/java/it/cnr/isti/workflow/manager/HumainFlowApplication.java
@@ -8,11 +8,13 @@ import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.context.annotation.Bean;
+import org.springframework.scheduling.annotation.EnableScheduling;
import it.cnr.isti.workflow.manager.auth.services.UserImportComponent;
import it.cnr.isti.workflow.manager.flows.FlowImportComponent;
@SpringBootApplication
+@EnableScheduling
public class HumainFlowApplication {
static {
diff --git a/src/main/java/it/cnr/isti/workflow/manager/app/config/WebConfig.java b/src/main/java/it/cnr/isti/workflow/manager/app/config/WebConfig.java
index 1640d00..ce92113 100644
--- a/src/main/java/it/cnr/isti/workflow/manager/app/config/WebConfig.java
+++ b/src/main/java/it/cnr/isti/workflow/manager/app/config/WebConfig.java
@@ -33,7 +33,7 @@ public class WebConfig implements WebMvcConfigurer {
registry.addMapping("/**")
.allowedOrigins(origins)
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
- .allowedHeaders("*")
+ .allowedHeaders("Content-Type", "Authorization", "X-Requested-With", "Accept")
.allowCredentials(true);
}
diff --git a/src/main/java/it/cnr/isti/workflow/manager/app/health/MCPBridgeHealthIndicator.java b/src/main/java/it/cnr/isti/workflow/manager/app/health/MCPBridgeHealthIndicator.java
new file mode 100644
index 0000000..6408310
--- /dev/null
+++ b/src/main/java/it/cnr/isti/workflow/manager/app/health/MCPBridgeHealthIndicator.java
@@ -0,0 +1,41 @@
+package it.cnr.isti.workflow.manager.app.health;
+
+import java.time.Duration;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.actuate.health.Health;
+import org.springframework.boot.actuate.health.HealthIndicator;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+
+@Component
+public class MCPBridgeHealthIndicator implements HealthIndicator {
+
+ private final WebClient.Builder webClientBuilder;
+ private final String mcpBridgeUrl;
+
+ public MCPBridgeHealthIndicator(WebClient.Builder webClientBuilder,
+ @Value("${app.mcp.bridge.url}") String mcpBridgeUrl) {
+ this.webClientBuilder = webClientBuilder;
+ this.mcpBridgeUrl = mcpBridgeUrl;
+ }
+
+ @Override
+ public Health health() {
+ try {
+ webClientBuilder.build()
+ .get()
+ .uri(mcpBridgeUrl + "/health")
+ .retrieve()
+ .toBodilessEntity()
+ .timeout(Duration.ofSeconds(5))
+ .block();
+ return Health.up().build();
+ } catch (Exception e) {
+ return Health.down()
+ .withDetail("url", mcpBridgeUrl)
+ .withDetail("error", e.getMessage())
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/it/cnr/isti/workflow/manager/app/health/OllamaHealthIndicator.java b/src/main/java/it/cnr/isti/workflow/manager/app/health/OllamaHealthIndicator.java
new file mode 100644
index 0000000..015fc3b
--- /dev/null
+++ b/src/main/java/it/cnr/isti/workflow/manager/app/health/OllamaHealthIndicator.java
@@ -0,0 +1,46 @@
+package it.cnr.isti.workflow.manager.app.health;
+
+import java.time.Duration;
+
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.boot.actuate.health.Health;
+import org.springframework.boot.actuate.health.HealthIndicator;
+import org.springframework.stereotype.Component;
+import org.springframework.web.reactive.function.client.WebClient;
+
+@Component
+public class OllamaHealthIndicator implements HealthIndicator {
+
+ private final WebClient webClient;
+ private final String ollamaUrl;
+ private final String ollamaKey;
+
+ public OllamaHealthIndicator(WebClient.Builder webClientBuilder,
+ @Value("${app.ollama.internal.url}") String ollamaUrl,
+ @Value("${app.ollama.internal.key}") String ollamaKey) {
+ this.ollamaUrl = ollamaUrl;
+ this.ollamaKey = ollamaKey;
+ this.webClient = webClientBuilder.baseUrl(ollamaUrl).build();
+ }
+
+ @Override
+ public Health health() {
+ try {
+ webClient.get()
+ .uri("/tags")
+ .header("Authorization", "Bearer " + ollamaKey)
+ .retrieve()
+ .bodyToMono(String.class)
+ .timeout(Duration.ofSeconds(5))
+ .block();
+ return Health.up()
+ .withDetail("url", ollamaUrl)
+ .build();
+ } catch (Exception e) {
+ return Health.down()
+ .withDetail("url", ollamaUrl)
+ .withDetail("error", e.getMessage())
+ .build();
+ }
+ }
+}
diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/config/JwtAuthenticationFilter.java b/src/main/java/it/cnr/isti/workflow/manager/auth/config/JwtAuthenticationFilter.java
index 89765ef..5db944d 100644
--- a/src/main/java/it/cnr/isti/workflow/manager/auth/config/JwtAuthenticationFilter.java
+++ b/src/main/java/it/cnr/isti/workflow/manager/auth/config/JwtAuthenticationFilter.java
@@ -18,6 +18,7 @@ import it.cnr.isti.workflow.manager.auth.repo.AuthRepository;
import it.cnr.isti.workflow.manager.auth.repo.LoginEntity;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
+import jakarta.servlet.DispatcherType;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@@ -57,6 +58,20 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return;
}
+ if (request.getDispatcherType() == DispatcherType.ERROR) {
+ logger.warn("Error dispatch reached security filter: {} {} | originalUri={} | originalStatus={} | exceptionType={} | exceptionMessage={} | authCookiePresent={} | authHeaderPresent={} | origin={} | remote={}",
+ request.getMethod(),
+ path,
+ request.getAttribute("jakarta.servlet.error.request_uri"),
+ request.getAttribute("jakarta.servlet.error.status_code"),
+ request.getAttribute("jakarta.servlet.error.exception_type"),
+ request.getAttribute("jakarta.servlet.error.message"),
+ extractJwtFromCookie(request) != null,
+ hasBearerHeader(request),
+ request.getHeader("Origin"),
+ request.getRemoteAddr());
+ }
+
String jwt = resolveJwt(request);
if (StringUtils.hasText(jwt)) {
@@ -73,18 +88,33 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authToken);
+ logger.debug("JWT authentication established for user '{}' on {} {} | dispatcher={} | tokenSource={} | origin={} | remote={}",
+ username, request.getMethod(), path, request.getDispatcherType(), resolveJwtSource(request),
+ request.getHeader("Origin"), request.getRemoteAddr());
} else {
- logger.warn("JWT validation failed for user '{}' on {} {}", username, request.getMethod(), path);
+ logger.warn("JWT validation failed for user '{}' on {} {} | dispatcher={} | tokenSource={} | origin={} | remote={}",
+ username, request.getMethod(), path, request.getDispatcherType(), resolveJwtSource(request),
+ request.getHeader("Origin"), request.getRemoteAddr());
}
}
} catch (Exception e) {
- logger.warn("JWT processing failed on {} {}: {}", request.getMethod(), path, e.getMessage());
+ logger.warn("JWT processing failed on {} {} | dispatcher={} | tokenSource={} | origin={} | remote={} | error={}",
+ request.getMethod(), path, request.getDispatcherType(), resolveJwtSource(request), request.getHeader("Origin"),
+ request.getRemoteAddr(), e.getMessage());
}
+ } else if (!isExcludedPath(path)) {
+ logger.debug("No JWT resolved for protected request {} {} | dispatcher={} | origin={} | remote={} | cookiesPresent={} | authHeaderPresent={}",
+ request.getMethod(), path, request.getDispatcherType(), request.getHeader("Origin"), request.getRemoteAddr(),
+ request.getCookies() != null && request.getCookies().length > 0, hasBearerHeader(request));
}
filterChain.doFilter(request, response);
}
+ private boolean isExcludedPath(String path) {
+ return EXCLUDED_PATHS.stream().anyMatch(path::startsWith);
+ }
+
private String resolveJwt(HttpServletRequest request) {
String jwtFromCookie = extractJwtFromCookie(request);
if (StringUtils.hasText(jwtFromCookie)) {
@@ -97,6 +127,19 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter {
return null;
}
+ private String resolveJwtSource(HttpServletRequest request) {
+ String jwtFromCookie = extractJwtFromCookie(request);
+ if (StringUtils.hasText(jwtFromCookie)) {
+ return "cookie";
+ }
+ return hasBearerHeader(request) ? "authorization-header" : "none";
+ }
+
+ private boolean hasBearerHeader(HttpServletRequest request) {
+ String authHeader = request.getHeader("Authorization");
+ return StringUtils.hasText(authHeader) && authHeader.startsWith("Bearer ");
+ }
+
private String extractJwtFromCookie(HttpServletRequest request) {
Cookie[] cookies = request.getCookies();
if (cookies == null) {
diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java b/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java
index 12de347..70dfd88 100644
--- a/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java
+++ b/src/main/java/it/cnr/isti/workflow/manager/auth/config/SecurityConfig.java
@@ -1,6 +1,10 @@
package it.cnr.isti.workflow.manager.auth.config;
+import java.util.Arrays;
+
+import org.slf4j.Logger;
import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
@@ -10,8 +14,16 @@ import org.springframework.security.config.annotation.method.configuration.Enabl
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
+import org.springframework.security.core.Authentication;
+import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
+import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
+import org.springframework.security.web.csrf.CsrfTokenRequestAttributeHandler;
+
+import jakarta.servlet.http.Cookie;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.DispatcherType;
@Configuration
@@ -19,14 +31,21 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
@EnableMethodSecurity
public class SecurityConfig {
+ private static final Logger logger = org.slf4j.LoggerFactory.getLogger(SecurityConfig.class);
+
@Autowired
private JwtAuthenticationFilter jwtFilter;
+ @Value("${app.auth.cookie.name:auth_token}")
+ private String authCookieName;
+
+ @Value("${app.security.csrf.enabled:false}")
+ private boolean csrfEnabled;
+
@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
- return http
+ http
.cors(Customizer.withDefaults())
- .csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/auth/login").permitAll()
.requestMatchers("/auth/register").permitAll()
@@ -36,12 +55,57 @@ public class SecurityConfig {
.requestMatchers("/swagger-ui/**").permitAll()
.requestMatchers("/v3/api-docs/**").permitAll()
.requestMatchers("/actuator/**").permitAll()
+ .requestMatchers("/error").permitAll()
.requestMatchers("/blocks/types").permitAll()
.requestMatchers("/blocks/types/**").permitAll()
.requestMatchers("/containers/types").permitAll()
.requestMatchers("/containers/types/**").permitAll()
.requestMatchers("/retriever/**").permitAll()
- .anyRequest().authenticated())
+ .anyRequest().authenticated());
+
+ if (csrfEnabled) {
+ logger.info("CSRF protection is ENABLED");
+ CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
+ http.csrf(csrf -> csrf
+ .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
+ .csrfTokenRequestHandler(requestHandler)
+ .ignoringRequestMatchers(
+ "/auth/login", "/auth/register", "/auth/logout",
+ "/auth/change-password", "/actuator/**"));
+ } else {
+ logger.warn("CSRF protection is DISABLED. Set app.security.csrf.enabled=true for production.");
+ http.csrf(csrf -> csrf.disable());
+ }
+
+ return http
+ .exceptionHandling(exceptions -> exceptions
+ .authenticationEntryPoint((request, response, authException) -> {
+ logger.warn(
+ "Rejecting unauthenticated request as 403: {} {} | authHeaderPresent={} | authCookiePresent={} | origin={} | remote={} | reason={}",
+ request.getMethod(),
+ request.getRequestURI(),
+ hasBearerHeader(request),
+ hasAuthCookie(request, authCookieName),
+ request.getHeader("Origin"),
+ request.getRemoteAddr(),
+ authException == null ? "n/a" : authException.getMessage());
+ response.sendError(403, "Forbidden");
+ })
+ .accessDeniedHandler((request, response, accessDeniedException) -> {
+ Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
+ logger.warn(
+ "Rejecting authenticated request with real 403: {} {} | principal={} | authorities={} | authHeaderPresent={} | authCookiePresent={} | origin={} | remote={} | reason={}",
+ request.getMethod(),
+ request.getRequestURI(),
+ authentication == null ? "anonymous" : authentication.getName(),
+ authentication == null ? "[]" : authentication.getAuthorities(),
+ hasBearerHeader(request),
+ hasAuthCookie(request, authCookieName),
+ request.getHeader("Origin"),
+ request.getRemoteAddr(),
+ accessDeniedException == null ? "n/a" : accessDeniedException.getMessage());
+ response.sendError(403, "Forbidden");
+ }))
.sessionManagement(sess -> sess.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.build();
@@ -51,4 +115,17 @@ public class SecurityConfig {
AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
+
+ private static boolean hasBearerHeader(HttpServletRequest request) {
+ String authHeader = request.getHeader("Authorization");
+ return authHeader != null && authHeader.startsWith("Bearer ");
+ }
+
+ private static boolean hasAuthCookie(HttpServletRequest request, String authCookieName) {
+ Cookie[] cookies = request.getCookies();
+ if (cookies == null) {
+ return false;
+ }
+ return Arrays.stream(cookies).anyMatch(cookie -> authCookieName.equals(cookie.getName()));
+ }
}
diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/services/AuthService.java b/src/main/java/it/cnr/isti/workflow/manager/auth/services/AuthService.java
index 82e97d7..00548d0 100644
--- a/src/main/java/it/cnr/isti/workflow/manager/auth/services/AuthService.java
+++ b/src/main/java/it/cnr/isti/workflow/manager/auth/services/AuthService.java
@@ -139,8 +139,19 @@ public class AuthService {
return authRepository.findByUsernameAndActiveTrue(username).orElse(null);
}
+ private static final java.util.Set COMMON_PASSWORDS = java.util.Set.of(
+ "password", "12345678", "123456789", "1234567890", "qwerty",
+ "letmein", "welcome", "admin", "trustno1", "iloveyou",
+ "sunshine", "princess", "football", "charlie", "access",
+ "master", "monkey", "dragon", "shadow", "michael",
+ "password1", "password123", "abc123", "changeme");
+
private boolean isValidPassword(String password) {
- if (password == null || password.length() < 8) {
+ if (password == null || password.length() < 10) {
+ return false;
+ }
+ String lower = password.toLowerCase(java.util.Locale.ROOT);
+ if (COMMON_PASSWORDS.stream().anyMatch(lower::contains)) {
return false;
}
boolean hasUppercase = false;
diff --git a/src/main/java/it/cnr/isti/workflow/manager/auth/services/LoginRateLimiter.java b/src/main/java/it/cnr/isti/workflow/manager/auth/services/LoginRateLimiter.java
new file mode 100644
index 0000000..ddee5d3
--- /dev/null
+++ b/src/main/java/it/cnr/isti/workflow/manager/auth/services/LoginRateLimiter.java
@@ -0,0 +1,42 @@
+package it.cnr.isti.workflow.manager.auth.services;
+
+import java.util.Deque;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ConcurrentLinkedDeque;
+
+import org.springframework.scheduling.annotation.Scheduled;
+import org.springframework.stereotype.Component;
+
+@Component
+public class LoginRateLimiter {
+
+ private static final int MAX_ATTEMPTS = 10;
+ private static final long WINDOW_MS = 60_000;
+
+ private final ConcurrentHashMap> requestLog = new ConcurrentHashMap<>();
+
+ public boolean isAllowed(String key) {
+ long now = System.currentTimeMillis();
+ Deque timestamps = requestLog.computeIfAbsent(key, k -> new ConcurrentLinkedDeque<>());
+ while (!timestamps.isEmpty() && timestamps.peekFirst() < now - WINDOW_MS) {
+ timestamps.pollFirst();
+ }
+ if (timestamps.size() >= MAX_ATTEMPTS) {
+ return false;
+ }
+ timestamps.addLast(now);
+ return true;
+ }
+
+ @Scheduled(fixedRate = 300_000)
+ public void cleanup() {
+ long now = System.currentTimeMillis();
+ requestLog.entrySet().removeIf(entry -> {
+ Deque ts = entry.getValue();
+ while (!ts.isEmpty() && ts.peekFirst() < now - WINDOW_MS) {
+ ts.pollFirst();
+ }
+ return ts.isEmpty();
+ });
+ }
+}
diff --git a/src/main/java/it/cnr/isti/workflow/manager/controllers/ApiExceptionHandler.java b/src/main/java/it/cnr/isti/workflow/manager/controllers/ApiExceptionHandler.java
index ce8ed2b..756e64a 100644
--- a/src/main/java/it/cnr/isti/workflow/manager/controllers/ApiExceptionHandler.java
+++ b/src/main/java/it/cnr/isti/workflow/manager/controllers/ApiExceptionHandler.java
@@ -6,8 +6,10 @@ import java.util.Map;
import java.util.stream.Collectors;
import org.slf4j.Logger;
+import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
+import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.FieldError;
import org.springframework.validation.ObjectError;
import org.springframework.web.bind.MethodArgumentNotValidException;
@@ -15,6 +17,7 @@ import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;
+import jakarta.servlet.http.HttpServletRequest;
import it.cnr.isti.workflow.manager.flows.validation.ValidationError;
import it.cnr.isti.workflow.manager.flows.validation.ValidationErrorCode;
import it.cnr.isti.workflow.manager.flows.validation.ValidationErrorCodec;
@@ -24,6 +27,9 @@ public class ApiExceptionHandler {
private static final Logger logger = org.slf4j.LoggerFactory.getLogger(ApiExceptionHandler.class);
+ @Autowired
+ private HttpServletRequest request;
+
@ExceptionHandler(MethodArgumentNotValidException.class)
public ProblemDetail handleMethodArgumentNotValid(MethodArgumentNotValidException e) {
List