Initial commit: webmail client (M0/M1)
Three-pane webmail client. - Backend: FastAPI + async SQLAlchemy + arq worker (Postgres/Redis), IMAP/SMTP sync, threading, search, drafts/outbox with undo, attachments, 2FA. - Frontend: React 18 + Vite + TypeScript + TanStack Query + Tailwind; three-pane UI with rich-text composer, light/dark themes, resizable panels. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,59 @@
|
||||
"""TOTP two-factor authentication (SPEC §6.1).
|
||||
|
||||
The base32 secret is stored envelope-encrypted in ``users.totp_secret``. A pending
|
||||
(not-yet-enabled) secret lives in Redis until the user confirms one valid code.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
import pyotp
|
||||
|
||||
from app.config import get_settings
|
||||
from app.redis_client import get_redis
|
||||
from app.security.crypto import decrypt_secret, encrypt_secret
|
||||
|
||||
_PENDING_PREFIX = "totp_pending:"
|
||||
_PENDING_TTL = 600 # 10 minutes to complete enrolment
|
||||
_VALID_WINDOW = 1 # ±1 step tolerance (SPEC §6.1)
|
||||
|
||||
|
||||
def generate_secret() -> str:
|
||||
"""Return a fresh base32 TOTP secret."""
|
||||
return pyotp.random_base32()
|
||||
|
||||
|
||||
def provisioning_uri(secret: str, handle: str) -> str:
|
||||
"""Return the otpauth:// URI for QR provisioning."""
|
||||
return pyotp.TOTP(secret).provisioning_uri(name=handle, issuer_name=get_settings().app_domain)
|
||||
|
||||
|
||||
def verify(secret: str, code: str) -> bool:
|
||||
"""Verify a TOTP ``code`` against ``secret`` (±1 window)."""
|
||||
return bool(pyotp.TOTP(secret).verify(code.strip(), valid_window=_VALID_WINDOW))
|
||||
|
||||
|
||||
async def start_setup(user_id: uuid.UUID) -> str:
|
||||
"""Generate a pending secret (stored in Redis) and return it."""
|
||||
secret = generate_secret()
|
||||
await get_redis().set(_PENDING_PREFIX + str(user_id), secret, ex=_PENDING_TTL)
|
||||
return secret
|
||||
|
||||
|
||||
async def get_pending(user_id: uuid.UUID) -> str | None:
|
||||
"""Return the pending (un-enabled) secret for a user, if any."""
|
||||
secret: str | None = await get_redis().get(_PENDING_PREFIX + str(user_id))
|
||||
return secret
|
||||
|
||||
|
||||
async def clear_pending(user_id: uuid.UUID) -> None:
|
||||
await get_redis().delete(_PENDING_PREFIX + str(user_id))
|
||||
|
||||
|
||||
def encrypt_totp_secret(secret: str) -> bytes:
|
||||
return encrypt_secret(secret.encode("utf-8"))
|
||||
|
||||
|
||||
def decrypt_totp_secret(blob: bytes) -> str:
|
||||
return decrypt_secret(blob).decode("utf-8")
|
||||
Reference in New Issue
Block a user