feat(mfa): #64 WebAuthn complet — enregistrement/clé/verify login + gestion clés dans Sécurité (backend+frontend+i18n+tests)
This commit is contained in:
+268
-2
@@ -179,6 +179,57 @@ const AuthManager = {
|
||||
return data.user;
|
||||
},
|
||||
|
||||
// ── WebAuthn (ROADMAP #64) ─────────────────────────────────────────
|
||||
|
||||
async webauthnLoginOptions(username) {
|
||||
const resp = await fetch("/api/auth/mfa/webauthn/options", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username }),
|
||||
});
|
||||
if (!resp.ok) throw new Error((await resp.json()).detail || "WebAuthn indisponible");
|
||||
return await resp.json();
|
||||
},
|
||||
|
||||
async verifyWebauthn(username, credential, rememberMe) {
|
||||
const response = await fetch("/api/auth/mfa/webauthn/verify", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: "include",
|
||||
body: JSON.stringify({ username, credential, remember_me: rememberMe || false }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
const err = await response.json();
|
||||
throw new Error(err.detail || "Vérification WebAuthn échouée");
|
||||
}
|
||||
const data = await response.json();
|
||||
this.saveToken(data);
|
||||
return data.user;
|
||||
},
|
||||
|
||||
async webauthnRegisterOptions() {
|
||||
return await api("/api/auth/mfa/webauthn/register/options", { method: "POST" });
|
||||
},
|
||||
|
||||
async webauthnRegister(credential, label) {
|
||||
return await api("/api/auth/mfa/webauthn/register", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ credential, label }),
|
||||
});
|
||||
},
|
||||
|
||||
async webauthnCredentials() {
|
||||
return await api("/api/auth/mfa/webauthn/credentials");
|
||||
},
|
||||
|
||||
async webauthnRemove(credentialId, password) {
|
||||
return await api("/api/auth/mfa/webauthn/credentials/remove", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ credential_id: credentialId, password }),
|
||||
});
|
||||
},
|
||||
|
||||
// ── MFA Setup API calls ──────────────────────────────────────────
|
||||
|
||||
async getMfaStatus() {
|
||||
@@ -376,7 +427,112 @@ async function _onLoginSuccess() {
|
||||
// MFA Challenge UI (TOTP code input during login)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function showMfaChallenge(username, rememberMe, loginBtn, loginErrorEl) {
|
||||
// base64url <-> ArrayBuffer helpers for WebAuthn (ROADMAP #64)
|
||||
function _b64urlToBuf(s) {
|
||||
const pad = "=".repeat((4 - (s.length % 4)) % 4);
|
||||
const b = (s + pad).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(b);
|
||||
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
function _bufToB64url(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let s = "";
|
||||
for (const c of bytes) s += String.fromCharCode(c);
|
||||
return btoa(s).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
|
||||
}
|
||||
|
||||
async function runWebauthnCeremony(optionsJson) {
|
||||
const pkOptions = {
|
||||
challenge: _b64urlToBuf(optionsJson.challenge),
|
||||
timeout: optionsJson.timeout || 60000,
|
||||
rpId: optionsJson.rpId,
|
||||
userVerification: optionsJson.userVerification || "preferred",
|
||||
allowCredentials: (optionsJson.allowCredentials || []).map((c) => ({
|
||||
type: c.type || "public-key",
|
||||
id: _b64urlToBuf(c.id),
|
||||
transports: c.transports,
|
||||
})),
|
||||
};
|
||||
const cred = await navigator.credentials.get({ publicKey: pkOptions });
|
||||
if (!cred) throw new Error(t("mfa.webauthn_cancelled"));
|
||||
return {
|
||||
id: cred.id,
|
||||
rawId: _bufToB64url(cred.rawId),
|
||||
type: cred.type,
|
||||
response: {
|
||||
clientDataJSON: _bufToB64url(cred.response.clientDataJSON),
|
||||
authenticatorData: _bufToB64url(cred.response.authenticatorData),
|
||||
signature: _bufToB64url(cred.response.signature),
|
||||
userHandle: cred.response.userHandle ? _bufToB64url(cred.response.userHandle) : "",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function runWebauthnRegistration() {
|
||||
const resp = await AuthManager.webauthnRegisterOptions();
|
||||
const o = resp.options;
|
||||
const pkOptions = {
|
||||
rp: { id: o.rpId || o.rp.id, name: o.rp.name },
|
||||
challenge: _b64urlToBuf(o.challenge),
|
||||
user: {
|
||||
id: _b64urlToBuf(o.user.id),
|
||||
name: o.user.name,
|
||||
displayName: o.user.displayName,
|
||||
},
|
||||
pubKeyCredParams: o.pubKeyCredParams,
|
||||
timeout: o.timeout || 60000,
|
||||
authenticatorSelection: o.authenticatorSelection || undefined,
|
||||
attestation: "none",
|
||||
};
|
||||
const cred = await navigator.credentials.create({ publicKey: pkOptions });
|
||||
if (!cred) throw new Error(t("mfa.webauthn_cancelled"));
|
||||
const credential = {
|
||||
id: cred.id,
|
||||
rawId: _bufToB64url(cred.rawId),
|
||||
type: cred.type,
|
||||
response: {
|
||||
clientDataJSON: _bufToB64url(cred.response.clientDataJSON),
|
||||
attestationObject: _bufToB64url(cred.response.attestationObject),
|
||||
},
|
||||
};
|
||||
if (cred.response.getTransports) {
|
||||
credential.response.transports = Array.from(cred.response.getTransports());
|
||||
}
|
||||
return credential;
|
||||
}
|
||||
|
||||
function _startWebauthnLogin(mfaSection, username, rememberMe) {
|
||||
const btn = mfaSection.querySelector("#mfa-webauthn-btn");
|
||||
const errorEl = mfaSection.querySelector("#mfa-error");
|
||||
if (btn) btn.disabled = true;
|
||||
if (errorEl) errorEl.classList.add("hidden");
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const resp = await AuthManager.webauthnLoginOptions(username);
|
||||
if (resp.mfa_method !== "webauthn" || !resp.options) {
|
||||
throw new Error(t("mfa.webauthn_no_key"));
|
||||
}
|
||||
const credential = await runWebauthnCeremony(resp.options);
|
||||
await AuthManager.verifyWebauthn(username, credential, rememberMe);
|
||||
mfaSection.classList.add("hidden");
|
||||
const loginForm = document.getElementById("login-form");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
await _onLoginSuccess();
|
||||
} catch (err) {
|
||||
if (errorEl) {
|
||||
errorEl.textContent = err.message || String(err);
|
||||
errorEl.classList.remove("hidden");
|
||||
}
|
||||
} finally {
|
||||
if (btn) btn.disabled = false;
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
|
||||
function showMfaChallenge(username, rememberMe, loginBtn, loginErrorEl, mfaMethod) {
|
||||
const loginBox = document.querySelector(".login-box");
|
||||
if (!loginBox) return;
|
||||
|
||||
@@ -393,6 +549,37 @@ function showMfaChallenge(username, rememberMe, loginBtn, loginErrorEl) {
|
||||
loginBox.appendChild(mfaSection);
|
||||
}
|
||||
|
||||
// WebAuthn second factor: key prompt instead of TOTP code input
|
||||
if (mfaMethod === "webauthn") {
|
||||
mfaSection.innerHTML = `
|
||||
<div class="mfa-icon">🔑</div>
|
||||
<h3>${t('mfa.title')}</h3>
|
||||
<p class="mfa-subtitle">${t('mfa.webauthn_prompt')}</p>
|
||||
<p class="mfa-error hidden" id="mfa-error"></p>
|
||||
<button type="button" class="btn-login" id="mfa-webauthn-btn">
|
||||
<span class="btn-text">${t('mfa.webauthn_btn')}</span>
|
||||
</button>
|
||||
<div class="mfa-actions">
|
||||
<button type="button" class="mfa-link-btn" id="mfa-use-recovery">${t('mfa.use_recovery')}</button>
|
||||
<button type="button" class="mfa-link-btn" id="mfa-back-login">${t('mfa.back_to_login')}</button>
|
||||
</div>
|
||||
`;
|
||||
mfaSection.classList.remove("hidden");
|
||||
document.getElementById("mfa-webauthn-btn").addEventListener("click", () => {
|
||||
_startWebauthnLogin(mfaSection, username, rememberMe);
|
||||
});
|
||||
document.getElementById("mfa-use-recovery").addEventListener("click", () => {
|
||||
showRecoveryChallenge(username, rememberMe, loginForm, mfaSection);
|
||||
});
|
||||
document.getElementById("mfa-back-login").addEventListener("click", () => {
|
||||
mfaSection.classList.add("hidden");
|
||||
if (loginForm) loginForm.classList.remove("hidden");
|
||||
});
|
||||
// Auto-start the ceremony — the browser shows its own dialog
|
||||
_startWebauthnLogin(mfaSection, username, rememberMe);
|
||||
return;
|
||||
}
|
||||
|
||||
mfaSection.innerHTML = `
|
||||
<div class="mfa-icon">🔐</div>
|
||||
<h3>${t('mfa.title')}</h3>
|
||||
@@ -565,7 +752,7 @@ function initLoginForm() {
|
||||
const result = await AuthManager.login(username, password, rememberMe);
|
||||
// Check if MFA is required
|
||||
if (result && result.mfa_required) {
|
||||
showMfaChallenge(result.username, rememberMe, btn, errorEl);
|
||||
showMfaChallenge(result.username, rememberMe, btn, errorEl, result.mfa_method);
|
||||
return;
|
||||
}
|
||||
// Normal login success
|
||||
@@ -858,6 +1045,85 @@ async function initMfaSettings() {
|
||||
_startMfaSetup();
|
||||
});
|
||||
}
|
||||
|
||||
// WebAuthn security keys section (ROADMAP #64)
|
||||
_renderWebauthnSection(area);
|
||||
}
|
||||
|
||||
|
||||
async function _renderWebauthnSection(container) {
|
||||
if (!container || !window.PublicKeyCredential) return;
|
||||
|
||||
let keys = [];
|
||||
try {
|
||||
const resp = await AuthManager.webauthnCredentials();
|
||||
keys = resp.credentials || [];
|
||||
} catch (e) {
|
||||
return; // auth disabled or endpoint unreachable — hide section
|
||||
}
|
||||
|
||||
let section = document.getElementById("webauthn-settings");
|
||||
if (!section) {
|
||||
section = document.createElement("div");
|
||||
section.id = "webauthn-settings";
|
||||
section.className = "webauthn-settings";
|
||||
container.appendChild(section);
|
||||
}
|
||||
|
||||
const listHtml = keys.length
|
||||
? `<ul class="webauthn-key-list">${keys.map((k) => `
|
||||
<li class="webauthn-key-item">
|
||||
<span class="webauthn-key-label">🔑 ${k.label || "Security key"}</span>
|
||||
<span class="webauthn-key-meta">${(k.transports || []).join(", ") || "—"}</span>
|
||||
<button class="config-btn-secondary config-btn-sm webauthn-key-remove"
|
||||
data-id="${k.credential_id}">${t("mfa.webauthn_remove")}</button>
|
||||
</li>`).join("")}</ul>`
|
||||
: `<p class="mfa-info-text">${t("mfa.webauthn_none")}</p>`;
|
||||
|
||||
section.innerHTML = `
|
||||
<h4 class="webauthn-title">${t("mfa.webauthn_title")}</h4>
|
||||
<p class="mfa-info-text">${t("mfa.webauthn_desc")}</p>
|
||||
${listHtml}
|
||||
<div class="mfa-recovery-actions">
|
||||
<button class="config-btn-primary" id="webauthn-add-btn">${t("mfa.webauthn_add")}</button>
|
||||
</div>
|
||||
<p class="mfa-error hidden" id="webauthn-error"></p>
|
||||
<div id="webauthn-flow-area"></div>
|
||||
`;
|
||||
|
||||
const errEl = section.querySelector("#webauthn-error");
|
||||
document.getElementById("webauthn-add-btn").addEventListener("click", async () => {
|
||||
errEl.classList.add("hidden");
|
||||
try {
|
||||
const credential = await runWebauthnRegistration();
|
||||
const label = prompt(t("mfa.webauthn_label_prompt"), "Ma clé");
|
||||
const result = await AuthManager.webauthnRegister(credential, label || "Security key");
|
||||
if (result.recovery_codes && result.recovery_codes.length) {
|
||||
_showRecoveryCodes(result.recovery_codes);
|
||||
} else {
|
||||
showToast(t("mfa.webauthn_added"), "success");
|
||||
}
|
||||
initMfaSettings();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || String(err);
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
|
||||
section.querySelectorAll(".webauthn-key-remove").forEach((btn) => {
|
||||
btn.addEventListener("click", async () => {
|
||||
const password = prompt(t("mfa.webauthn_remove_confirm"));
|
||||
if (password === null) return;
|
||||
try {
|
||||
await AuthManager.webauthnRemove(btn.dataset.id, password);
|
||||
showToast(t("mfa.webauthn_removed"), "success");
|
||||
initMfaSettings();
|
||||
} catch (err) {
|
||||
errEl.textContent = err.message || String(err);
|
||||
errEl.classList.remove("hidden");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user