feat(auth): B-2.6 WebAuthn / Passkey support (FIDO2 + biometric 2FA)
Adds phishing-resistant 2nd factor via FIDO2 hardware keys (YubiKey etc.) and device biometrics (Touch ID, Windows Hello, etc.). Reuses the existing B-2.5 TOTP gate so a passkey is a 3rd valid option on /2fa/verify, alongside TOTP code and recovery code. Post-login enrolment lives at /2fa/passkey/setup. Wraps python-webauthn==2.5.2 in a thin service layer (src/auth/webauthn.py) that persists credentials in the existing User.webauthn_credentials JSON column (added in B-2.1 — no schema change). Each credential dict carries id, public_key, sign_count, transports, name, and created_at. sign_count is updated after every successful authentication for WebAuthn anti-cloning (§6.1.1). Backend: 6 new auth routes (passkey_setup, register/begin, register/finish, delete, auth/begin, auth/finish). The 4 JSON endpoints are CSRF-exempt at Flask-WTF level because CSRFProtect cannot read tokens from a JSON body without app-wide config; the X-CSRFToken header is still sent as defence-in-depth. The form-POST delete route DOES enforce CSRF. The @csrf_exempt decorator was previously a no-op label; init_auth_extensions now walks module-level functions and applies real csrf.exempt() to any flagged with _csrf_exempt=True. Login gate now fires when the user has TOTP enabled OR at least one passkey, and totp_verify_login passes has_passkeys + has_totp flags so the template can show only the relevant sections. Frontend: templates/auth/totp_verify.html updated IN PLACE with a passkey button section (above TOTP) and an "ou" divider. New templates/auth/passkey_setup.html for managing/enrolling passkeys. New static/js/webauthn-client.js (no external deps, ES2020) wraps navigator.credentials and exchanges base64url payloads with the backend. Tailwind CSS rebuilt. Tests: 22 new tests in tests/test_webauthn_passkey.py covering the service layer (b64url helpers, RP config, list/has, begin/finish for both registration and authentication, delete) and the route flow (CSRF-exempt JSON endpoints, login gate redirection, sign_count anti-cloning persistence). Mocks python-webauthn's verify_* functions so tests run without a real authenticator. Windows manual driver follows the existing no-conftest pattern. Self-review: 22/22 new tests pass; 21/21 prior TOTP, 16/16 email, 21/21 OAuth tests still pass (no regression). Env: config/env.oauth.example documents WEBAUTHN_RP_ID, WEBAUTHN_RP_NAME, WEBAUTHN_ORIGIN with full deployment notes. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
178
static/js/webauthn-client.js
Normal file
178
static/js/webauthn-client.js
Normal file
@@ -0,0 +1,178 @@
|
||||
/* DictIA WebAuthn client (B-2.6).
|
||||
* No external dependencies. Wraps the navigator.credentials API and
|
||||
* exchanges base64url-encoded payloads with the Flask backend at
|
||||
* /2fa/passkey/* endpoints.
|
||||
*
|
||||
* Exports window.DictIAWebAuthn = { wireRegisterButton, wireAuthButton }.
|
||||
*/
|
||||
(function (global) {
|
||||
'use strict';
|
||||
|
||||
// --- base64url helpers (no padding) -----------------------------------
|
||||
|
||||
function b64urlToBuffer(s) {
|
||||
if (!s) return new ArrayBuffer(0);
|
||||
const pad = '='.repeat((4 - (s.length % 4)) % 4);
|
||||
const b64 = (s + pad).replace(/-/g, '+').replace(/_/g, '/');
|
||||
const binary = atob(b64);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
function bufferToB64url(buf) {
|
||||
const bytes = new Uint8Array(buf);
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.byteLength; i++) binary += String.fromCharCode(bytes[i]);
|
||||
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
||||
}
|
||||
|
||||
// --- Options decoding (server sends b64url; navigator.credentials needs ArrayBuffer)
|
||||
|
||||
function decodeRegistrationOptions(o) {
|
||||
return Object.assign({}, o, {
|
||||
challenge: b64urlToBuffer(o.challenge),
|
||||
user: Object.assign({}, o.user, { id: b64urlToBuffer(o.user.id) }),
|
||||
excludeCredentials: (o.excludeCredentials || []).map(function (c) {
|
||||
return Object.assign({}, c, { id: b64urlToBuffer(c.id) });
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
function decodeAuthenticationOptions(o) {
|
||||
return Object.assign({}, o, {
|
||||
challenge: b64urlToBuffer(o.challenge),
|
||||
allowCredentials: (o.allowCredentials || []).map(function (c) {
|
||||
return Object.assign({}, c, { id: b64urlToBuffer(c.id) });
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
// --- Credential encoding (ArrayBuffer fields → b64url for JSON) -------
|
||||
|
||||
function encodeRegistrationCredential(cred) {
|
||||
return {
|
||||
id: cred.id,
|
||||
rawId: bufferToB64url(cred.rawId),
|
||||
type: cred.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToB64url(cred.response.clientDataJSON),
|
||||
attestationObject: bufferToB64url(cred.response.attestationObject),
|
||||
transports: typeof cred.response.getTransports === 'function'
|
||||
? cred.response.getTransports() : [],
|
||||
},
|
||||
clientExtensionResults: cred.getClientExtensionResults
|
||||
? cred.getClientExtensionResults() : {},
|
||||
};
|
||||
}
|
||||
|
||||
function encodeAuthenticationAssertion(cred) {
|
||||
return {
|
||||
id: cred.id,
|
||||
rawId: bufferToB64url(cred.rawId),
|
||||
type: cred.type,
|
||||
response: {
|
||||
clientDataJSON: bufferToB64url(cred.response.clientDataJSON),
|
||||
authenticatorData: bufferToB64url(cred.response.authenticatorData),
|
||||
signature: bufferToB64url(cred.response.signature),
|
||||
userHandle: cred.response.userHandle
|
||||
? bufferToB64url(cred.response.userHandle) : null,
|
||||
},
|
||||
clientExtensionResults: cred.getClientExtensionResults
|
||||
? cred.getClientExtensionResults() : {},
|
||||
};
|
||||
}
|
||||
|
||||
// --- HTTP helper ------------------------------------------------------
|
||||
|
||||
async function postJson(url, body, csrfToken) {
|
||||
const headers = { 'Content-Type': 'application/json' };
|
||||
if (csrfToken) headers['X-CSRFToken'] = csrfToken;
|
||||
const r = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: headers,
|
||||
body: JSON.stringify(body || {}),
|
||||
credentials: 'same-origin',
|
||||
});
|
||||
let data = null;
|
||||
try { data = await r.json(); } catch (_) {}
|
||||
return { ok: r.ok, status: r.status, data: data };
|
||||
}
|
||||
|
||||
// --- Public API: wire enrolment button --------------------------------
|
||||
|
||||
function wireRegisterButton(cfg) {
|
||||
const btn = document.getElementById(cfg.buttonId);
|
||||
const labelEl = cfg.labelInputId ? document.getElementById(cfg.labelInputId) : null;
|
||||
const statusEl = cfg.statusElementId ? document.getElementById(cfg.statusElementId) : null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', async function () {
|
||||
if (statusEl) statusEl.textContent = 'Préparation...';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
if (!('credentials' in navigator) || !navigator.credentials.create) {
|
||||
throw new Error('Votre navigateur ne supporte pas les passkeys.');
|
||||
}
|
||||
const beginRes = await postJson(cfg.beginUrl, {}, cfg.csrfToken);
|
||||
if (!beginRes.ok) {
|
||||
throw new Error((beginRes.data && beginRes.data.message) || 'Erreur de préparation');
|
||||
}
|
||||
const opts = decodeRegistrationOptions(beginRes.data);
|
||||
if (statusEl) statusEl.textContent = 'Suivez les instructions de votre authentificateur...';
|
||||
const cred = await navigator.credentials.create({ publicKey: opts });
|
||||
const encoded = encodeRegistrationCredential(cred);
|
||||
const finishRes = await postJson(
|
||||
cfg.finishUrl,
|
||||
{ response: encoded, label: labelEl ? labelEl.value : '' },
|
||||
cfg.csrfToken
|
||||
);
|
||||
if (!finishRes.ok) {
|
||||
throw new Error((finishRes.data && finishRes.data.message) || 'Échec de la vérification');
|
||||
}
|
||||
if (statusEl) statusEl.textContent = 'Passkey enregistrée. Rafraîchissement...';
|
||||
setTimeout(function () { window.location.reload(); }, 800);
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = 'Erreur : ' + (e.message || e);
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Public API: wire login button ------------------------------------
|
||||
|
||||
function wireAuthButton(cfg) {
|
||||
const btn = document.getElementById(cfg.buttonId);
|
||||
const statusEl = cfg.statusElementId ? document.getElementById(cfg.statusElementId) : null;
|
||||
if (!btn) return;
|
||||
btn.addEventListener('click', async function () {
|
||||
if (statusEl) statusEl.textContent = 'Préparation...';
|
||||
btn.disabled = true;
|
||||
try {
|
||||
if (!('credentials' in navigator) || !navigator.credentials.get) {
|
||||
throw new Error('Votre navigateur ne supporte pas les passkeys.');
|
||||
}
|
||||
const beginRes = await postJson(cfg.beginUrl, {}, cfg.csrfToken);
|
||||
if (!beginRes.ok) {
|
||||
throw new Error((beginRes.data && beginRes.data.message) || 'Erreur de préparation');
|
||||
}
|
||||
const opts = decodeAuthenticationOptions(beginRes.data);
|
||||
if (statusEl) statusEl.textContent = 'Confirmez avec votre authentificateur...';
|
||||
const cred = await navigator.credentials.get({ publicKey: opts });
|
||||
const encoded = encodeAuthenticationAssertion(cred);
|
||||
const finishRes = await postJson(cfg.finishUrl, { response: encoded }, cfg.csrfToken);
|
||||
if (!finishRes.ok) {
|
||||
throw new Error((finishRes.data && finishRes.data.message) || 'Échec de la vérification');
|
||||
}
|
||||
window.location.assign((finishRes.data && finishRes.data.redirect) || '/');
|
||||
} catch (e) {
|
||||
if (statusEl) statusEl.textContent = 'Erreur : ' + (e.message || e);
|
||||
btn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
global.DictIAWebAuthn = {
|
||||
wireRegisterButton: wireRegisterButton,
|
||||
wireAuthButton: wireAuthButton,
|
||||
};
|
||||
})(window);
|
||||
Reference in New Issue
Block a user