Security hardening, execution variables, and health checks
- Add login rate limiting (LoginRateLimiter) - Enhance JWT authentication filter with improved error handling - Strengthen SecurityConfig with CSRF, headers, and session management - Add password hashing support in AuthService - Improve API exception handling with structured error responses - Add execution template variable resolution with sanitization - Add execution ownership validation in ExecutionRepository - Add Ollama health indicator endpoint - Add CORS configuration update - Update controller input validation and authorization checks - Add related unit tests
This commit is contained in:
parent
b3d495cf6a
commit
1cfb95e459
2
pom.xml
2
pom.xml
|
|
@ -6,7 +6,7 @@
|
|||
<parent>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-parent</artifactId>
|
||||
<version>3.5.11</version>
|
||||
<version>3.5.13</version>
|
||||
<relativePath /> <!-- lookup parent from repository -->
|
||||
</parent>
|
||||
<groupId>it.cnr.isti</groupId>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -139,8 +139,19 @@ public class AuthService {
|
|||
return authRepository.findByUsernameAndActiveTrue(username).orElse(null);
|
||||
}
|
||||
|
||||
private static final java.util.Set<String> 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;
|
||||
|
|
|
|||
|
|
@ -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<String, Deque<Long>> requestLog = new ConcurrentHashMap<>();
|
||||
|
||||
public boolean isAllowed(String key) {
|
||||
long now = System.currentTimeMillis();
|
||||
Deque<Long> 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<Long> ts = entry.getValue();
|
||||
while (!ts.isEmpty() && ts.peekFirst() < now - WINDOW_MS) {
|
||||
ts.pollFirst();
|
||||
}
|
||||
return ts.isEmpty();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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<Map<String, Object>> errors = e.getBindingResult().getAllErrors().stream()
|
||||
|
|
@ -34,7 +40,8 @@ public class ApiExceptionHandler {
|
|||
.map(error -> String.valueOf(error.getOrDefault("message", "Validation failed")))
|
||||
.collect(Collectors.joining(", "));
|
||||
|
||||
logger.warn("Request validation failed with status 400 BAD_REQUEST: {}", detail);
|
||||
logger.warn("Request validation failed with status 400 BAD_REQUEST on {} {}: {}",
|
||||
request.getMethod(), request.getRequestURI(), detail);
|
||||
|
||||
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
|
||||
problem.setTitle("Bad Request");
|
||||
|
|
@ -43,9 +50,22 @@ public class ApiExceptionHandler {
|
|||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(HttpMessageNotReadableException.class)
|
||||
public ProblemDetail handleHttpMessageNotReadable(HttpMessageNotReadableException e) {
|
||||
String detail = resolveReadableMessage(e);
|
||||
logger.warn("Request body unreadable on {} {}: {}",
|
||||
request.getMethod(), request.getRequestURI(), detail);
|
||||
|
||||
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.BAD_REQUEST);
|
||||
problem.setTitle("Bad Request");
|
||||
problem.setDetail(detail);
|
||||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(ResponseStatusException.class)
|
||||
public ProblemDetail handleResponseStatus(ResponseStatusException e) {
|
||||
logger.warn("Request failed with status {}: {}", e.getStatusCode(), e.getReason());
|
||||
logger.warn("Request failed on {} {} with status {}: {}",
|
||||
request.getMethod(), request.getRequestURI(), e.getStatusCode(), e.getReason());
|
||||
|
||||
ProblemDetail problem = ProblemDetail.forStatus(e.getStatusCode());
|
||||
problem.setTitle(e.getStatusCode().toString());
|
||||
|
|
@ -54,6 +74,31 @@ public class ApiExceptionHandler {
|
|||
return problem;
|
||||
}
|
||||
|
||||
@ExceptionHandler(Exception.class)
|
||||
public ProblemDetail handleGenericException(Exception e) {
|
||||
logger.error("Unhandled exception on {} {}: {}",
|
||||
request.getMethod(), request.getRequestURI(), e.getMessage(), e);
|
||||
|
||||
ProblemDetail problem = ProblemDetail.forStatus(HttpStatus.INTERNAL_SERVER_ERROR);
|
||||
problem.setTitle(HttpStatus.INTERNAL_SERVER_ERROR.toString());
|
||||
problem.setDetail(e.getMessage() == null || e.getMessage().isBlank() ? "Internal server error" : e.getMessage());
|
||||
return problem;
|
||||
}
|
||||
|
||||
private String resolveReadableMessage(HttpMessageNotReadableException e) {
|
||||
Throwable cause = e.getMostSpecificCause();
|
||||
String message = cause != null && cause.getMessage() != null && !cause.getMessage().isBlank()
|
||||
? cause.getMessage()
|
||||
: e.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
return "Request body is invalid";
|
||||
}
|
||||
if (message.contains("missing type id property 'type'")) {
|
||||
return "Request body is invalid: missing required property 'type' for container configuration";
|
||||
}
|
||||
return message;
|
||||
}
|
||||
|
||||
private List<ValidationError> toValidationErrors(ObjectError error) {
|
||||
List<ValidationError> decoded = ValidationErrorCodec.decode(error.getDefaultMessage());
|
||||
if (decoded.isEmpty()) {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import it.cnr.isti.workflow.manager.auth.model.ChangePasswordRequest;
|
|||
import it.cnr.isti.workflow.manager.auth.model.CreateUserResult;
|
||||
import it.cnr.isti.workflow.manager.auth.model.DeleteUserResult;
|
||||
import it.cnr.isti.workflow.manager.auth.services.AuthService;
|
||||
import it.cnr.isti.workflow.manager.auth.services.LoginRateLimiter;
|
||||
import it.cnr.isti.workflow.manager.auth.services.TurnstileService;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
|
|
@ -36,6 +37,7 @@ import org.eclipse.microprofile.openapi.annotations.Operation;
|
|||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
|
||||
import jakarta.servlet.http.HttpServletRequest;
|
||||
import jakarta.servlet.http.HttpServletResponse;
|
||||
|
||||
@RestController
|
||||
|
|
@ -53,6 +55,9 @@ public class AuthController {
|
|||
@Autowired
|
||||
private TurnstileService turnstileService;
|
||||
|
||||
@Autowired
|
||||
private LoginRateLimiter loginRateLimiter;
|
||||
|
||||
@Value("${app.auth.cookie.name:auth_token}")
|
||||
private String authCookieName;
|
||||
|
||||
|
|
@ -64,8 +69,11 @@ public class AuthController {
|
|||
|
||||
@PostMapping("/login")
|
||||
@Operation(summary = "Login", description = "Authenticates a user and returns a JWT token on success.")
|
||||
public ResponseEntity<?> login(@RequestBody AuthRequest request, HttpServletResponse servletResponse) {
|
||||
logger.info("Login attempt for user: {}", request.getUsername());
|
||||
public ResponseEntity<?> login(@RequestBody AuthRequest request, HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
|
||||
logger.debug("Login attempt for user: {}", request.getUsername());
|
||||
if (!loginRateLimiter.isAllowed(servletRequest.getRemoteAddr())) {
|
||||
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS).body("Too many login attempts. Try again later.");
|
||||
}
|
||||
if (request.getUsername() == null || request.getPassword() == null) {
|
||||
return ResponseEntity.badRequest().body("Username and password are required");
|
||||
}
|
||||
|
|
@ -89,7 +97,7 @@ public class AuthController {
|
|||
@PostMapping("/register")
|
||||
@Operation(summary = "Register user", description = "Registers a new user account with username and password.")
|
||||
public ResponseEntity<?> register(@RequestBody AuthRequest request, HttpServletResponse servletResponse) {
|
||||
logger.info("Register attempt for user {}", request.getUsername());
|
||||
logger.debug("Register attempt for user {}", request.getUsername());
|
||||
if (request.getUsername() == null || request.getPassword() == null || request.getEmail() == null) {
|
||||
return ResponseEntity.badRequest().body("Username, password and email are required");
|
||||
}
|
||||
|
|
@ -168,6 +176,7 @@ public class AuthController {
|
|||
return ResponseEntity.badRequest().body("username, password and email are required");
|
||||
}
|
||||
CreateUserResult result = authService.createUser(request.username(), request.password(), request.email(), request.role());
|
||||
logger.info("AUDIT: admin createUser username={} result={}", request.username(), result);
|
||||
return switch (result) {
|
||||
case SUCCESS -> ResponseEntity.ok().build();
|
||||
case USER_ALREADY_EXISTS -> ResponseEntity.badRequest().body("USER_ALREADY_EXISTS");
|
||||
|
|
@ -186,6 +195,7 @@ public class AuthController {
|
|||
return ResponseEntity.badRequest().body("username and newPassword are required");
|
||||
}
|
||||
AdminChangePasswordResult result = authService.adminChangePassword(username, request.newPassword());
|
||||
logger.info("AUDIT: admin changePassword username={} result={}", username, result);
|
||||
return switch (result) {
|
||||
case SUCCESS -> ResponseEntity.ok().build();
|
||||
case USER_NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body("User " + username + " not found");
|
||||
|
|
@ -202,6 +212,7 @@ public class AuthController {
|
|||
return ResponseEntity.badRequest().body("username and role are required");
|
||||
}
|
||||
ChangeUserRoleResult result = authService.changeUserRole(username, request.role());
|
||||
logger.info("AUDIT: admin changeRole username={} newRole={} result={}", username, request.role(), result);
|
||||
return switch (result) {
|
||||
case SUCCESS -> ResponseEntity.ok().build();
|
||||
case USER_NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body("User " + username + " not found");
|
||||
|
|
@ -218,6 +229,7 @@ public class AuthController {
|
|||
return ResponseEntity.badRequest().body("username is required");
|
||||
}
|
||||
DeleteUserResult result = authService.deleteUser(username);
|
||||
logger.info("AUDIT: admin deleteUser username={} result={}", username, result);
|
||||
return switch (result) {
|
||||
case SUCCESS -> ResponseEntity.ok().build();
|
||||
case USER_NOT_FOUND -> ResponseEntity.status(HttpStatus.NOT_FOUND).body("User " + username + " not found");
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ package it.cnr.isti.workflow.manager.controllers;
|
|||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.slf4j.Logger;
|
||||
import org.eclipse.microprofile.openapi.annotations.Operation;
|
||||
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
|
@ -28,6 +30,8 @@ import it.cnr.isti.workflow.manager.ios.IODescriptor;
|
|||
@RequestMapping("/containers")
|
||||
public class ContainersController {
|
||||
|
||||
private static final Logger logger = org.slf4j.LoggerFactory.getLogger(ContainersController.class);
|
||||
|
||||
Map<String, ContainerType> containerTypes;
|
||||
|
||||
List<ContainerFactory<?, ?>> containerFactories;
|
||||
|
|
@ -115,11 +119,24 @@ public class ContainersController {
|
|||
@SecurityRequirement(name = "bearerAuth")
|
||||
@Operation(summary = "Create container", description = "Creates a container from the provided container configuration.")
|
||||
public <T extends ContainerType, C extends ContainerConfiguration<T>> Container<T> create(@RequestBody C configuration) {
|
||||
logger.debug("Create container request received | payloadClass={} | containerType={} | containerName={}",
|
||||
configuration == null ? "null" : configuration.getClass().getName(),
|
||||
configuration == null || configuration.getContainerType() == null ? "null"
|
||||
: configuration.getContainerType().getSimpleName(),
|
||||
configuration == null ? "null" : configuration.getName());
|
||||
ContainerFactory<T, C> factory = (ContainerFactory<T, C>) containerFactories.stream()
|
||||
.filter(f -> f.getContainerType().equals(configuration.getContainerType()))
|
||||
.findFirst()
|
||||
.orElseThrow(() -> new IllegalArgumentException("Container factory not found for type: " + configuration.getContainerType()));
|
||||
return factory.create(configuration);
|
||||
logger.debug("Resolved container factory {} for type {}",
|
||||
factory.getClass().getName(),
|
||||
configuration.getContainerType().getSimpleName());
|
||||
Container<T> container = factory.create(configuration);
|
||||
logger.debug("Container created successfully | containerId={} | containerType={} | containerName={}",
|
||||
container == null ? "null" : container.getId(),
|
||||
container == null || container.getType() == null ? "null" : container.getType().getName(),
|
||||
container == null ? "null" : container.getName());
|
||||
return container;
|
||||
}
|
||||
|
||||
@PostMapping("/validate-subflow")
|
||||
|
|
|
|||
|
|
@ -11,6 +11,8 @@ import org.eclipse.microprofile.openapi.annotations.Operation;
|
|||
import org.eclipse.microprofile.openapi.annotations.security.SecurityRequirement;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.web.server.WebServerException;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.security.core.annotation.AuthenticationPrincipal;
|
||||
|
|
@ -132,6 +134,22 @@ public class ExecutionsController {
|
|||
return visibleExecutions(userDetails).stream().map(ExecutionView::fromExecution).toList();
|
||||
}
|
||||
|
||||
@Operation(summary = "Retrieves executions (paginated)", description = "Retrieves a paginated list of executions for the authenticated user")
|
||||
@GetMapping(path = "paged")
|
||||
public Page<ExecutionView> getAllPaged(
|
||||
@RequestParam(defaultValue = "0") int page,
|
||||
@RequestParam(defaultValue = "50") int size,
|
||||
@AuthenticationPrincipal LoginEntity userDetails) {
|
||||
if (userDetails == null) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Authentication required");
|
||||
}
|
||||
if (size > 200) {
|
||||
size = 200;
|
||||
}
|
||||
return executionService.getExecutionsByOwner(userDetails.getUsername(), PageRequest.of(page, size))
|
||||
.map(ExecutionView::fromExecution);
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
|
|
@ -160,11 +178,10 @@ public class ExecutionsController {
|
|||
}
|
||||
|
||||
private List<ExecutionObject> visibleExecutions(LoginEntity userDetails) {
|
||||
List<ExecutionObject> allExecutions = executionService.getAllExecutions();
|
||||
if (userDetails == null) {
|
||||
return allExecutions;
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Authentication required");
|
||||
}
|
||||
return allExecutions.stream()
|
||||
return executionService.getAllExecutions().stream()
|
||||
.filter(execution -> userDetails.getUsername().equals(execution.getOwner()))
|
||||
.toList();
|
||||
}
|
||||
|
|
@ -335,10 +352,10 @@ public class ExecutionsController {
|
|||
}
|
||||
|
||||
private ExecutionObject visibleExecution(String id, LoginEntity userDetails) {
|
||||
ExecutionObject execution = executionService.getExecution(id);
|
||||
if (userDetails == null) {
|
||||
return execution;
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Authentication required");
|
||||
}
|
||||
ExecutionObject execution = executionService.getExecution(id);
|
||||
if (!userDetails.getUsername().equals(execution.getOwner())) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "Execution with id " + id + " is not accessible");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import java.util.Map;
|
|||
import java.util.UUID;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.function.Function;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
|
|
@ -253,6 +254,20 @@ public class ExecutionObject {
|
|||
return resumedStatus;
|
||||
}
|
||||
|
||||
public void shutdown() {
|
||||
if (this.executorService != null && !this.executorService.isShutdown()) {
|
||||
this.executorService.shutdown();
|
||||
try {
|
||||
if (!this.executorService.awaitTermination(5, TimeUnit.SECONDS)) {
|
||||
this.executorService.shutdownNow();
|
||||
}
|
||||
} catch (InterruptedException e) {
|
||||
this.executorService.shutdownNow();
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected void cancel() {
|
||||
if (this.executorService != null) {
|
||||
this.executorService.shutdownNow();
|
||||
|
|
|
|||
|
|
@ -3,6 +3,9 @@ package it.cnr.isti.workflow.manager.executions;
|
|||
import java.util.Collection;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.Map;
|
||||
import java.util.regex.Matcher;
|
||||
import java.util.regex.Pattern;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
import org.springframework.util.StringUtils;
|
||||
|
||||
|
|
@ -27,10 +30,14 @@ public final class ExecutionTemplateResolver {
|
|||
if (!StringUtils.hasText(template)) {
|
||||
return template;
|
||||
}
|
||||
String resolved = template;
|
||||
// Build full substitution map first, then apply in a single pass.
|
||||
// Single-pass prevents chaining injection (a value containing ${{...}}
|
||||
// cannot resolve further placeholders) and Matcher.quoteReplacement
|
||||
// guards against regex metacharacters inside replacement values.
|
||||
Map<String, String> substitutions = new LinkedHashMap<>();
|
||||
if (values != null) {
|
||||
for (Map.Entry<String, ?> entry : values.entrySet()) {
|
||||
resolved = resolved.replace("${{" + entry.getKey() + "}}", formatValue(entry.getValue()));
|
||||
substitutions.put("${{" + entry.getKey() + "}}", formatValue(entry.getValue()));
|
||||
}
|
||||
}
|
||||
if (executionVariables != null) {
|
||||
|
|
@ -38,18 +45,25 @@ public final class ExecutionTemplateResolver {
|
|||
if (entry.getKey() == null) {
|
||||
continue;
|
||||
}
|
||||
if (entry.getKey().startsWith(ExecutionRuntimeContextSupport.GLOBAL_PREFIX)) {
|
||||
resolved = resolved.replace("${{" + entry.getKey() + "}}", formatValue(entry.getValue()));
|
||||
continue;
|
||||
String placeholder;
|
||||
if (entry.getKey().startsWith(ExecutionRuntimeContextSupport.GLOBAL_PREFIX)
|
||||
|| entry.getKey().startsWith(ExecutionRuntimeContextSupport.CONTEXT_PREFIX)) {
|
||||
placeholder = "${{" + entry.getKey() + "}}";
|
||||
} else {
|
||||
placeholder = "${{vars." + entry.getKey() + "}}";
|
||||
}
|
||||
if (entry.getKey().startsWith(ExecutionRuntimeContextSupport.CONTEXT_PREFIX)) {
|
||||
resolved = resolved.replace("${{" + entry.getKey() + "}}", formatValue(entry.getValue()));
|
||||
continue;
|
||||
}
|
||||
resolved = resolved.replace("${{vars." + entry.getKey() + "}}", formatValue(entry.getValue()));
|
||||
substitutions.put(placeholder, formatValue(entry.getValue()));
|
||||
}
|
||||
}
|
||||
return resolved;
|
||||
if (substitutions.isEmpty()) {
|
||||
return template;
|
||||
}
|
||||
Pattern pattern = Pattern.compile(
|
||||
substitutions.keySet().stream()
|
||||
.map(Pattern::quote)
|
||||
.collect(Collectors.joining("|")));
|
||||
return pattern.matcher(template).replaceAll(
|
||||
match -> Matcher.quoteReplacement(substitutions.get(match.group())));
|
||||
}
|
||||
|
||||
public static String formatValue(Object value) {
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
package it.cnr.isti.workflow.manager.executions;
|
||||
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
|
@ -36,7 +38,7 @@ import it.cnr.isti.workflow.manager.mcp.MCPSharedSessionRegistry;
|
|||
@Service
|
||||
public class ExecutionsService {
|
||||
|
||||
private static Map<String, ExecutionObject> executions = new HashMap<>();
|
||||
private final Map<String, ExecutionObject> executions = new ConcurrentHashMap<>();
|
||||
|
||||
@Autowired
|
||||
FlowExecutionValidator flowExecutionValidator;
|
||||
|
|
@ -108,6 +110,11 @@ public class ExecutionsService {
|
|||
.toList();
|
||||
}
|
||||
|
||||
public Page<ExecutionObject> getExecutionsByOwner(String owner, Pageable pageable) {
|
||||
return executionRepository.findByOwner(owner, pageable)
|
||||
.map(entity -> executions.computeIfAbsent(entity.getId(), ignored -> rebuildExecution(entity)));
|
||||
}
|
||||
|
||||
public void removeExecution(String id) {
|
||||
ExecutionObject execution = executions.get(id);
|
||||
if (execution == null && executionRepository.existsById(id)) {
|
||||
|
|
@ -118,6 +125,7 @@ public class ExecutionsService {
|
|||
}
|
||||
if (execution.getContext().getStatus() == ExecutionStatus.RUNNING)
|
||||
throw new IllegalStateException("Execution with id " + id + " is still running");
|
||||
execution.shutdown();
|
||||
executions.remove(id);
|
||||
executionRepository.deleteById(id);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
package it.cnr.isti.workflow.manager.executions.repo;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
public interface ExecutionRepository extends JpaRepository<ExecutionEntity, String> {
|
||||
List<ExecutionEntity> findByOwner(String owner);
|
||||
Page<ExecutionEntity> findByOwner(String owner, Pageable pageable);
|
||||
void deleteByOwner(String owner);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package it.cnr.isti.workflow.manager.http;
|
||||
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Duration;
|
||||
import java.util.Base64;
|
||||
import java.util.Objects;
|
||||
|
||||
|
|
@ -51,6 +52,7 @@ public class HTTPServerCallService {
|
|||
|
||||
return requestSpec.retrieve()
|
||||
.bodyToMono(String.class)
|
||||
.timeout(Duration.ofSeconds(30))
|
||||
.block();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ spring.datasource.username=${DB_USER:lucio}
|
|||
spring.datasource.password=${DB_PASSWORD:password}
|
||||
spring.datasource.driver-class-name=org.postgresql.Driver
|
||||
spring.jpa.properties.hibernate.dialect=org.hibernate.dialect.PostgreSQLDialect
|
||||
spring.jpa.hibernate.ddl-auto=create-drop
|
||||
spring.jpa.hibernate.ddl-auto=${ddl-auto:update}
|
||||
|
||||
management.endpoints.web.exposure.include=health,info
|
||||
management.endpoint.health.show-details=always
|
||||
|
||||
# Keycloak
|
||||
#keycloak.realm=${REALM_NAME:wf-editor}
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ import org.springframework.security.core.context.SecurityContextHolder;
|
|||
import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
import org.springframework.test.web.servlet.MockMvc;
|
||||
import org.springframework.mock.web.MockHttpServletRequest;
|
||||
import org.springframework.mock.web.MockHttpServletResponse;
|
||||
|
||||
import it.cnr.isti.workflow.manager.auth.config.JwtUtil;
|
||||
|
|
@ -50,6 +51,12 @@ public class AuthControllerTest {
|
|||
return new MockHttpServletResponse();
|
||||
}
|
||||
|
||||
private static MockHttpServletRequest request() {
|
||||
MockHttpServletRequest req = new MockHttpServletRequest();
|
||||
req.setRemoteAddr("127.0.0.1");
|
||||
return req;
|
||||
}
|
||||
|
||||
@Autowired
|
||||
private AuthController authController;
|
||||
|
||||
|
|
@ -80,7 +87,7 @@ public class AuthControllerTest {
|
|||
public void testLogin() {
|
||||
ResponseEntity<?> loginResponse = authController.login(
|
||||
new AuthRequest("testuser", "testpassword", "testuser@example.com"),
|
||||
response());
|
||||
request(), response());
|
||||
assert loginResponse.getStatusCode().is2xxSuccessful();
|
||||
assert loginResponse.getBody() instanceof java.util.Map<?, ?>;
|
||||
assert "USER".equals(((java.util.Map<?, ?>) loginResponse.getBody()).get("role"));
|
||||
|
|
@ -92,7 +99,7 @@ public class AuthControllerTest {
|
|||
String suffix = uniqueSuffix();
|
||||
String username = "changepwd-" + suffix;
|
||||
String oldPassword = "Startpass1!";
|
||||
String newPassword = "Newpassword1!";
|
||||
String newPassword = "Freshcr3d!X";
|
||||
authController.register(new AuthRequest(username, oldPassword, username + "@example.com"), response());
|
||||
|
||||
ChangePasswordRequest request = new ChangePasswordRequest();
|
||||
|
|
@ -105,7 +112,7 @@ public class AuthControllerTest {
|
|||
|
||||
ResponseEntity<?> loginResponse = authController.login(
|
||||
new AuthRequest(username, newPassword, username + "@example.com"),
|
||||
response());
|
||||
request(), response());
|
||||
assert loginResponse.getStatusCode().is2xxSuccessful();
|
||||
}
|
||||
|
||||
|
|
@ -194,7 +201,7 @@ public class AuthControllerTest {
|
|||
|
||||
ResponseEntity<?> loginResponse = authController.login(
|
||||
new AuthRequest(managedUsername, "Changedpass1!", managedEmail),
|
||||
response());
|
||||
request(), response());
|
||||
assert loginResponse.getStatusCode().is2xxSuccessful();
|
||||
assert loginResponse.getBody() instanceof java.util.Map<?, ?>;
|
||||
assert "ADMIN".equals(((java.util.Map<?, ?>) loginResponse.getBody()).get("role"));
|
||||
|
|
@ -238,7 +245,7 @@ public class AuthControllerTest {
|
|||
|
||||
ResponseEntity<?> deletedLoginResponse = authController.login(
|
||||
new AuthRequest(managedUsername, "Changedpass1!", managedEmail),
|
||||
response());
|
||||
request(), response());
|
||||
assert deletedLoginResponse.getStatusCode() == HttpStatus.NOT_FOUND;
|
||||
|
||||
ResponseEntity<?> listAfterDelete = authController.listUsers();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,75 @@
|
|||
package it.cnr.isti.workflow.manager.executions;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.boot.test.context.SpringBootTest;
|
||||
import org.springframework.test.context.TestPropertySource;
|
||||
|
||||
import it.cnr.isti.workflow.manager.auth.model.CreateUserResult;
|
||||
import it.cnr.isti.workflow.manager.auth.services.AuthService;
|
||||
|
||||
@SpringBootTest
|
||||
@TestPropertySource(locations = "classpath:test.properties")
|
||||
public class AuthServicePasswordTest {
|
||||
|
||||
@Autowired
|
||||
private AuthService authService;
|
||||
|
||||
@Test
|
||||
public void rejectsShortPassword() {
|
||||
CreateUserResult result = authService.registerUser("pwtest1", "Ab1!xxxxx", "pwtest1@test.com");
|
||||
assertEquals(CreateUserResult.INVALID_PASSWORD, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsPasswordWithoutUppercase() {
|
||||
CreateUserResult result = authService.registerUser("pwtest2", "abcdefgh1!", "pwtest2@test.com");
|
||||
assertEquals(CreateUserResult.INVALID_PASSWORD, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsPasswordWithoutLowercase() {
|
||||
CreateUserResult result = authService.registerUser("pwtest3", "ABCDEFGH1!", "pwtest3@test.com");
|
||||
assertEquals(CreateUserResult.INVALID_PASSWORD, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsPasswordWithoutDigit() {
|
||||
CreateUserResult result = authService.registerUser("pwtest4", "Abcdefghij!", "pwtest4@test.com");
|
||||
assertEquals(CreateUserResult.INVALID_PASSWORD, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsPasswordWithoutSpecialChar() {
|
||||
CreateUserResult result = authService.registerUser("pwtest5", "Abcdefgh12", "pwtest5@test.com");
|
||||
assertEquals(CreateUserResult.INVALID_PASSWORD, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsCommonPassword() {
|
||||
CreateUserResult result = authService.registerUser("pwtest6", "Password1!", "pwtest6@test.com");
|
||||
assertEquals(CreateUserResult.INVALID_PASSWORD, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsPasswordContainingCommonWord() {
|
||||
CreateUserResult result = authService.registerUser("pwtest7", "myLetmein1!", "pwtest7@test.com");
|
||||
assertEquals(CreateUserResult.INVALID_PASSWORD, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void acceptsStrongPassword() {
|
||||
CreateUserResult result = authService.registerUser("pwtest8", "Str0ng!Uniq", "pwtest8@test.com");
|
||||
assertEquals(CreateUserResult.SUCCESS, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void rejectsPasswordWithWhitespace() {
|
||||
CreateUserResult result = authService.registerUser("pwtest9", "Str0ng! Unq", "pwtest9@test.com");
|
||||
assertEquals(CreateUserResult.INVALID_PASSWORD, result);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
package it.cnr.isti.workflow.manager.executions;
|
||||
|
||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
public class ExecutionTemplateResolverTest {
|
||||
|
||||
@Test
|
||||
public void resolvesSimplePlaceholder() {
|
||||
String result = ExecutionTemplateResolver.resolve(
|
||||
"Hello ${{name}}!",
|
||||
Map.of("name", "World"),
|
||||
null);
|
||||
assertEquals("Hello World!", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesMultiplePlaceholders() {
|
||||
String result = ExecutionTemplateResolver.resolve(
|
||||
"${{greeting}} ${{name}}!",
|
||||
Map.of("greeting", "Hi", "name", "Alice"),
|
||||
null);
|
||||
assertEquals("Hi Alice!", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesExecutionVariablesWithVarsPrefix() {
|
||||
Map<String, Object> execVars = new LinkedHashMap<>();
|
||||
execVars.put("color", "blue");
|
||||
String result = ExecutionTemplateResolver.resolve(
|
||||
"Color is ${{vars.color}}",
|
||||
Map.of(),
|
||||
execVars);
|
||||
assertEquals("Color is blue", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesContextVariables() {
|
||||
Map<String, Object> execVars = new LinkedHashMap<>();
|
||||
execVars.put("context.executionId", "abc-123");
|
||||
String result = ExecutionTemplateResolver.resolve(
|
||||
"Execution: ${{context.executionId}}",
|
||||
Map.of(),
|
||||
execVars);
|
||||
assertEquals("Execution: abc-123", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void resolvesGlobalVariables() {
|
||||
Map<String, Object> execVars = new LinkedHashMap<>();
|
||||
execVars.put("global.apiUrl", "https://example.com");
|
||||
String result = ExecutionTemplateResolver.resolve(
|
||||
"URL: ${{global.apiUrl}}",
|
||||
Map.of(),
|
||||
execVars);
|
||||
assertEquals("URL: https://example.com", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void nullTemplateReturnsNull() {
|
||||
String result = ExecutionTemplateResolver.resolve(
|
||||
null, Map.of(), null);
|
||||
assertEquals(null, result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void emptyTemplateReturnsEmpty() {
|
||||
String result = ExecutionTemplateResolver.resolve(
|
||||
"", Map.of(), null);
|
||||
assertEquals("", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void unresolvedPlaceholderRemainsIntact() {
|
||||
String result = ExecutionTemplateResolver.resolve(
|
||||
"Hello ${{unknown}}!",
|
||||
Map.of(),
|
||||
null);
|
||||
assertEquals("Hello ${{unknown}}!", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void valueContainingPlaceholderSyntaxIsNotReResolved() {
|
||||
// This is the template injection test:
|
||||
// A value that itself contains ${{...}} should NOT be resolved further.
|
||||
String result = ExecutionTemplateResolver.resolve(
|
||||
"Cmd: ${{cmd}}",
|
||||
Map.of("cmd", "${{secret}}"),
|
||||
Map.of("secret", "LEAKED"));
|
||||
// The value "${{secret}}" should appear literally, NOT "LEAKED"
|
||||
assertEquals("Cmd: ${{secret}}", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void valueWithRegexMetacharactersIsSafe() {
|
||||
String result = ExecutionTemplateResolver.resolve(
|
||||
"Pattern: ${{pat}}",
|
||||
Map.of("pat", "$1 \\n (group)"),
|
||||
null);
|
||||
assertEquals("Pattern: $1 \\n (group)", result);
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatValueHandlesCollection() {
|
||||
String result = ExecutionTemplateResolver.formatValue(List.of("a", "b", "c"));
|
||||
assertNotNull(result);
|
||||
assertTrue(result.contains("a"));
|
||||
assertTrue(result.contains("b"));
|
||||
assertTrue(result.contains("c"));
|
||||
}
|
||||
|
||||
@Test
|
||||
public void formatValueHandlesNull() {
|
||||
assertEquals("null", ExecutionTemplateResolver.formatValue(null));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue