user_otp_code.py 2.1 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455
  1. from __future__ import annotations
  2. from datetime import datetime, timezone
  3. from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func
  4. from sqlalchemy.orm import Mapped, mapped_column
  5. from backend.app.core.database import Base
  6. class UserOTPCode(Base):
  7. """Temporary email OTP (One-Time Password) code for 2FA verification.
  8. Each record represents a single sent OTP code. Codes expire after
  9. OTP_TTL_MINUTES and are invalidated after MAX_ATTEMPTS failed attempts
  10. or after successful verification.
  11. """
  12. __tablename__ = "user_otp_codes"
  13. OTP_TTL_MINUTES = 10
  14. MAX_ATTEMPTS = 5
  15. id: Mapped[int] = mapped_column(primary_key=True)
  16. user_id: Mapped[int] = mapped_column(Integer, ForeignKey("users.id", ondelete="CASCADE"), index=True)
  17. # pbkdf2_sha256 hash of the 6-digit code
  18. code_hash: Mapped[str] = mapped_column(String(255))
  19. # Number of failed verification attempts for this code
  20. attempts: Mapped[int] = mapped_column(Integer, default=0)
  21. # True once the code has been successfully used or explicitly invalidated
  22. used: Mapped[bool] = mapped_column(Boolean, default=False)
  23. expires_at: Mapped[datetime] = mapped_column(DateTime)
  24. created_at: Mapped[datetime] = mapped_column(DateTime, server_default=func.now())
  25. def consume(self) -> None:
  26. """T4: Mark this OTP as used, enforcing preconditions.
  27. Raises ``ValueError`` if the code is already used or expired so callers
  28. cannot silently re-use an invalidated code. The caller is responsible
  29. for flushing/committing the change to the DB.
  30. """
  31. now = datetime.now(timezone.utc)
  32. exp = self.expires_at
  33. if exp.tzinfo is None:
  34. from datetime import timezone as _tz
  35. exp = exp.replace(tzinfo=_tz.utc)
  36. if self.used:
  37. raise ValueError("OTP code has already been used")
  38. if exp < now:
  39. raise ValueError("OTP code has expired")
  40. self.used = True
  41. def __repr__(self) -> str:
  42. return f"<UserOTPCode user_id={self.user_id} used={self.used}>"