feat(mfa): #64 WebAuthn complet — enregistrement/clé/verify login + gestion clés dans Sécurité (backend+frontend+i18n+tests)
This commit is contained in:
+198
-5
@@ -5,7 +5,7 @@
|
||||
import logging
|
||||
import re
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request, Response
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from backend.ratelimit import is_rate_limited
|
||||
@@ -147,12 +147,13 @@ async def login(body: LoginRequest, response: Response, request: Request):
|
||||
# Success — clear rate limits
|
||||
rl_record_success(client_ip)
|
||||
|
||||
# If MFA is enabled, don't issue token yet — require TOTP verification
|
||||
if user.get("mfa_enabled") and user.get("mfa_secret"):
|
||||
logger.info(f"User '{body.username}' login deferred — MFA required")
|
||||
# If MFA is enabled, don't issue token yet — require second factor
|
||||
if user.get("mfa_enabled"):
|
||||
method = "totp" if user.get("mfa_secret") else _preferred_mfa_method(user)
|
||||
logger.info(f"User '{body.username}' login deferred — MFA required ({method})")
|
||||
return {
|
||||
"mfa_required": True,
|
||||
"mfa_method": "totp",
|
||||
"mfa_method": method,
|
||||
"username": body.username,
|
||||
"remember_me": body.remember_me,
|
||||
}
|
||||
@@ -434,6 +435,196 @@ async def mfa_totp_disable(
|
||||
return {"mfa_enabled": False}
|
||||
|
||||
|
||||
# ── WebAuthn endpoints (ROADMAP #64) ─────────────────────────────────
|
||||
|
||||
def _preferred_mfa_method(user: dict) -> str:
|
||||
"""Which second factor to offer at login: webauthn when keys exist, else totp."""
|
||||
if user.get("webauthn_credentials"):
|
||||
return "webauthn"
|
||||
return "totp"
|
||||
|
||||
|
||||
class WebauthnRegisterRequest(BaseModel):
|
||||
credential: dict
|
||||
label: str = ""
|
||||
|
||||
|
||||
class WebauthnVerifyRequest(BaseModel):
|
||||
username: str
|
||||
credential: dict
|
||||
remember_me: bool = False
|
||||
|
||||
|
||||
class WebauthnRemoveRequest(BaseModel):
|
||||
credential_id: str
|
||||
password: str
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/register/options")
|
||||
async def mfa_webauthn_register_options(current_user=Depends(require_auth)):
|
||||
"""Start WebAuthn key enrolment — returns publicKey creation options for the browser."""
|
||||
from .webauthn_mfa import begin_registration
|
||||
|
||||
options = begin_registration(current_user["username"],
|
||||
current_user.get("display_name", ""))
|
||||
return {"options": options}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/register")
|
||||
async def mfa_webauthn_register(
|
||||
req: WebauthnRegisterRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Verify the created credential, store it, and enable MFA if not already on.
|
||||
|
||||
Returns recovery codes when MFA is newly enabled (they were never issued).
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import complete_registration
|
||||
|
||||
user = get_user(current_user["username"])
|
||||
try:
|
||||
record = complete_registration(current_user["username"], req.credential,
|
||||
label=req.label)
|
||||
except ValueError as e:
|
||||
raise HTTPException(400, str(e))
|
||||
except Exception as e:
|
||||
logger.warning(f"WebAuthn registration failed for {current_user['username']}: {e}")
|
||||
raise HTTPException(400, "Validation du credential WebAuthn échouée")
|
||||
|
||||
record["registered_at"] = datetime.now(timezone.utc).isoformat()
|
||||
creds = list(user.get("webauthn_credentials", []))
|
||||
creds = [c for c in creds if c.get("credential_id") != record["credential_id"]]
|
||||
creds.append(record)
|
||||
|
||||
updates: dict = {"webauthn_credentials": creds}
|
||||
issued_recovery: list[str] = []
|
||||
if not user.get("mfa_enabled"):
|
||||
issued_recovery = generate_recovery_codes()
|
||||
updates.update({
|
||||
"mfa_enabled": True,
|
||||
"mfa_method": "webauthn",
|
||||
"mfa_recovery_codes": [hash_recovery_code(c) for c in issued_recovery],
|
||||
})
|
||||
update_user(current_user["username"], updates)
|
||||
|
||||
logger.info(f"WebAuthn credential registered for user '{current_user['username']}' "
|
||||
f"({record['label']})")
|
||||
return {
|
||||
"ok": True,
|
||||
"credentials": user_credentials_response(creds),
|
||||
"mfa_enabled": True,
|
||||
"recovery_codes": issued_recovery,
|
||||
}
|
||||
|
||||
|
||||
def user_credentials_response(creds: list[dict]) -> list[dict]:
|
||||
from .webauthn_mfa import credentials_for_api
|
||||
return credentials_for_api(creds)
|
||||
|
||||
|
||||
@router.get("/mfa/webauthn/credentials")
|
||||
async def mfa_webauthn_list(current_user=Depends(require_auth)):
|
||||
from .user_store import get_user
|
||||
user = get_user(current_user["username"])
|
||||
return {"credentials": user_credentials_response(user.get("webauthn_credentials", []))}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/credentials/remove")
|
||||
async def mfa_webauthn_remove(
|
||||
req: WebauthnRemoveRequest,
|
||||
current_user=Depends(require_auth),
|
||||
):
|
||||
"""Remove a WebAuthn key. Requires password. Disables MFA if no second factor remains."""
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import clear_pending
|
||||
|
||||
user = get_user(current_user["username"])
|
||||
if not verify_password(req.password, user["password_hash"]):
|
||||
raise HTTPException(400, "Mot de passe incorrect")
|
||||
|
||||
creds = [c for c in user.get("webauthn_credentials", [])
|
||||
if c.get("credential_id") != req.credential_id]
|
||||
if len(creds) == len(user.get("webauthn_credentials", [])):
|
||||
raise HTTPException(404, "Credential inconnu")
|
||||
|
||||
updates: dict = {"webauthn_credentials": creds}
|
||||
if not creds and not user.get("mfa_secret"):
|
||||
updates.update({"mfa_enabled": False, "mfa_method": None, "mfa_recovery_codes": []})
|
||||
elif not creds and user.get("mfa_secret"):
|
||||
updates["mfa_method"] = "totp"
|
||||
update_user(current_user["username"], updates)
|
||||
clear_pending(current_user["username"])
|
||||
return {"ok": True, "credentials": user_credentials_response(creds),
|
||||
"mfa_enabled": bool(updates.get("mfa_enabled", user.get("mfa_enabled"))) and bool(creds or user.get("mfa_secret"))}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/options")
|
||||
async def mfa_webauthn_login_options(body: dict = Body(...)):
|
||||
"""Unauthenticated: begin the login assertion for a user with registered keys.
|
||||
|
||||
Returns null options (mfa_method 'totp') when the user has no WebAuthn keys.
|
||||
"""
|
||||
username = str(body.get("username", ""))
|
||||
user = get_user(username)
|
||||
if not user or not user.get("mfa_enabled"):
|
||||
raise HTTPException(400, "MFA non activé pour cet utilisateur")
|
||||
|
||||
creds = user.get("webauthn_credentials", [])
|
||||
if not creds:
|
||||
return {"mfa_method": "totp", "options": None}
|
||||
|
||||
from .webauthn_mfa import begin_authentication
|
||||
options = begin_authentication(username, creds)
|
||||
if options is None:
|
||||
return {"mfa_method": "totp", "options": None}
|
||||
return {"mfa_method": "webauthn", "options": options}
|
||||
|
||||
|
||||
@router.post("/mfa/webauthn/verify")
|
||||
async def mfa_webauthn_verify(
|
||||
body: WebauthnVerifyRequest,
|
||||
response: Response,
|
||||
request: Request,
|
||||
):
|
||||
"""Unauthenticated: verify the WebAuthn assertion and issue JWT tokens."""
|
||||
from .user_store import get_user, update_user
|
||||
from .webauthn_mfa import complete_authentication
|
||||
|
||||
user = get_user(body.username)
|
||||
if not user:
|
||||
hash_password("dummy_timing_protection")
|
||||
raise HTTPException(401, "Identifiants invalides")
|
||||
if not user.get("mfa_enabled"):
|
||||
raise HTTPException(400, "MFA non activé pour cet utilisateur")
|
||||
|
||||
creds = user.get("webauthn_credentials", [])
|
||||
try:
|
||||
credential_id = body.credential.get("id", "")
|
||||
stored = next((c for c in creds if c.get("credential_id") == credential_id), None)
|
||||
if stored is None:
|
||||
raise ValueError("Credential non enregistré")
|
||||
new_count = complete_authentication(body.username, body.credential, stored)
|
||||
except ValueError as e:
|
||||
raise HTTPException(401, str(e))
|
||||
except Exception as e:
|
||||
logger.warning(f"WebAuthn verification failed for {body.username}: {e}")
|
||||
raise HTTPException(401, "Vérification WebAuthn échouée")
|
||||
|
||||
updated = [dict(c) for c in creds]
|
||||
for c in updated:
|
||||
if c.get("credential_id") == body.credential.get("id"):
|
||||
c["sign_count"] = new_count
|
||||
update_user(body.username, {"webauthn_credentials": updated})
|
||||
|
||||
client_ip = request.client.host if request.client else "unknown"
|
||||
rl_record_success(client_ip)
|
||||
logger.info(f"User '{body.username}' logged in via WebAuthn")
|
||||
return _issue_tokens(user, body.username, body.remember_me, response)
|
||||
|
||||
|
||||
@router.get("/mfa/status")
|
||||
async def mfa_status(current_user=Depends(require_auth)):
|
||||
"""Return current user's MFA status."""
|
||||
@@ -442,6 +633,8 @@ async def mfa_status(current_user=Depends(require_auth)):
|
||||
return {
|
||||
"mfa_enabled": user.get("mfa_enabled", False),
|
||||
"mfa_method": user.get("mfa_method"),
|
||||
"totp_enabled": bool(user.get("mfa_secret")),
|
||||
"webauthn_credentials": len(user.get("webauthn_credentials", [])),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
# backend/auth/webauthn_mfa.py
|
||||
# WebAuthn support for MFA (ROADMAP #64): security keys / platform biometrics.
|
||||
# Thin wrapper over the `webauthn` library with an in-memory challenge store.
|
||||
#
|
||||
# Credentials are persisted in users.json under "webauthn_credentials":
|
||||
# [{ "credential_id": <b64url>, "public_key": <b64url>, "sign_count": int,
|
||||
# "transports": [...], "label": str, "registered_at": iso }]
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from webauthn import (
|
||||
generate_authentication_options,
|
||||
generate_registration_options,
|
||||
options_to_json,
|
||||
verify_authentication_response,
|
||||
verify_registration_response,
|
||||
)
|
||||
from webauthn.helpers import (
|
||||
base64url_to_bytes,
|
||||
bytes_to_base64url,
|
||||
parse_authentication_credential_json,
|
||||
parse_registration_credential_json,
|
||||
)
|
||||
from webauthn.helpers.structs import (
|
||||
AuthenticatorSelectionCriteria,
|
||||
ResidentKeyRequirement,
|
||||
UserVerificationRequirement,
|
||||
)
|
||||
|
||||
logger = logging.getLogger("obsigate.auth.webauthn")
|
||||
|
||||
# Challenge lifetime: clients have 3 minutes to complete the ceremony.
|
||||
CHALLENGE_TTL_SECONDS = 180
|
||||
|
||||
# In-memory pending challenges: key -> (challenge_bytes, expires_at)
|
||||
_pending: dict[str, tuple[bytes, float]] = {}
|
||||
|
||||
|
||||
def rp_id() -> str:
|
||||
return os.environ.get("OBSIGATE_WEBAUTHN_RP_ID", "localhost")
|
||||
|
||||
|
||||
def rp_name() -> str:
|
||||
return os.environ.get("OBSIGATE_WEBAUTHN_RP_NAME", "ObsiGate")
|
||||
|
||||
|
||||
def expected_origins() -> list[str]:
|
||||
raw = os.environ.get("OBSIGATE_WEBAUTHN_ORIGINS", "http://localhost")
|
||||
return [o.strip() for o in raw.split(",") if o.strip()]
|
||||
|
||||
|
||||
def _prune_expired() -> None:
|
||||
now = time.time()
|
||||
for key in [k for k, (_, exp) in _pending.items() if exp < now]:
|
||||
_pending.pop(key, None)
|
||||
|
||||
|
||||
def _store_challenge(key: str) -> bytes:
|
||||
_prune_expired()
|
||||
challenge = secrets.token_bytes(32)
|
||||
_pending[key] = (challenge, time.time() + CHALLENGE_TTL_SECONDS)
|
||||
return challenge
|
||||
|
||||
|
||||
def _take_challenge(key: str) -> bytes | None:
|
||||
"""Pop a challenge (single-use). Returns None if missing/expired."""
|
||||
_prune_expired()
|
||||
entry = _pending.pop(key, None)
|
||||
return entry[0] if entry else None
|
||||
|
||||
|
||||
def clear_pending(username: str) -> None:
|
||||
"""Drop all pending challenges for a user (e.g. after enable/disable)."""
|
||||
for key in [k for k in _pending if k.startswith(f"{username}:")]:
|
||||
_pending.pop(key, None)
|
||||
|
||||
|
||||
# ── Registration (enrol a key in settings) ─────────────────────────────
|
||||
|
||||
def begin_registration(username: str, display_name: str) -> dict:
|
||||
options = generate_registration_options(
|
||||
rp_id=rp_id(),
|
||||
rp_name=rp_name(),
|
||||
user_name=username,
|
||||
user_display_name=display_name or username,
|
||||
challenge=_store_challenge(f"{username}:register"),
|
||||
authenticator_selection=AuthenticatorSelectionCriteria(
|
||||
resident_key=ResidentKeyRequirement.PREFERRED,
|
||||
user_verification=UserVerificationRequirement.PREFERRED,
|
||||
),
|
||||
)
|
||||
return _finalize_options(options)
|
||||
|
||||
|
||||
def complete_registration(username: str, credential_json: dict[str, Any],
|
||||
label: str = "") -> dict:
|
||||
challenge = _take_challenge(f"{username}:register")
|
||||
if challenge is None:
|
||||
raise ValueError("Session d'enregistrement expirée — recommencez")
|
||||
|
||||
credential = parse_registration_credential_json(credential_json)
|
||||
verification = verify_registration_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=rp_id(),
|
||||
expected_origin=expected_origins(),
|
||||
)
|
||||
|
||||
transports = credential.response.transports or []
|
||||
label = (label or str(credential_json.get("label") or "")).strip() or "Security key"
|
||||
record = {
|
||||
"credential_id": bytes_to_base64url(verification.credential_id),
|
||||
"public_key": bytes_to_base64url(verification.credential_public_key),
|
||||
"sign_count": int(verification.sign_count),
|
||||
"transports": [str(t) for t in transports],
|
||||
"label": label[:60],
|
||||
}
|
||||
return record
|
||||
|
||||
|
||||
# ── Authentication (assertion at login) ────────────────────────────────
|
||||
|
||||
def begin_authentication(username: str, credentials: list[dict]) -> dict | None:
|
||||
if not credentials:
|
||||
return None
|
||||
from webauthn.helpers.structs import PublicKeyCredentialDescriptor
|
||||
|
||||
allow = [
|
||||
PublicKeyCredentialDescriptor(id=base64url_to_bytes(c["credential_id"]))
|
||||
for c in credentials
|
||||
]
|
||||
options = generate_authentication_options(
|
||||
rp_id=rp_id(),
|
||||
challenge=_store_challenge(f"{username}:login"),
|
||||
allow_credentials=allow,
|
||||
)
|
||||
return _finalize_options(options)
|
||||
|
||||
|
||||
def complete_authentication(
|
||||
username: str,
|
||||
credential_json: dict[str, Any],
|
||||
stored: dict,
|
||||
) -> int:
|
||||
"""Verify an assertion. Returns the new sign_count. Raises ValueError on failure."""
|
||||
challenge = _take_challenge(f"{username}:login")
|
||||
if challenge is None:
|
||||
raise ValueError("Session expirée — rechargez la page")
|
||||
|
||||
credential = parse_authentication_credential_json(credential_json)
|
||||
verification = verify_authentication_response(
|
||||
credential=credential,
|
||||
expected_challenge=challenge,
|
||||
expected_rp_id=rp_id(),
|
||||
expected_origin=expected_origins(),
|
||||
credential_public_key=base64url_to_bytes(stored["public_key"]),
|
||||
credential_current_sign_count=int(stored.get("sign_count", 0)),
|
||||
)
|
||||
return int(verification.new_sign_count)
|
||||
|
||||
|
||||
def credentials_for_api(credentials: list[dict]) -> list[dict]:
|
||||
"""Sanitized view for the settings UI (no public keys)."""
|
||||
return [
|
||||
{
|
||||
"credential_id": c.get("credential_id"),
|
||||
"label": c.get("label", "Security key"),
|
||||
"transports": c.get("transports", []),
|
||||
"registered_at": c.get("registered_at"),
|
||||
}
|
||||
for c in credentials
|
||||
]
|
||||
|
||||
|
||||
def _finalize_options(options) -> dict:
|
||||
import json
|
||||
|
||||
return json.loads(options_to_json(options))
|
||||
Reference in New Issue
Block a user