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:
2026-07-17 23:20:34 +02:00
co-authored by Claude Opus 4.8
commit a5ec0d21cb
174 changed files with 22239 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""Identity: app accounts, auth, sessions, handles (SPEC §6.1)."""
+76
View File
@@ -0,0 +1,76 @@
"""Session and CSRF cookie handling (SPEC §6.1, §10.3).
Over HTTPS we emit the spec-mandated ``__Host-session`` prefix with ``Secure``.
Over plain HTTP (dev only) we fall back to a plain ``session`` name without
``Secure`` so login works behind the Vite HTTP proxy. See docs/open-questions.md
OQ-3. ``SameSite=Lax``, ``HttpOnly`` (session), ``Path=/`` are always applied.
"""
from __future__ import annotations
import secrets
from fastapi import Request, Response
SESSION_COOKIE_SECURE = "__Host-session"
SESSION_COOKIE_INSECURE = "session"
CSRF_COOKIE_SECURE = "__Host-csrf"
CSRF_COOKIE_INSECURE = "csrf"
CSRF_HEADER = "X-CSRF"
def _is_secure(request: Request) -> bool:
return request.url.scheme == "https"
def session_cookie_name(request: Request) -> str:
"""Return the session cookie name appropriate to the request scheme."""
return SESSION_COOKIE_SECURE if _is_secure(request) else SESSION_COOKIE_INSECURE
def csrf_cookie_name(request: Request) -> str:
"""Return the CSRF cookie name appropriate to the request scheme."""
return CSRF_COOKIE_SECURE if _is_secure(request) else CSRF_COOKIE_INSECURE
def read_session_token(request: Request) -> str:
"""Read the session token from either cookie name (whichever is present)."""
return request.cookies.get(SESSION_COOKIE_SECURE) or request.cookies.get(
SESSION_COOKIE_INSECURE, ""
)
def new_csrf_token() -> str:
"""Return a fresh CSRF token for the double-submit pattern."""
return secrets.token_urlsafe(32)
def set_auth_cookies(
request: Request, response: Response, session_token: str, csrf_token: str, max_age: int
) -> None:
"""Set the session (HttpOnly) and CSRF (JS-readable) cookies."""
secure = _is_secure(request)
response.set_cookie(
session_cookie_name(request),
session_token,
max_age=max_age,
httponly=True,
secure=secure,
samesite="lax",
path="/",
)
response.set_cookie(
csrf_cookie_name(request),
csrf_token,
max_age=max_age,
httponly=False, # double-submit: client JS echoes it in the X-CSRF header
secure=secure,
samesite="lax",
path="/",
)
def clear_auth_cookies(request: Request, response: Response) -> None:
"""Clear both auth cookies (logout)."""
for name in (session_cookie_name(request), csrf_cookie_name(request)):
response.delete_cookie(name, path="/")
+50
View File
@@ -0,0 +1,50 @@
"""Auth dependencies: current user and CSRF enforcement (SPEC §6.1, §10.3)."""
from __future__ import annotations
from typing import Annotated
from fastapi import Depends, Request
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.db import get_session
from app.identity.cookies import CSRF_HEADER, csrf_cookie_name, read_session_token
from app.identity.sessions import Session, resolve_session
from app.models import User
async def current_session(request: Request) -> Session:
"""Resolve the caller's session or raise 401."""
token = read_session_token(request)
session = await resolve_session(token)
if session is None:
raise_api_error(401, "not_authenticated", "authentication required")
return session
async def current_user(
session: Annotated[Session, Depends(current_session)],
db: Annotated[AsyncSession, Depends(get_session)],
) -> User:
"""Load the authenticated :class:`User` (I-7: scoped by the session's user_id)."""
user = await db.scalar(select(User).where(User.id == session.user_id))
if user is None:
# Session references a deleted user; treat as unauthenticated.
raise_api_error(401, "not_authenticated", "authentication required")
return user
async def require_csrf(request: Request) -> None:
"""Enforce the double-submit CSRF token on mutating requests (SPEC §10.3)."""
if request.method in ("GET", "HEAD", "OPTIONS"):
return
header = request.headers.get(CSRF_HEADER, "")
cookie = request.cookies.get(csrf_cookie_name(request), "")
if not header or not cookie or header != cookie:
raise_api_error(403, "csrf_failed", "missing or invalid CSRF token")
CurrentUser = Annotated[User, Depends(current_user)]
CurrentSession = Annotated[Session, Depends(current_session)]
+56
View File
@@ -0,0 +1,56 @@
"""Handle (localpart) validation per SPEC §4.
Rules (enforced in code, mail-grade from day one):
- localpart: 3-30 chars, characters ``[a-z0-9._-]``
- no leading/trailing dot, no double dot
- case-insensitive (normalize to lowercase)
- not in the reserved list (:mod:`app.identity.reserved`)
The stored handle is the full form ``localpart@{APP_DOMAIN}``.
"""
from __future__ import annotations
import re
from app.config import get_settings
from app.identity.reserved import is_reserved
_LOCALPART_RE = re.compile(r"^[a-z0-9._-]+$")
_MIN_LEN = 3
_MAX_LEN = 30
class InvalidHandle(ValueError):
"""Raised when a proposed localpart violates the SPEC §4 rules."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
def normalize_localpart(localpart: str) -> str:
"""Lowercase and strip surrounding whitespace (case-insensitive handles)."""
return localpart.strip().lower()
def validate_localpart(localpart: str) -> str:
"""Validate and normalize ``localpart``; return it or raise :class:`InvalidHandle`."""
lp = normalize_localpart(localpart)
if not (_MIN_LEN <= len(lp) <= _MAX_LEN):
raise InvalidHandle("length", f"handle must be {_MIN_LEN}-{_MAX_LEN} characters")
if not _LOCALPART_RE.match(lp):
raise InvalidHandle("charset", "handle may contain only a-z, 0-9, '.', '_', '-'")
if lp.startswith(".") or lp.endswith("."):
raise InvalidHandle("dot_edge", "handle may not start or end with a dot")
if ".." in lp:
raise InvalidHandle("double_dot", "handle may not contain consecutive dots")
if is_reserved(lp):
raise InvalidHandle("reserved", "that handle is reserved")
return lp
def full_handle(localpart: str) -> str:
"""Return the full handle ``localpart@{APP_DOMAIN}`` for a validated localpart."""
return f"{validate_localpart(localpart)}@{get_settings().app_domain}"
+34
View File
@@ -0,0 +1,34 @@
"""Argon2id password hashing (SPEC §6.1, §10.1).
Params are fixed: ``time_cost=3``, ``memory_cost=64 MiB``, ``parallelism=4``.
No other password hashing is permitted anywhere (SPEC §10.1).
"""
from __future__ import annotations
from argon2 import PasswordHasher
from argon2.exceptions import VerificationError, VerifyMismatchError
_hasher = PasswordHasher(
time_cost=3,
memory_cost=64 * 1024, # KiB -> 64 MiB
parallelism=4,
)
def hash_password(password: str) -> str:
"""Return an argon2id hash for ``password``."""
return _hasher.hash(password)
def verify_password(password_hash: str, password: str) -> bool:
"""Constant-time verify ``password`` against ``password_hash``."""
try:
return _hasher.verify(password_hash, password)
except (VerifyMismatchError, VerificationError):
return False
def needs_rehash(password_hash: str) -> bool:
"""Return True if ``password_hash`` was made with outdated parameters."""
return _hasher.check_needs_rehash(password_hash)
+50
View File
@@ -0,0 +1,50 @@
"""Reserved localparts that may not be registered (SPEC §4/§6.1).
Mail-grade from day one: role addresses (RFC 2142) plus common admin/system
handles. Comparison is case-insensitive; keep entries lowercase.
"""
from __future__ import annotations
RESERVED_LOCALPARTS: frozenset[str] = frozenset(
{
# RFC 2142 role addresses
"postmaster",
"abuse",
"hostmaster",
"webmaster",
"usenet",
"news",
"www",
"uucp",
"ftp",
"noc",
"security",
"mailer-daemon",
# Common system/admin handles
"admin",
"administrator",
"root",
"noreply",
"no-reply",
"donotreply",
"do-not-reply",
"support",
"help",
"info",
"contact",
"sales",
"billing",
"system",
"daemon",
"nobody",
"null",
"void",
"test",
}
)
def is_reserved(localpart: str) -> bool:
"""Return True if ``localpart`` is reserved (case-insensitive)."""
return localpart.lower() in RESERVED_LOCALPARTS
+43
View File
@@ -0,0 +1,43 @@
"""Password reset tokens (SPEC §6.1).
Single-use, 30-minute, Redis-backed tokens. Only issued when the account has a
recovery email on file. Actual delivery of the token to the recovery address
requires an app-level mailer (not part of M0/M1); until then the token is logged
server-side. See docs/open-questions.md.
"""
from __future__ import annotations
import logging
import secrets
import uuid
from app.redis_client import aw, get_redis
logger = logging.getLogger(__name__)
_PREFIX = "pwreset:"
_TTL = 30 * 60 # 30 minutes
async def issue_token(user_id: uuid.UUID, recovery_email: str) -> str:
"""Create a single-use reset token bound to ``user_id`` and return it."""
token = secrets.token_urlsafe(32)
await get_redis().set(_PREFIX + token, str(user_id), ex=_TTL)
# TODO(spec): deliver via the recovery address once an app mailer exists.
logger.info("password reset token issued for user=%s recovery=%s", user_id, recovery_email)
return token
async def consume_token(token: str) -> uuid.UUID | None:
"""Atomically consume a reset token, returning the user_id (or None)."""
if not token:
return None
redis = get_redis()
raw = await aw(redis.getdel(_PREFIX + token)) # single-use: fetch and delete atomically
if raw is None:
return None
try:
return uuid.UUID(raw)
except ValueError:
return None
+213
View File
@@ -0,0 +1,213 @@
"""Auth API routes (SPEC §9.1): register, login (2FA-aware), logout, me,
TOTP setup/enable, session list/revoke, password reset."""
from __future__ import annotations
from typing import Annotated
from fastapi import APIRouter, Depends, Request, Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.api.errors import raise_api_error
from app.config import get_settings
from app.db import get_session
from app.identity import totp as totp_mod
from app.identity.cookies import (
clear_auth_cookies,
new_csrf_token,
set_auth_cookies,
)
from app.identity.deps import CurrentSession, CurrentUser, require_csrf
from app.identity.passwords import hash_password, verify_password
from app.identity.reset import consume_token, issue_token
from app.identity.schemas import (
LoginRequest,
MessageOut,
PasswordResetIn,
PasswordResetRequestIn,
RegisterRequest,
SessionOut,
TotpDisableRequest,
TotpEnableRequest,
TotpSetupResponse,
UserOut,
)
from app.identity.service import (
RateLimited,
RegistrationError,
TotpInvalid,
TotpRequired,
authenticate,
register_user,
resolve_handle,
)
from app.identity.sessions import (
create_session,
destroy_all_sessions,
destroy_session,
list_sessions,
revoke_session,
)
from app.models import User
router = APIRouter(prefix="/auth", tags=["auth"])
DbSession = Annotated[AsyncSession, Depends(get_session)]
def _client_ip(request: Request) -> str:
return request.client.host if request.client else "unknown"
async def _establish_session(request: Request, response: Response, user: User) -> None:
token = await create_session(user.id, request.headers.get("user-agent", ""))
csrf = new_csrf_token()
max_age = get_settings().session_ttl_days * 24 * 3600
set_auth_cookies(request, response, token, csrf, max_age)
def _user_out(user: User) -> UserOut:
return UserOut(
id=user.id,
handle=user.handle,
recovery_email=user.recovery_email,
settings=user.settings,
totp_enabled=user.totp_secret is not None,
)
@router.post("/register", response_model=UserOut)
async def register(
body: RegisterRequest, request: Request, response: Response, db: DbSession
) -> UserOut:
try:
user = await register_user(
db,
localpart=body.localpart,
password=body.password,
recovery_email=body.recovery_email,
)
except RegistrationError as exc:
status = 409 if exc.code == "handle_taken" else 400
raise_api_error(status, exc.code, exc.message)
await db.commit()
await _establish_session(request, response, user)
return _user_out(user)
@router.post("/login", response_model=UserOut)
async def login(body: LoginRequest, request: Request, response: Response, db: DbSession) -> UserOut:
try:
user = await authenticate(
db,
handle_input=body.handle,
password=body.password,
ip=_client_ip(request),
totp=body.totp,
)
except RateLimited as exc:
response.headers["Retry-After"] = str(exc.retry_after)
raise_api_error(429, "rate_limited", "too many attempts; try again later")
except TotpRequired:
raise_api_error(401, "totp_required", "a 2FA code is required")
except TotpInvalid:
raise_api_error(401, "totp_invalid", "incorrect 2FA code")
if user is None:
raise_api_error(401, "invalid_credentials", "incorrect handle or password")
await _establish_session(request, response, user)
return _user_out(user)
@router.post("/logout", response_model=MessageOut, dependencies=[Depends(require_csrf)])
async def logout(request: Request, response: Response, session: CurrentSession) -> MessageOut:
await destroy_session(session.token)
clear_auth_cookies(request, response)
return MessageOut()
@router.get("/me", response_model=UserOut)
async def me(user: CurrentUser) -> UserOut:
return _user_out(user)
# --- Two-factor authentication (SPEC §6.1) ----------------------------------
@router.post("/totp/setup", response_model=TotpSetupResponse, dependencies=[Depends(require_csrf)])
async def totp_setup(user: CurrentUser) -> TotpSetupResponse:
if user.totp_secret is not None:
raise_api_error(409, "totp_already_enabled", "2FA is already enabled")
secret = await totp_mod.start_setup(user.id)
return TotpSetupResponse(
secret=secret, provisioning_uri=totp_mod.provisioning_uri(secret, user.handle)
)
@router.post("/totp/enable", response_model=MessageOut, dependencies=[Depends(require_csrf)])
async def totp_enable(body: TotpEnableRequest, user: CurrentUser, db: DbSession) -> MessageOut:
secret = await totp_mod.get_pending(user.id)
if secret is None:
raise_api_error(400, "totp_no_setup", "start TOTP setup first")
if not totp_mod.verify(secret, body.code):
raise_api_error(400, "totp_invalid", "incorrect 2FA code")
user.totp_secret = totp_mod.encrypt_totp_secret(secret)
await db.commit()
await totp_mod.clear_pending(user.id)
return MessageOut()
@router.post("/totp/disable", response_model=MessageOut, dependencies=[Depends(require_csrf)])
async def totp_disable(body: TotpDisableRequest, user: CurrentUser, db: DbSession) -> MessageOut:
# Re-authenticate with the password before turning off 2FA.
if not verify_password(user.password_hash, body.password):
raise_api_error(401, "invalid_credentials", "incorrect password")
user.totp_secret = None
await db.commit()
return MessageOut()
# --- Session management (SPEC §9.1) -----------------------------------------
@router.get("/sessions", response_model=list[SessionOut])
async def sessions(user: CurrentUser, session: CurrentSession) -> list[SessionOut]:
infos = await list_sessions(user.id, session.token)
return [
SessionOut(sid=i.sid, created=i.created.isoformat(), ua=i.ua, current=i.current)
for i in infos
]
@router.delete("/sessions/{sid}", response_model=MessageOut, dependencies=[Depends(require_csrf)])
async def revoke(sid: str, user: CurrentUser) -> MessageOut:
if not await revoke_session(user.id, sid):
raise_api_error(404, "not_found", "session not found")
return MessageOut()
# --- Password reset (SPEC §6.1) ---------------------------------------------
@router.post("/password/reset-request", response_model=MessageOut)
async def password_reset_request(body: PasswordResetRequestIn, db: DbSession) -> MessageOut:
# Always return ok (do not leak whether the handle exists / has recovery mail).
handle = resolve_handle(body.handle)
user = await db.scalar(select(User).where(User.handle == handle))
if user is not None and user.recovery_email:
await issue_token(user.id, user.recovery_email)
return MessageOut()
@router.post("/password/reset", response_model=MessageOut)
async def password_reset(body: PasswordResetIn, db: DbSession) -> MessageOut:
user_id = await consume_token(body.token)
if user_id is None:
raise_api_error(400, "invalid_token", "reset token is invalid or expired")
user = await db.get(User, user_id)
if user is None:
raise_api_error(400, "invalid_token", "reset token is invalid or expired")
user.password_hash = hash_password(body.password)
await db.commit()
await destroy_all_sessions(user_id) # force re-login everywhere after reset
return MessageOut()
+61
View File
@@ -0,0 +1,61 @@
"""Request/response models for the auth API (SPEC §9.1)."""
from __future__ import annotations
import uuid
from pydantic import BaseModel, Field
class RegisterRequest(BaseModel):
localpart: str = Field(min_length=3, max_length=30)
password: str = Field(min_length=8, max_length=1024)
recovery_email: str | None = None
class LoginRequest(BaseModel):
# Accepts either a bare localpart or the full ``localpart@domain`` handle.
handle: str = Field(min_length=1, max_length=320)
password: str = Field(min_length=1, max_length=1024)
totp: str | None = None # required when the account has 2FA enabled
class TotpSetupResponse(BaseModel):
secret: str # base32, for manual entry
provisioning_uri: str # otpauth:// URI to render as a QR code
class TotpEnableRequest(BaseModel):
code: str = Field(min_length=6, max_length=10)
class TotpDisableRequest(BaseModel):
password: str = Field(min_length=1, max_length=1024)
class SessionOut(BaseModel):
sid: str
created: str
ua: str
current: bool
class PasswordResetRequestIn(BaseModel):
handle: str = Field(min_length=1, max_length=320)
class PasswordResetIn(BaseModel):
token: str = Field(min_length=1)
password: str = Field(min_length=8, max_length=1024)
class UserOut(BaseModel):
id: uuid.UUID
handle: str
recovery_email: str | None
settings: dict[str, object]
totp_enabled: bool = False
class MessageOut(BaseModel):
status: str = "ok"
+124
View File
@@ -0,0 +1,124 @@
"""Registration, authentication, and login rate limiting (SPEC §6.1)."""
from __future__ import annotations
from dataclasses import dataclass
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import get_settings
from app.identity.handles import InvalidHandle, full_handle, normalize_localpart
from app.identity.passwords import hash_password, verify_password
from app.models import User
from app.redis_client import get_redis
# SPEC §6.1: 10 attempts / 15 min per handle AND per IP.
_RL_MAX = 10
_RL_WINDOW = 15 * 60
class RegistrationError(Exception):
"""Registration failed for a user-actionable reason."""
def __init__(self, code: str, message: str) -> None:
super().__init__(message)
self.code = code
self.message = message
@dataclass(frozen=True)
class RateLimited(Exception):
"""Raised when the login rate limit is exceeded."""
retry_after: int
class TotpRequired(Exception):
"""Password verified but the account has 2FA enabled and no code was given."""
class TotpInvalid(Exception):
"""Password verified but the supplied TOTP code was wrong."""
def resolve_handle(handle_input: str) -> str:
"""Normalize a login identifier to the full ``localpart@APP_DOMAIN`` form."""
text = handle_input.strip().lower()
if "@" in text:
return text
return f"{normalize_localpart(text)}@{get_settings().app_domain}"
async def register_user(
db: AsyncSession, *, localpart: str, password: str, recovery_email: str | None
) -> User:
"""Create a new user. Raises :class:`RegistrationError` on validation/uniqueness."""
try:
handle = full_handle(localpart)
except InvalidHandle as exc:
raise RegistrationError(exc.code, exc.message) from exc
user = User(
handle=handle,
password_hash=hash_password(password),
recovery_email=(recovery_email.strip().lower() if recovery_email else None),
)
db.add(user)
try:
await db.flush()
except IntegrityError as exc:
await db.rollback()
raise RegistrationError("handle_taken", "that handle is already registered") from exc
return user
async def _bump_rate_limit(key: str) -> int:
redis = get_redis()
count = await redis.incr(key)
if count == 1:
await redis.expire(key, _RL_WINDOW)
return int(count)
async def _check_rate_limit(handle: str, ip: str) -> None:
for key in (f"rl:login:handle:{handle}", f"rl:login:ip:{ip}"):
count = await _bump_rate_limit(key)
if count > _RL_MAX:
ttl = await get_redis().ttl(key)
raise RateLimited(retry_after=max(ttl, 1))
async def authenticate(
db: AsyncSession, *, handle_input: str, password: str, ip: str, totp: str | None = None
) -> User | None:
"""Verify credentials (constant-time). Returns the user or None; may rate-limit.
When the account has TOTP enabled, raises :class:`TotpRequired` (no code given)
or :class:`TotpInvalid` (wrong code) after the password check passes.
"""
handle = resolve_handle(handle_input)
await _check_rate_limit(handle, ip)
user = await db.scalar(select(User).where(User.handle == handle))
# Always run a verify to keep timing uniform whether or not the user exists.
stored_hash = user.password_hash if user else _DUMMY_HASH
ok = verify_password(stored_hash, password)
if user is None or not ok:
return None
if user.totp_secret is not None:
from app.identity.totp import decrypt_totp_secret
from app.identity.totp import verify as verify_totp
if not totp:
raise TotpRequired
if not verify_totp(decrypt_totp_secret(user.totp_secret), totp):
raise TotpInvalid
return user
# A precomputed hash so authentication of a non-existent handle still spends
# argon2 time (mitigates user-enumeration via timing, SPEC §6.1 constant-time).
_DUMMY_HASH = hash_password("x" * 24)
+141
View File
@@ -0,0 +1,141 @@
"""Server-side sessions in Redis (SPEC §4 "Sessions", §9.1 session management).
``sess:{token}`` -> JSON ``{user_id, sid, created, ua}`` with a sliding TTL of
``SESSION_TTL_DAYS`` days. A per-user index ``usess:{user_id}`` (hash sid->token)
lets us list and revoke a user's sessions without exposing the raw token, which is
the session credential itself. Tokens are opaque and high-entropy.
"""
from __future__ import annotations
import json
import secrets
import uuid
from dataclasses import dataclass
from datetime import UTC, datetime
from app.config import get_settings
from app.redis_client import aw, get_redis
_PREFIX = "sess:"
_INDEX = "usess:"
_TOKEN_BYTES = 32
@dataclass(frozen=True)
class Session:
token: str
user_id: uuid.UUID
sid: str # public session id (safe to expose; not the token)
created: datetime
ua: str
@dataclass(frozen=True)
class SessionInfo:
sid: str
created: datetime
ua: str
current: bool
def _ttl_seconds() -> int:
return get_settings().session_ttl_days * 24 * 3600
def _index_key(user_id: uuid.UUID | str) -> str:
return f"{_INDEX}{user_id}"
async def create_session(user_id: uuid.UUID, user_agent: str) -> str:
"""Create a session for ``user_id`` and return its opaque token."""
token = secrets.token_urlsafe(_TOKEN_BYTES)
sid = secrets.token_hex(8)
payload = {
"user_id": str(user_id),
"sid": sid,
"created": datetime.now(UTC).isoformat(),
"ua": user_agent[:256],
}
redis = get_redis()
ttl = _ttl_seconds()
await redis.set(_PREFIX + token, json.dumps(payload), ex=ttl)
await aw(redis.hset(_index_key(user_id), sid, token))
await redis.expire(_index_key(user_id), ttl)
return token
async def resolve_session(token: str) -> Session | None:
"""Resolve ``token`` to a :class:`Session`, refreshing its sliding TTL."""
if not token:
return None
redis = get_redis()
raw = await redis.get(_PREFIX + token)
if raw is None:
return None
ttl = _ttl_seconds()
data = json.loads(raw)
await redis.expire(_PREFIX + token, ttl) # sliding
await redis.expire(_index_key(data["user_id"]), ttl)
return Session(
token=token,
user_id=uuid.UUID(data["user_id"]),
sid=data.get("sid", ""),
created=datetime.fromisoformat(data["created"]),
ua=data.get("ua", ""),
)
async def destroy_session(token: str) -> None:
"""Delete a session (logout)."""
if not token:
return
redis = get_redis()
raw = await redis.get(_PREFIX + token)
await redis.delete(_PREFIX + token)
if raw is not None:
data = json.loads(raw)
await aw(redis.hdel(_index_key(data["user_id"]), data.get("sid", "")))
async def list_sessions(user_id: uuid.UUID, current_token: str) -> list[SessionInfo]:
"""List a user's active sessions, pruning any whose token has expired."""
redis = get_redis()
mapping: dict[str, str] = await aw(redis.hgetall(_index_key(user_id)))
out: list[SessionInfo] = []
for sid, token in mapping.items():
raw = await redis.get(_PREFIX + token)
if raw is None:
await aw(redis.hdel(_index_key(user_id), sid)) # prune stale index entry
continue
data = json.loads(raw)
out.append(
SessionInfo(
sid=sid,
created=datetime.fromisoformat(data["created"]),
ua=data.get("ua", ""),
current=(token == current_token),
)
)
out.sort(key=lambda s: s.created, reverse=True)
return out
async def revoke_session(user_id: uuid.UUID, sid: str) -> bool:
"""Revoke a specific session by its public sid. Returns False if not found."""
redis = get_redis()
token = await aw(redis.hget(_index_key(user_id), sid))
if token is None:
return False
await redis.delete(_PREFIX + token)
await aw(redis.hdel(_index_key(user_id), sid))
return True
async def destroy_all_sessions(user_id: uuid.UUID) -> None:
"""Revoke every session for a user (e.g. after a password reset)."""
redis = get_redis()
mapping: dict[str, str] = await aw(redis.hgetall(_index_key(user_id)))
for token in mapping.values():
await redis.delete(_PREFIX + token)
await redis.delete(_index_key(user_id))
+59
View File
@@ -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")