- 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>
31 lines
1.4 KiB
Python
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"),
|
|
)
|