Vai al contenuto

Web Auth · Multi-2FA

Stack mag 2026: FastAPI 0.115, pyotp 2.9, webauthn 2.7.1, argon2-cffi 23.1, @simplewebauthn/browser 13.x

1. User profile

from enum import Enum
from uuid import UUID, uuid4
from datetime import datetime
from pydantic import BaseModel, EmailStr


class UserRole(str, Enum):
    OWNER = "owner"
    ADMIN = "admin"
    MEMBER = "member"
    GUEST = "guest"  # TTL 48h


class ThemePreference(str, Enum):
    LIGHT = "light"
    DARK = "dark"
    SYSTEM = "system"


class PrivacyMode(str, Enum):
    STANDARD = "standard"
    REDUCED = "reduced"      # niente testo conversazioni in log
    ANONYMOUS = "anonymous"  # solo metadati tecnici


class UserProfile(BaseModel):
    id: UUID = uuid4()
    email: EmailStr
    display_name: str
    avatar_url: str | None = None
    role: UserRole = UserRole.MEMBER
    theme: ThemePreference = ThemePreference.SYSTEM
    language: str = "it"
    tts_voice: str | None = None
    privacy_mode: PrivacyMode = PrivacyMode.STANDARD
    mfa_enabled: bool = False
    created_at: datetime
    last_login_at: datetime | None = None
    is_active: bool = True

Multi-utente isolation

Ogni query DB include owner_id = current_user.id. PostgreSQL Row-Level Security raccomandato come difesa in profondità.

RBAC

from fastapi import Depends, HTTPException

ROLE_HIERARCHY = {UserRole.OWNER: 4, UserRole.ADMIN: 3, UserRole.MEMBER: 2, UserRole.GUEST: 1}


def require_role(min_role: UserRole):
    def dep(current_user: UserProfile = Depends(get_current_user)):
        if ROLE_HIERARCHY[current_user.role] < ROLE_HIERARCHY[min_role]:
            raise HTTPException(403, "Permessi insufficienti")
        return current_user
    return dep


# Usage:
# @router.delete("/users/{uid}")
# async def delete_user(uid: UUID, _=Depends(require_role(UserRole.ADMIN))):

Sezioni profilo UI

  • I miei dispositivi: lista pairing attivi con device_name, ip_address, last_seen, pulsante revoca (server-side)
  • I miei dati GDPR: export JSON/ZIP via task asincrono → email link entro 72h
  • Activity log: timestamp, event_type, ip, user_agent. Visibile solo all'utente

2. Login flow base

Argon2id (OWASP 2025)

from argon2 import PasswordHasher
from argon2.exceptions import VerifyMismatchError

# Parametri OWASP 2025: memory=128MB, time=3, parallelism=4
_ph = PasswordHasher(time_cost=3, memory_cost=131072, parallelism=4, hash_len=32, salt_len=16)


def hash_password(plain: str) -> str:
    return _ph.hash(plain)


def verify_password(plain: str, hashed: str) -> bool:
    try:
        return _ph.verify(hashed, plain)
    except VerifyMismatchError:
        return False


PASSWORD_POLICY = {
    "min_length": 12,
    "require_uppercase": True,
    "require_digit": True,
    "require_special": True,
    "pwned_check": True,  # API HaveIBeenPwned k-anonymity
}

JWT monouso ES256, scadenza 15 min, invalidato dopo uso (tabella used_tokens). Non riutilizzabile anche se intercettato.

SESSION_COOKIE_SETTINGS = {
    "key": "jarvis_session",
    "httponly": True,
    "secure": True,
    "samesite": "strict",
    "max_age": 86400 * 14,
    "path": "/",
}

Brute force protection

  • Per IP: 20 tentativi / 5 min (slowapi + Redis)
  • Per account: 5 tentativi falliti consecutivi → lockout 15 min + notifica email
  • 3 lockout in 24h → richiede verifica email

3. MFA implementations

3.1 TOTP (RFC 6238)

import pyotp
import qrcode
import io
import base64
import secrets


def generate_totp_secret() -> str:
    return pyotp.random_base32()


def get_totp_uri(secret: str, email: str, issuer: str = "open-jarvis") -> str:
    return pyotp.TOTP(secret).provisioning_uri(name=email, issuer_name=issuer)


def generate_qr_base64(uri: str) -> str:
    img = qrcode.make(uri)
    buf = io.BytesIO()
    img.save(buf, format="PNG")
    return base64.b64encode(buf.getvalue()).decode()


def verify_totp(secret: str, code: str) -> bool:
    return pyotp.TOTP(secret).verify(code, valid_window=1)  # ±30s NTP drift


def generate_backup_codes(count: int = 10) -> list[str]:
    return [secrets.token_hex(5).upper() for _ in range(count)]

App compatibili: Google Authenticator, Authy, Aegis (open source raccomandato), 1Password, Bitwarden.

3.2 Email OTP

6 cifre, TTL 10 min, max 3 tentativi, rate limit 1 req/60s per utente. Codice hashato SHA-256 in DB.

3.3 SMS OTP (sconsigliato)

Vulnerabile a SIM swapping e SS7. Se necessario: provider OTP-specific (Twilio Verify, Vonage), TTL ≤ 5 min, mai unico fattore per operazioni alto rischio. NIST SP 800-63B deprecia SMS dal 2024.

3.4 WebAuthn / Passkey

Phishing-resistant by design (challenge include origin). Synced passkey AAL2-compliant (NIST 800-63-4 lug 2025).

import webauthn
from webauthn.helpers.structs import PublicKeyCredentialDescriptor, AuthenticatorTransport

RP_ID = "jarvis.local"
RP_NAME = "open-jarvis"
ORIGIN = "https://jarvis.local"


def begin_registration(user_id: bytes, username: str, existing: list):
    return webauthn.generate_registration_options(
        rp_id=RP_ID,
        rp_name=RP_NAME,
        user_id=user_id,
        user_name=username,
        exclude_credentials=[
            PublicKeyCredentialDescriptor(id=c.credential_id) for c in existing
        ],
    )


def complete_registration(credential, challenge: bytes, expected_origin: str):
    return webauthn.verify_registration_response(
        credential=credential,
        expected_challenge=challenge,
        expected_rp_id=RP_ID,
        expected_origin=expected_origin,
    )


def begin_authentication(credentials: list):
    return webauthn.generate_authentication_options(
        rp_id=RP_ID,
        allow_credentials=[
            PublicKeyCredentialDescriptor(
                id=c.credential_id, transports=[AuthenticatorTransport.INTERNAL]
            )
            for c in credentials
        ],
    )


def complete_authentication(credential, challenge: bytes, stored_cred):
    return webauthn.verify_authentication_response(
        credential=credential,
        expected_challenge=challenge,
        expected_rp_id=RP_ID,
        expected_origin=ORIGIN,
        credential_public_key=stored_cred.public_key,
        credential_current_sign_count=stored_cred.sign_count,
    )

3.5 Hardware token U2F/FIDO2

YubiKey 5, Google Titan: stesso stack WebAuthn, nessuna libreria aggiuntiva. Raccomandato come 2FA obbligatorio per ruolo Owner.

3.6 Step-up auth

Operazioni sensibili (cambio email, cancellazione account) richiedono ri-auth ≤ 5 min:

from datetime import datetime, timedelta, timezone
from fastapi import Request, HTTPException

STEP_UP_WINDOW = timedelta(minutes=5)


def require_step_up(request: Request) -> None:
    session = request.state.session
    verified_at = session.get("step_up_verified_at")
    if not verified_at:
        raise HTTPException(403, "step_up_required")
    if datetime.now(timezone.utc) - verified_at > STEP_UP_WINDOW:
        raise HTTPException(403, "step_up_expired")

4. FastAPI endpoint completi

from fastapi import APIRouter, Response, Request, Depends, HTTPException
from pydantic import BaseModel, EmailStr
from uuid import UUID

router = APIRouter(prefix="/auth", tags=["auth"])


class LoginRequest(BaseModel):
    email: EmailStr
    password: str


class TOTPVerifyRequest(BaseModel):
    session_token: str
    code: str


@router.post("/register", status_code=201)
async def register(body: dict):
    validate_password_policy(body["password"])
    hashed = hash_password(body["password"])
    user = await create_user(body["email"], hashed, body["display_name"])
    await send_verification_email(user.email)
    return {"message": "Verifica la tua email."}


@router.post("/login")
async def login(body: LoginRequest, response: Response):
    user = await get_user_by_email(body.email)
    if not user or not verify_password(body.password, user.password_hash):
        await record_failed_attempt(body.email)
        raise HTTPException(401, "Credenziali non valide.")
    if user.mfa_enabled:
        pending_token = create_pending_session(user.id)  # 5 min
        return {"mfa_required": True, "session_token": pending_token,
                "mfa_methods": user.enrolled_mfa_methods}
    set_session_cookie(response, create_session(user.id))
    return {"message": "Login completato."}


@router.post("/2fa/totp/verify")
async def verify_totp_endpoint(body: TOTPVerifyRequest, response: Response):
    user = await get_user_from_pending(body.session_token)
    if not verify_totp(user.totp_secret, body.code):
        raise HTTPException(401, "Codice TOTP non valido.")
    set_session_cookie(response, create_session(user.id))
    return {"message": "2FA completato."}


@router.post("/2fa/email/send")
async def send_email_otp(body: dict):
    user = await get_user_from_pending(body["session_token"])
    otp = generate_email_otp(user.id)
    await send_otp_email(user.email, otp)
    return {"message": "OTP inviato."}


@router.post("/2fa/email/verify")
async def verify_email_otp(body: dict, response: Response):
    user = await get_user_from_pending(body["session_token"])
    if not check_email_otp(user.id, body["code"]):
        raise HTTPException(401, "OTP non valido o scaduto.")
    set_session_cookie(response, create_session(user.id))
    return {"message": "2FA via email completato."}


@router.post("/passkey/register/begin")
async def passkey_register_begin(request: Request,
                                  current_user=Depends(get_current_user)):
    existing = await get_user_credentials(current_user.id)
    options = begin_registration(current_user.id.bytes, current_user.email, existing)
    request.session["webauthn_challenge"] = options.challenge
    return options


@router.post("/passkey/login")
async def passkey_login(request: Request, credential: dict, response: Response):
    user_handle = extract_user_handle(credential)
    user = await get_user_by_id(UUID(bytes=user_handle))
    stored = await get_credential(credential["id"])
    challenge = request.session.pop("webauthn_challenge", None)
    result = complete_authentication(credential, challenge, stored)
    await update_sign_count(stored.id, result.new_sign_count)
    set_session_cookie(response, create_session(user.id))
    return {"message": "Login con passkey completato."}

5. Frontend React

Passkey

// @simplewebauthn/browser 13.x
import { startRegistration, startAuthentication } from "@simplewebauthn/browser";


export async function registerPasskey(): Promise<void> {
  const opts = await fetch("/auth/passkey/register/begin", {
    method: "POST", credentials: "include",
  }).then(r => r.json());

  let attResp;
  try {
    attResp = await startRegistration({ optionsJSON: opts });
  } catch (err) {
    if (err instanceof Error && err.name === "InvalidStateError") {
      throw new Error("Passkey già registrata su questo dispositivo.");
    }
    throw err;
  }

  const verify = await fetch("/auth/passkey/register/complete", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "include",
    body: JSON.stringify(attResp),
  });
  if (!verify.ok) throw new Error("Registrazione fallita.");
}


export async function authenticateWithPasskey(): Promise<void> {
  const opts = await fetch("/auth/passkey/challenge", {
    method: "POST", credentials: "include",
  }).then(r => r.json());

  const authResp = await startAuthentication({ optionsJSON: opts });

  const verify = await fetch("/auth/passkey/login", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    credentials: "include",
    body: JSON.stringify(authResp),
  });
  if (!verify.ok) throw new Error("Autenticazione fallita.");
}

TOTP wizard

import { useState, useEffect } from "react";


interface TOTPSetupData {
  qr_base64: string;
  backup_codes: string[];
  secret: string;
}


export function TOTPEnrollmentWizard() {
  const [step, setStep] = useState<"qr" | "verify" | "backup">("qr");
  const [setup, setSetup] = useState<TOTPSetupData | null>(null);
  const [code, setCode] = useState("");

  useEffect(() => {
    fetch("/auth/2fa/totp/setup/begin", { method: "POST", credentials: "include" })
      .then(r => r.json())
      .then(setSetup);
  }, []);

  async function confirm() {
    const r = await fetch("/auth/2fa/totp/setup/confirm", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      credentials: "include",
      body: JSON.stringify({ code }),
    });
    if (!r.ok) throw new Error("Codice non valido");
    setStep("backup");
  }

  if (step === "qr" && setup) {
    return (
      <div>
        <p>Scansiona con la tua app authenticator:</p>
        <img src={`data:image/png;base64,${setup.qr_base64}`} alt="QR TOTP" />
        <button onClick={() => setStep("verify")}>Continua</button>
      </div>
    );
  }
  if (step === "verify") {
    return (
      <div>
        <input value={code} onChange={e => setCode(e.target.value)}
               maxLength={6} autoComplete="one-time-code" placeholder="Codice 6 cifre" />
        <button onClick={confirm}>Verifica</button>
      </div>
    );
  }
  if (step === "backup" && setup) {
    return (
      <div>
        <p><strong>Salva questi codici di recupero  visibili solo ora:</strong></p>
        <pre>{setup.backup_codes.join("\n")}</pre>
        <p>Ogni codice è monouso. Conservali offline.</p>
      </div>
    );
  }
  return <div>Caricamento...</div>;
}

6. Email delivery

Provider managed

Provider Free tier DKIM/DMARC
Postmark 100 msg/mese automatico
Mailgun 1000 msg/mese (EU) configurabile
AWS SES 62.000/mese da EC2 configurabile
Resend 3000/mese automatico, SDK TS/Python

Self-hosted

  • Postal (transactional, dashboard)
  • Mailcow (Docker, completo)
  • Mail-in-a-Box (single-domain)

Pro: zero dipendenze esterne. Contro: gestione blacklist IP, warm-up reputazione.

Anti-bounce

Suppression list locale: bounce permanente (5xx) blocca futuri invii. Bounce soft (4xx) retry 3x con backoff esponenziale.

7. Best practice 2026

Passkey-first sign-up

window.PublicKeyCredential !== undefined → CTA primario "Crea passkey". Email+password come secondaria. FIDO Alliance 2025: 87% aziende adotta FIDO2; team passkey-first vedono 50-70% adozione volontaria in 6 mesi.

OIDC quick onboarding

"Sign in with Apple/Google" via authlib 1.4.x. Opzionale, disabilitato di default in Jarvis privacy-first.

Privacy-preserving auth

No CDN esterni nel flow auth. Font, JS, QR serviti localmente. Log: solo metadata (timestamp, IP anonymized, user agent).

Audit log

from enum import Enum
from dataclasses import dataclass, field
from datetime import datetime, timezone


class AuditEvent(str, Enum):
    LOGIN_SUCCESS = "login_success"
    LOGIN_FAILED = "login_failed"
    MFA_ENROLLED = "mfa_enrolled"
    MFA_VERIFIED = "mfa_verified"
    PASSWORD_CHANGED = "password_changed"
    PASSKEY_REGISTERED = "passkey_registered"
    ACCOUNT_LOCKED = "account_locked"
    DATA_EXPORT_REQUESTED = "data_export_requested"
    ACCOUNT_DELETED = "account_deleted"


@dataclass(frozen=True)
class AuditRecord:
    user_id: UUID
    event: AuditEvent
    ip_address: str
    user_agent: str
    timestamp: datetime = field(default_factory=lambda: datetime.now(timezone.utc))
    metadata: dict = field(default_factory=dict)


async def record_audit_event(record: AuditRecord) -> None:
    await db.execute(
        "INSERT INTO audit_log VALUES ($1,$2,$3,$4,$5,$6)",
        record.user_id, record.event.value, record.ip_address,
        record.user_agent, record.timestamp, record.metadata
    )

Dipendenze Python (mag 2026)

fastapi==0.115.12
pydantic[email]==2.11.x
argon2-cffi==23.1.0
pyotp==2.9.0
qrcode[pil]==8.1.x
webauthn==2.7.1
slowapi==0.1.9
redis==5.2.x
python-jose[cryptography]==3.4.x
emails==0.6.x

Frontend (mag 2026)

{
  "@simplewebauthn/browser": "^13.0.0",
  "qrcode": "^1.5.4",
  "react": "^19.0.0",
  "typescript": "^5.8.x"
}

Riferimenti