From ffb65edcec9c6a8438613efca72906f8f7163a04 Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Sun, 26 Apr 2026 13:56:25 +0200 Subject: [PATCH] feat: add MFA enforcement filter with APP_MFA_REQUIRED error code Co-Authored-By: Claude Sonnet 4.6 --- .../saas/config/MfaEnforcementFilter.java | 91 +++++++++++++++++++ .../cameleer/saas/config/SecurityConfig.java | 6 +- 2 files changed, 95 insertions(+), 2 deletions(-) create mode 100644 src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java diff --git a/src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java b/src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java new file mode 100644 index 0000000..079df18 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java @@ -0,0 +1,91 @@ +package net.siegeln.cameleer.saas.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import net.siegeln.cameleer.saas.tenant.TenantService; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.MediaType; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Map; +import java.util.Set; + +@Component +public class MfaEnforcementFilter extends OncePerRequestFilter { + + private static final Logger log = LoggerFactory.getLogger(MfaEnforcementFilter.class); + private static final String ERROR_CODE = "APP_MFA_REQUIRED"; + private static final Set EXEMPT_PREFIXES = Set.of( + "/api/tenant/mfa/", + "/api/config", + "/api/me", + "/api/onboarding" + ); + + private final TenantService tenantService; + private final ObjectMapper objectMapper; + + public MfaEnforcementFilter(TenantService tenantService, ObjectMapper objectMapper) { + this.tenantService = tenantService; + this.objectMapper = objectMapper; + } + + @Override + protected boolean shouldNotFilter(HttpServletRequest request) { + String path = request.getServletPath(); + if (!path.startsWith("/api/tenant/")) return true; + return EXEMPT_PREFIXES.stream().anyMatch(path::startsWith); + } + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + + var auth = SecurityContextHolder.getContext().getAuthentication(); + if (!(auth instanceof JwtAuthenticationToken jwtAuth)) { + filterChain.doFilter(request, response); + return; + } + + Jwt jwt = jwtAuth.getToken(); + Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled"); + + if (Boolean.TRUE.equals(mfaEnrolled)) { + filterChain.doFilter(request, response); + return; + } + + // TenantIsolationInterceptor runs after filters, so TenantContext is not populated yet. + // Resolve the tenant directly from the JWT organization_id claim. + String orgId = jwt.getClaimAsString("organization_id"); + if (orgId == null) { + filterChain.doFilter(request, response); + return; + } + + var tenant = tenantService.getByLogtoOrgId(orgId).orElse(null); + if (tenant == null || !Boolean.TRUE.equals(tenant.getSettings().get("mfaRequired"))) { + filterChain.doFilter(request, response); + return; + } + + log.info("MFA enforcement: blocking user {} — tenant {} requires MFA", jwt.getSubject(), tenant.getSlug()); + response.setStatus(HttpServletResponse.SC_FORBIDDEN); + response.setContentType(MediaType.APPLICATION_JSON_VALUE); + response.setHeader("X-Cameleer-Error", ERROR_CODE); + objectMapper.writeValue(response.getOutputStream(), Map.of( + "error", ERROR_CODE, + "code", "mfa_enrollment_required", + "message", "Your organization requires multi-factor authentication" + )); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java index d5f16ac..10a67ef 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java @@ -24,6 +24,7 @@ import org.springframework.security.oauth2.jwt.JwtIssuerValidator; import org.springframework.security.oauth2.jwt.JwtTimestampValidator; import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.web.BearerTokenAuthenticationFilter; import org.springframework.security.web.SecurityFilterChain; import java.net.URL; @@ -36,7 +37,7 @@ import java.util.List; public class SecurityConfig { @Bean - public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + public SecurityFilterChain filterChain(HttpSecurity http, MfaEnforcementFilter mfaEnforcementFilter) throws Exception { http .csrf(csrf -> csrf.disable()) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) @@ -53,7 +54,8 @@ public class SecurityConfig { .anyRequest().authenticated() ) .oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt -> - jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))); + jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))) + .addFilterAfter(mfaEnforcementFilter, BearerTokenAuthenticationFilter.class); return http.build(); }