feat: always enable WebAuthn in MFA factors and add passkey registration
- Sync vendor auth policy to Logto sign-in experience on save and on startup. Always include WebAuthn + TOTP + BackupCode in MFA factors when MFA is enabled — no reason to gate passkeys behind a toggle. - Enable Logto Account Center on startup for user-facing MFA management. - Add passkey registration to account settings via Logto Account API. Frontend calls Logto directly (same domain) for the WebAuthn ceremony: generate options, browser credential creation, verify, and bind. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,61 @@
|
||||
package net.siegeln.cameleer.saas.config;
|
||||
|
||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||
import net.siegeln.cameleer.saas.vendor.VendorAuthPolicyRepository;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.boot.context.event.ApplicationReadyEvent;
|
||||
import org.springframework.context.event.EventListener;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Syncs Logto configuration on startup: enables the Account Center
|
||||
* and ensures MFA factors (including WebAuthn) match the vendor policy.
|
||||
*/
|
||||
@Component
|
||||
public class LogtoStartupConfig {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(LogtoStartupConfig.class);
|
||||
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final VendorAuthPolicyRepository authPolicyRepository;
|
||||
|
||||
public LogtoStartupConfig(LogtoManagementClient logtoClient,
|
||||
VendorAuthPolicyRepository authPolicyRepository) {
|
||||
this.logtoClient = logtoClient;
|
||||
this.authPolicyRepository = authPolicyRepository;
|
||||
}
|
||||
|
||||
@EventListener(ApplicationReadyEvent.class)
|
||||
public void onStartup() {
|
||||
logtoClient.enableAccountCenter();
|
||||
syncMfaFactors();
|
||||
}
|
||||
|
||||
private void syncMfaFactors() {
|
||||
try {
|
||||
var policy = authPolicyRepository.getPolicy();
|
||||
String mfaMode = policy.getMfaMode();
|
||||
boolean mfaEnabled = !"off".equals(mfaMode);
|
||||
|
||||
if (!mfaEnabled) {
|
||||
logtoClient.updateSignInExperience(Map.of(
|
||||
"mfa", Map.of("factors", List.of(), "policy", "UserControlled")));
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> factors = new ArrayList<>(List.of("Totp", "WebAuthn", "BackupCode"));
|
||||
String logtoPolicy = "required".equals(mfaMode) ? "Mandatory" : "UserControlled";
|
||||
|
||||
logtoClient.updateSignInExperience(Map.of(
|
||||
"mfa", Map.of("factors", factors, "policy", logtoPolicy)));
|
||||
log.info("Synced MFA factors to Logto: {} (policy={})", factors, logtoPolicy);
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to sync MFA factors on startup: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -653,6 +653,28 @@ public class LogtoManagementClient {
|
||||
}
|
||||
}
|
||||
|
||||
/** Enable the Account Center API for MFA management (WebAuthn passkey registration). */
|
||||
public void enableAccountCenter() {
|
||||
if (!isAvailable()) return;
|
||||
try {
|
||||
restClient.patch()
|
||||
.uri(config.getLogtoEndpoint() + "/api/account-center")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of(
|
||||
"enabled", true,
|
||||
"fields", Map.of(
|
||||
"mfa", Map.of("edit", "ReadonlyProfile")
|
||||
)
|
||||
))
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
log.info("Account Center enabled for MFA management");
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to enable Account Center: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
/** Update user custom data (partial merge). Used for mfa_method_preference. */
|
||||
public void updateUserCustomData(String userId, Map<String, Object> customData) {
|
||||
if (!isAvailable()) return;
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
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.security.access.prepost.PreAuthorize;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
|
||||
@RestController
|
||||
@@ -11,13 +17,17 @@ import java.util.Set;
|
||||
@PreAuthorize("hasAuthority('SCOPE_platform:admin')")
|
||||
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_PASSKEY_MODES = Set.of("optional", "preferred", "required");
|
||||
|
||||
private final VendorAuthPolicyRepository repository;
|
||||
private final LogtoManagementClient logtoClient;
|
||||
|
||||
public VendorAuthPolicyController(VendorAuthPolicyRepository repository) {
|
||||
public VendorAuthPolicyController(VendorAuthPolicyRepository repository,
|
||||
LogtoManagementClient logtoClient) {
|
||||
this.repository = repository;
|
||||
this.logtoClient = logtoClient;
|
||||
}
|
||||
|
||||
public record AuthPolicyResponse(String mfaMode, boolean passkeyEnabled, String passkeyMode) {
|
||||
@@ -54,6 +64,30 @@ public class VendorAuthPolicyController {
|
||||
}
|
||||
|
||||
repository.save(policy);
|
||||
syncMfaConfigToLogto(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);
|
||||
|
||||
if (!mfaEnabled) {
|
||||
logtoClient.updateSignInExperience(Map.of(
|
||||
"mfa", Map.of("factors", List.of(), "policy", "UserControlled")));
|
||||
return;
|
||||
}
|
||||
|
||||
List<String> factors = new ArrayList<>(List.of("Totp", "WebAuthn", "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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user