feat: add MFA enforcement filter with APP_MFA_REQUIRED error code

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-26 13:56:25 +02:00
parent 8b8909e488
commit ffb65edcec
2 changed files with 95 additions and 2 deletions

View File

@@ -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<String> 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"
));
}
}

View File

@@ -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();
}