From 89c83ec7b8fc7ab0f30b5da79029162963f31a3c Mon Sep 17 00:00:00 2001 From: hsiegeln <37154749+hsiegeln@users.noreply.github.com> Date: Mon, 27 Apr 2026 08:48:09 +0200 Subject: [PATCH] feat: expand MfaEnforcementFilter for vendor policy and passkey checks Co-Authored-By: Claude Sonnet 4.6 --- .../saas/config/MfaEnforcementFilter.java | 97 ++++++++++++++++--- 1 file changed, 83 insertions(+), 14 deletions(-) diff --git a/src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java b/src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java index 079df18..7e62c69 100644 --- a/src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java +++ b/src/main/java/net/siegeln/cameleer/saas/config/MfaEnforcementFilter.java @@ -6,6 +6,7 @@ import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import net.siegeln.cameleer.saas.tenant.TenantService; +import net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.http.MediaType; @@ -23,26 +24,34 @@ import java.util.Set; 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" + "/api/onboarding", + "/api/vendor/auth-policy", + "/api/tenant/auth-settings" ); private final TenantService tenantService; + private final VendorAuthPolicyRepository vendorPolicyRepo; private final ObjectMapper objectMapper; - public MfaEnforcementFilter(TenantService tenantService, ObjectMapper objectMapper) { + public MfaEnforcementFilter(TenantService tenantService, + VendorAuthPolicyRepository vendorPolicyRepo, + ObjectMapper objectMapper) { this.tenantService = tenantService; + this.vendorPolicyRepo = vendorPolicyRepo; this.objectMapper = objectMapper; } @Override protected boolean shouldNotFilter(HttpServletRequest request) { String path = request.getServletPath(); - if (!path.startsWith("/api/tenant/")) return true; + boolean isProtected = path.startsWith("/api/tenant/") + || path.startsWith("/api/vendor/") + || path.startsWith("/api/portal/"); + if (!isProtected) return true; return EXEMPT_PREFIXES.stream().anyMatch(path::startsWith); } @@ -57,15 +66,46 @@ public class MfaEnforcementFilter extends OncePerRequestFilter { } Jwt jwt = jwtAuth.getToken(); - Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled"); + String path = request.getServletPath(); - if (Boolean.TRUE.equals(mfaEnrolled)) { + if (path.startsWith("/api/vendor/") || path.startsWith("/api/portal/")) { + enforceVendorPolicy(jwt, request, response, filterChain); + } else if (path.startsWith("/api/tenant/")) { + enforceTenantPolicy(jwt, request, response, filterChain); + } else { filterChain.doFilter(request, response); + } + } + + private void enforceVendorPolicy(Jwt jwt, HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + var policy = vendorPolicyRepo.getPolicy(); + Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled"); + Boolean passkeyEnrolled = jwt.getClaim("passkey_enrolled"); + + if ("required".equals(policy.getMfaMode()) && !Boolean.TRUE.equals(mfaEnrolled)) { + log.info("MFA enforcement (vendor): blocking user {} — vendor policy requires MFA", jwt.getSubject()); + writeError(response, "APP_MFA_REQUIRED", "mfa_enrollment_required", + "Platform authentication policy requires multi-factor authentication"); return; } - // TenantIsolationInterceptor runs after filters, so TenantContext is not populated yet. - // Resolve the tenant directly from the JWT organization_id claim. + if (policy.isPasskeyEnabled() && "required".equals(policy.getPasskeyMode()) + && !Boolean.TRUE.equals(passkeyEnrolled)) { + log.info("Passkey enforcement (vendor): blocking user {} — vendor policy requires passkey", jwt.getSubject()); + writeError(response, "APP_PASSKEY_REQUIRED", "passkey_enrollment_required", + "Platform authentication policy requires a passkey"); + return; + } + + filterChain.doFilter(request, response); + } + + private void enforceTenantPolicy(Jwt jwt, HttpServletRequest request, HttpServletResponse response, + FilterChain filterChain) throws ServletException, IOException { + Boolean mfaEnrolled = jwt.getClaim("mfa_enrolled"); + Boolean passkeyEnrolled = jwt.getClaim("passkey_enrolled"); + String orgId = jwt.getClaimAsString("organization_id"); if (orgId == null) { filterChain.doFilter(request, response); @@ -73,19 +113,48 @@ public class MfaEnforcementFilter extends OncePerRequestFilter { } var tenant = tenantService.getByLogtoOrgId(orgId).orElse(null); - if (tenant == null || !Boolean.TRUE.equals(tenant.getSettings().get("mfaRequired"))) { + if (tenant == null) { filterChain.doFilter(request, response); return; } - log.info("MFA enforcement: blocking user {} — tenant {} requires MFA", jwt.getSubject(), tenant.getSlug()); + Map settings = tenant.getSettings() != null ? tenant.getSettings() : Map.of(); + + String mfaMode = settings.containsKey("mfaMode") + ? String.valueOf(settings.get("mfaMode")) + : (Boolean.TRUE.equals(settings.get("mfaRequired")) ? "required" : "off"); + + if ("required".equals(mfaMode) && !Boolean.TRUE.equals(mfaEnrolled)) { + log.info("MFA enforcement: blocking user {} — tenant {} requires MFA", jwt.getSubject(), tenant.getSlug()); + writeError(response, "APP_MFA_REQUIRED", "mfa_enrollment_required", + "Your organization requires multi-factor authentication"); + return; + } + + boolean passkeyEnabled = Boolean.TRUE.equals(settings.get("passkeyEnabled")); + String passkeyMode = settings.containsKey("passkeyMode") + ? String.valueOf(settings.get("passkeyMode")) + : "optional"; + + if (passkeyEnabled && "required".equals(passkeyMode) && !Boolean.TRUE.equals(passkeyEnrolled)) { + log.info("Passkey enforcement: blocking user {} — tenant {} requires passkey", jwt.getSubject(), tenant.getSlug()); + writeError(response, "APP_PASSKEY_REQUIRED", "passkey_enrollment_required", + "Your organization requires a passkey"); + return; + } + + filterChain.doFilter(request, response); + } + + private void writeError(HttpServletResponse response, String errorCode, String code, String message) + throws IOException { response.setStatus(HttpServletResponse.SC_FORBIDDEN); response.setContentType(MediaType.APPLICATION_JSON_VALUE); - response.setHeader("X-Cameleer-Error", ERROR_CODE); + response.setHeader("X-Cameleer-Error", errorCode); objectMapper.writeValue(response.getOutputStream(), Map.of( - "error", ERROR_CODE, - "code", "mfa_enrollment_required", - "message", "Your organization requires multi-factor authentication" + "error", errorCode, + "code", code, + "message", message )); } }