fix: always offer MFA+passkey enrollment, separate availability from enforcement
Two fundamental fixes: - user.missing_mfa now triggers MfaEnrollmentError (enroll UI) instead of MfaRequiredError (verify UI). Users without MFA were shown a TOTP code prompt they couldn't fill. - Logto MFA factors always set to [Totp, WebAuthn, BackupCode] with UserControlled policy on startup. Availability is always-on for all users. The vendor auth policy controls enforcement (via MfaEnforcementFilter), not what Logto offers during sign-in. - Removed syncMfaConfigToLogto from VendorAuthPolicyController — vendor policy changes no longer modify Logto's sign-in experience. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,20 +1,19 @@
|
|||||||
package net.siegeln.cameleer.saas.config;
|
package net.siegeln.cameleer.saas.config;
|
||||||
|
|
||||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||||
import net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository;
|
|
||||||
import org.slf4j.Logger;
|
import org.slf4j.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||||
import org.springframework.context.event.EventListener;
|
import org.springframework.context.event.EventListener;
|
||||||
import org.springframework.stereotype.Component;
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Syncs Logto configuration on startup: ensures MFA factors
|
* Ensures Logto sign-in experience always offers TOTP + WebAuthn + BackupCode
|
||||||
* (including WebAuthn) match the vendor policy.
|
* on startup. Availability is always-on; enforcement is handled separately by
|
||||||
|
* MfaEnforcementFilter based on the vendor auth policy.
|
||||||
*/
|
*/
|
||||||
@Component
|
@Component
|
||||||
public class LogtoStartupConfig {
|
public class LogtoStartupConfig {
|
||||||
@@ -22,40 +21,18 @@ public class LogtoStartupConfig {
|
|||||||
private static final Logger log = LoggerFactory.getLogger(LogtoStartupConfig.class);
|
private static final Logger log = LoggerFactory.getLogger(LogtoStartupConfig.class);
|
||||||
|
|
||||||
private final LogtoManagementClient logtoClient;
|
private final LogtoManagementClient logtoClient;
|
||||||
private final VendorAuthPolicyRepository authPolicyRepository;
|
|
||||||
|
|
||||||
public LogtoStartupConfig(LogtoManagementClient logtoClient,
|
public LogtoStartupConfig(LogtoManagementClient logtoClient) {
|
||||||
VendorAuthPolicyRepository authPolicyRepository) {
|
|
||||||
this.logtoClient = logtoClient;
|
this.logtoClient = logtoClient;
|
||||||
this.authPolicyRepository = authPolicyRepository;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@EventListener(ApplicationReadyEvent.class)
|
@EventListener(ApplicationReadyEvent.class)
|
||||||
public void onStartup() {
|
public void onStartup() {
|
||||||
syncMfaFactors();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void syncMfaFactors() {
|
|
||||||
try {
|
try {
|
||||||
var policy = authPolicyRepository.getPolicy();
|
List<String> factors = List.of("Totp", "WebAuthn", "BackupCode");
|
||||||
String mfaMode = policy.getMfaMode();
|
|
||||||
boolean mfaEnabled = !"off".equals(mfaMode);
|
|
||||||
boolean passkeyEnabled = policy.isPasskeyEnabled();
|
|
||||||
|
|
||||||
List<String> factors = new ArrayList<>();
|
|
||||||
if (mfaEnabled) {
|
|
||||||
factors.add("Totp");
|
|
||||||
}
|
|
||||||
if (mfaEnabled || passkeyEnabled) {
|
|
||||||
factors.add("WebAuthn");
|
|
||||||
factors.add("BackupCode");
|
|
||||||
}
|
|
||||||
|
|
||||||
String logtoPolicy = "required".equals(mfaMode) ? "Mandatory" : "UserControlled";
|
|
||||||
|
|
||||||
logtoClient.updateSignInExperience(Map.of(
|
logtoClient.updateSignInExperience(Map.of(
|
||||||
"mfa", Map.of("factors", factors, "policy", logtoPolicy)));
|
"mfa", Map.of("factors", factors, "policy", "UserControlled")));
|
||||||
log.info("Synced MFA factors to Logto: {} (policy={})", factors, logtoPolicy);
|
log.info("Logto MFA factors set to {} (UserControlled)", factors);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to sync MFA factors on startup: {}", e.getMessage());
|
log.warn("Failed to sync MFA factors on startup: {}", e.getMessage());
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
package net.siegeln.cameleer.saas.vendor;
|
package net.siegeln.cameleer.saas.vendor;
|
||||||
|
|
||||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
|
||||||
import org.slf4j.Logger;
|
|
||||||
import org.slf4j.LoggerFactory;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.security.access.prepost.PreAuthorize;
|
import org.springframework.security.access.prepost.PreAuthorize;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.Map;
|
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@@ -17,17 +11,13 @@ import java.util.Set;
|
|||||||
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
|
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
|
||||||
public class VendorAuthPolicyController {
|
public class VendorAuthPolicyController {
|
||||||
|
|
||||||
private static final Logger log = LoggerFactory.getLogger(VendorAuthPolicyController.class);
|
|
||||||
private static final Set<String> VALID_MFA_MODES = Set.of("off", "optional", "required");
|
private static final Set<String> VALID_MFA_MODES = Set.of("off", "optional", "required");
|
||||||
private static final Set<String> VALID_PASSKEY_MODES = Set.of("optional", "preferred", "required");
|
private static final Set<String> VALID_PASSKEY_MODES = Set.of("optional", "preferred", "required");
|
||||||
|
|
||||||
private final VendorAuthPolicyRepository repository;
|
private final VendorAuthPolicyRepository repository;
|
||||||
private final LogtoManagementClient logtoClient;
|
|
||||||
|
|
||||||
public VendorAuthPolicyController(VendorAuthPolicyRepository repository,
|
public VendorAuthPolicyController(VendorAuthPolicyRepository repository) {
|
||||||
LogtoManagementClient logtoClient) {
|
|
||||||
this.repository = repository;
|
this.repository = repository;
|
||||||
this.logtoClient = logtoClient;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record AuthPolicyResponse(String mfaMode, boolean passkeyEnabled, String passkeyMode) {
|
public record AuthPolicyResponse(String mfaMode, boolean passkeyEnabled, String passkeyMode) {
|
||||||
@@ -64,33 +54,6 @@ public class VendorAuthPolicyController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
repository.save(policy);
|
repository.save(policy);
|
||||||
syncMfaConfigToLogto(policy);
|
|
||||||
return ResponseEntity.ok(AuthPolicyResponse.from(policy));
|
return ResponseEntity.ok(AuthPolicyResponse.from(policy));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Sync the vendor auth policy MFA settings to the Logto sign-in experience. */
|
|
||||||
private void syncMfaConfigToLogto(VendorAuthPolicyEntity policy) {
|
|
||||||
try {
|
|
||||||
String mfaMode = policy.getMfaMode();
|
|
||||||
boolean mfaEnabled = !"off".equals(mfaMode);
|
|
||||||
boolean passkeyEnabled = policy.isPasskeyEnabled();
|
|
||||||
|
|
||||||
List<String> factors = new ArrayList<>();
|
|
||||||
if (mfaEnabled) {
|
|
||||||
factors.add("Totp");
|
|
||||||
}
|
|
||||||
// Passkeys are always available when enabled, regardless of MFA mode
|
|
||||||
if (mfaEnabled || passkeyEnabled) {
|
|
||||||
factors.add("WebAuthn");
|
|
||||||
factors.add("BackupCode");
|
|
||||||
}
|
|
||||||
|
|
||||||
String logtoPolicy = "required".equals(mfaMode) ? "Mandatory" : "UserControlled";
|
|
||||||
|
|
||||||
logtoClient.updateSignInExperience(Map.of(
|
|
||||||
"mfa", Map.of("factors", factors, "policy", logtoPolicy)));
|
|
||||||
} catch (Exception e) {
|
|
||||||
log.warn("Failed to sync MFA config to Logto: {}", e.getMessage());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -99,13 +99,13 @@ export async function signIn(identifier: string, password: string): Promise<stri
|
|||||||
const result = await trySubmit();
|
const result = await trySubmit();
|
||||||
if (result.ok) return result.redirectTo;
|
if (result.ok) return result.redirectTo;
|
||||||
|
|
||||||
// MFA already enrolled — user must verify (show TOTP input)
|
// MFA already enrolled — user must verify (show TOTP/passkey input)
|
||||||
if (result.code === 'user.missing_mfa' || result.code === 'session.mfa.require_mfa_verification') {
|
if (result.code === 'session.mfa.require_mfa_verification') {
|
||||||
throw new MfaRequiredError();
|
throw new MfaRequiredError();
|
||||||
}
|
}
|
||||||
|
|
||||||
// MFA not enrolled — offer enrollment (passkey / TOTP)
|
// MFA not enrolled — offer enrollment (passkey / TOTP)
|
||||||
if (result.status === 422 && result.code.includes('mfa')) {
|
if (result.code === 'user.missing_mfa' || (result.status === 422 && result.code.includes('mfa'))) {
|
||||||
throw new MfaEnrollmentError();
|
throw new MfaEnrollmentError();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user