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. */
|
/** Update user custom data (partial merge). Used for mfa_method_preference. */
|
||||||
public void updateUserCustomData(String userId, Map<String, Object> customData) {
|
public void updateUserCustomData(String userId, Map<String, Object> customData) {
|
||||||
if (!isAvailable()) return;
|
if (!isAvailable()) return;
|
||||||
|
|||||||
@@ -1,9 +1,15 @@
|
|||||||
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
|
||||||
@@ -11,13 +17,17 @@ 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) {
|
||||||
@@ -54,6 +64,30 @@ 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);
|
||||||
|
|
||||||
|
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());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
61
ui/src/api/logto-account-api.ts
Normal file
61
ui/src/api/logto-account-api.ts
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { startRegistration } from '@simplewebauthn/browser';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Logto Account API client for WebAuthn passkey registration.
|
||||||
|
* Calls Logto's Account API endpoints directly (same domain).
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function accountApi(
|
||||||
|
method: string,
|
||||||
|
path: string,
|
||||||
|
token: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<Response> {
|
||||||
|
return fetch(`/api${path}`, {
|
||||||
|
method,
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
...(body ? { 'Content-Type': 'application/json' } : {}),
|
||||||
|
},
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function registerPasskey(getAccountToken: () => Promise<string>): Promise<void> {
|
||||||
|
const token = await getAccountToken();
|
||||||
|
|
||||||
|
// Step 1: Get registration options from Logto Account API
|
||||||
|
const optionsRes = await accountApi('POST', '/verifications/web-authn/registration', token);
|
||||||
|
if (!optionsRes.ok) {
|
||||||
|
const err = await optionsRes.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to start passkey registration (${optionsRes.status})`);
|
||||||
|
}
|
||||||
|
const { registrationOptions, verificationRecordId } = await optionsRes.json();
|
||||||
|
|
||||||
|
// Step 2: Browser WebAuthn ceremony
|
||||||
|
const credential = await startRegistration({ optionsJSON: registrationOptions });
|
||||||
|
|
||||||
|
// Step 3: Verify the registration with Logto
|
||||||
|
const verifyRes = await accountApi(
|
||||||
|
'POST',
|
||||||
|
'/verifications/web-authn/registration/verify',
|
||||||
|
token,
|
||||||
|
{ verificationRecordId, payload: credential },
|
||||||
|
);
|
||||||
|
if (!verifyRes.ok) {
|
||||||
|
const err = await verifyRes.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Passkey verification failed (${verifyRes.status})`);
|
||||||
|
}
|
||||||
|
const verifyData = await verifyRes.json();
|
||||||
|
const verifiedRecordId = verifyData.verificationRecordId;
|
||||||
|
|
||||||
|
// Step 4: Bind the passkey to the user's account
|
||||||
|
const bindRes = await accountApi('POST', '/my-account/mfa-verifications', token, {
|
||||||
|
type: 'WebAuthn',
|
||||||
|
newIdentifierVerificationRecordId: verifiedRecordId,
|
||||||
|
});
|
||||||
|
if (!bindRes.ok) {
|
||||||
|
const err = await bindRes.json().catch(() => ({}));
|
||||||
|
throw new Error(err.message || `Failed to bind passkey (${bindRes.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useLogto } from '@logto/react';
|
||||||
import { errorMessage } from '../../api/client';
|
import { errorMessage } from '../../api/client';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
@@ -13,6 +14,7 @@ import {
|
|||||||
useAccountRenamePasskey,
|
useAccountRenamePasskey,
|
||||||
useAccountDeletePasskey,
|
useAccountDeletePasskey,
|
||||||
} from '../../api/account-hooks';
|
} from '../../api/account-hooks';
|
||||||
|
import { registerPasskey } from '../../api/logto-account-api';
|
||||||
import styles from '../../styles/platform.module.css';
|
import styles from '../../styles/platform.module.css';
|
||||||
|
|
||||||
export function PasskeyNudgeBanner() {
|
export function PasskeyNudgeBanner() {
|
||||||
@@ -41,12 +43,14 @@ export function PasskeyNudgeBanner() {
|
|||||||
|
|
||||||
export function PasskeySection() {
|
export function PasskeySection() {
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const { data: passkeys, isLoading } = useAccountPasskeyList();
|
const { getAccessToken } = useLogto();
|
||||||
|
const { data: passkeys, isLoading, refetch } = useAccountPasskeyList();
|
||||||
const renamePasskey = useAccountRenamePasskey();
|
const renamePasskey = useAccountRenamePasskey();
|
||||||
const deletePasskey = useAccountDeletePasskey();
|
const deletePasskey = useAccountDeletePasskey();
|
||||||
const [editingId, setEditingId] = useState<string | null>(null);
|
const [editingId, setEditingId] = useState<string | null>(null);
|
||||||
const [editName, setEditName] = useState('');
|
const [editName, setEditName] = useState('');
|
||||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||||
|
const [registering, setRegistering] = useState(false);
|
||||||
|
|
||||||
function parseAgent(agent: string | null): string {
|
function parseAgent(agent: string | null): string {
|
||||||
if (!agent) return 'Unknown device';
|
if (!agent) return 'Unknown device';
|
||||||
@@ -72,6 +76,25 @@ export function PasskeySection() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleRegister() {
|
||||||
|
setRegistering(true);
|
||||||
|
try {
|
||||||
|
await registerPasskey(async () => {
|
||||||
|
const token = await getAccessToken();
|
||||||
|
if (!token) throw new Error('Not authenticated');
|
||||||
|
return token;
|
||||||
|
});
|
||||||
|
await refetch();
|
||||||
|
toast({ title: 'Passkey registered', variant: 'success' });
|
||||||
|
} catch (err) {
|
||||||
|
// User cancelled the WebAuthn prompt — not an error
|
||||||
|
if (err instanceof Error && err.name === 'NotAllowedError') return;
|
||||||
|
toast({ title: 'Passkey registration failed', description: errorMessage(err), variant: 'error' });
|
||||||
|
} finally {
|
||||||
|
setRegistering(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function handleDelete(id: string) {
|
async function handleDelete(id: string) {
|
||||||
try {
|
try {
|
||||||
await deletePasskey.mutateAsync(id);
|
await deletePasskey.mutateAsync(id);
|
||||||
@@ -90,9 +113,14 @@ export function PasskeySection() {
|
|||||||
<p className={styles.description} style={{ marginTop: 0 }}>
|
<p className={styles.description} style={{ marginTop: 0 }}>
|
||||||
Use your fingerprint, face, or security key to sign in faster.
|
Use your fingerprint, face, or security key to sign in faster.
|
||||||
</p>
|
</p>
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Button variant="primary" onClick={handleRegister} loading={registering}>
|
||||||
|
Register passkey
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
{credentials.length === 0 ? (
|
{credentials.length === 0 ? (
|
||||||
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
<p style={{ color: 'var(--text-muted)', fontSize: '0.875rem' }}>
|
||||||
No passkeys registered. Passkeys can be registered during sign-in when prompted.
|
No passkeys registered yet.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||||
|
|||||||
Reference in New Issue
Block a user