UMBRA/backend/app/models/totp_usage.py
Kyle Pope b134ad9e8b Implement Stage 6 Track B: TOTP MFA (pyotp, Fernet-encrypted secrets, backup codes)
- models/totp_usage.py: replay-prevention table, unique on (user_id, code, window)
- models/backup_code.py: Argon2id-hashed recovery codes with used_at tracking
- services/totp.py: Fernet encrypt/decrypt, verify_totp_code returns actual window, QR base64, backup code generation
- routers/totp.py: setup (idempotent), confirm, totp-verify (mfa_token + TOTP or backup code), disable, regenerate, status
- alembic/024: creates totp_usage and backup_codes tables
- main.py: register totp router, import new models for Alembic discovery
- requirements.txt: add pyotp>=2.9.0, qrcode[pil]>=7.4.0, cryptography>=42.0.0
- jobs/notifications.py: periodic cleanup for totp_usage (5 min) and expired user_sessions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-25 04:18:05 +08:00

31 lines
1.4 KiB
Python

from sqlalchemy import String, Integer, ForeignKey, UniqueConstraint, func
from sqlalchemy.orm import Mapped, mapped_column
from datetime import datetime
from app.database import Base
class TOTPUsage(Base):
__tablename__ = "totp_usage"
id: Mapped[int] = mapped_column(primary_key=True)
user_id: Mapped[int] = mapped_column(
ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
)
# The 6-digit code that was used
code: Mapped[str] = mapped_column(String(6), nullable=False)
# The TOTP time window in which the code was valid (floor(unix_time / 30))
window: Mapped[int] = mapped_column(Integer, nullable=False)
used_at: Mapped[datetime] = mapped_column(default=func.now())
# NOTE on replay prevention design:
# The unique constraint is on (user_id, code, window) — NOT (user_id, window).
# This allows a user to present the T-1 window code and then the T window code
# consecutively — these are different 6-digit OTPs, so both should succeed.
# Constraining only (user_id, window) would reject the second legitimate code.
# The actual attack we prevent is reusing the *same code string in the same window*,
# which is the only true replay. This is compliant with RFC 6238 §5.2.
# The 5-minute MFA token expiry provides the outer time bound on any abuse window.
__table_args__ = (
UniqueConstraint("user_id", "code", "window", name="uq_totp_user_code_window"),
)