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:
Allison
2026-04-28 00:27:09 -04:00
parent aa269c5bc0
commit b8fa321edd
9 changed files with 1580 additions and 7 deletions

View File

@@ -2477,6 +2477,9 @@
.text-brand-navy {
color: #060d1a;
}
.text-brand-navy\/50 {
color: color-mix(in oklab, #060d1a 50%, transparent);
}
.text-brand-navy\/60 {
color: color-mix(in oklab, #060d1a 60%, transparent);
}
@@ -3628,6 +3631,13 @@
}
}
}
.hover\:text-red-900 {
&:hover {
@media (hover: hover) {
color: var(--color-red-900);
}
}
}
.hover\:text-white {
&:hover {
@media (hover: hover) {
@@ -3790,6 +3800,11 @@
outline-color: #0062ff;
}
}
.focus-visible\:outline-red-700 {
&:focus-visible {
outline-color: var(--color-red-700);
}
}
.active\:scale-95 {
&:active {
--tw-scale-x: 95%;

View 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);