auth_ephemeral.py 6.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199
  1. """Ephemeral authentication tokens and rate-limit events.
  2. These tables replace the module-level in-memory dicts in mfa.py, making
  3. the 2FA / OIDC flow compatible with multi-worker deployments and persistent
  4. across server restarts.
  5. Tables
  6. ------
  7. AuthEphemeralToken
  8. Short-lived, single-use tokens for:
  9. - pre_auth : issued after password check, consumed when 2FA is verified
  10. - oidc_state : CSRF nonce for the OIDC authorization-code flow
  11. - oidc_exchange : short bridge token from the OIDC callback to the SPA
  12. AuthRateLimitEvent
  13. Timestamped events used for sliding-window rate limiting:
  14. - 2fa_attempt : each failed 2FA verification attempt
  15. - email_send : each OTP email sent (prevents email flooding)
  16. """
  17. from __future__ import annotations
  18. from datetime import datetime, timezone
  19. from enum import Enum
  20. from sqlalchemy import DateTime, Integer, String
  21. from sqlalchemy.orm import Mapped, mapped_column
  22. from backend.app.core.database import Base
  23. class TokenType(str, Enum):
  24. """T3: Enumerated token types for AuthEphemeralToken.token_type.
  25. Using str-based Enum keeps the stored values human-readable and
  26. backward-compatible with existing rows.
  27. """
  28. PRE_AUTH = "pre_auth"
  29. OIDC_STATE = "oidc_state"
  30. OIDC_EXCHANGE = "oidc_exchange"
  31. PASSWORD_RESET = "password_reset"
  32. EMAIL_OTP_SETUP = "email_otp_setup"
  33. SLICER_DOWNLOAD = "slicer_download"
  34. class EventType(str, Enum):
  35. """T3: Enumerated event types for AuthRateLimitEvent.event_type.
  36. Using str-based Enum keeps the stored values human-readable and
  37. backward-compatible with existing rows.
  38. """
  39. TWO_FA_ATTEMPT = "2fa_attempt"
  40. EMAIL_SEND = "email_send"
  41. LOGIN_ATTEMPT = "login_attempt"
  42. LOGIN_IP = "login_ip"
  43. PASSWORD_RESET_SEND = "password_reset_send"
  44. PASSWORD_RESET_IP = "password_reset_ip"
  45. class AuthEphemeralToken(Base):
  46. """Single-use, time-limited token for pre-auth / OIDC flows."""
  47. __tablename__ = "auth_ephemeral_tokens"
  48. id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
  49. token: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
  50. token_type: Mapped[str] = mapped_column(String(20), nullable=False) # 'pre_auth' | 'oidc_state' | 'oidc_exchange'
  51. # pre_auth + oidc_exchange: which user this session belongs to
  52. username: Mapped[str | None] = mapped_column(String(150), nullable=True)
  53. # oidc_state: which provider initiated the flow
  54. provider_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
  55. # oidc_state: replay-protection nonce embedded in the ID token
  56. nonce: Mapped[str | None] = mapped_column(String(128), nullable=True)
  57. # oidc_state: PKCE code verifier (S256 method)
  58. code_verifier: Mapped[str | None] = mapped_column(String(128), nullable=True)
  59. # pre_auth: HttpOnly cookie value bound to this token to prevent token theft
  60. # (XSS can read JS memory but cannot read HttpOnly cookies).
  61. challenge_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
  62. expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
  63. created_at: Mapped[datetime] = mapped_column(
  64. DateTime(timezone=True),
  65. nullable=False,
  66. default=lambda: datetime.now(timezone.utc),
  67. )
  68. # ------------------------------------------------------------------
  69. # T1: Classmethod factories — enforce required fields per token type
  70. # and prevent accidentally leaving optional fields at their defaults.
  71. # ------------------------------------------------------------------
  72. @classmethod
  73. def new_pre_auth(
  74. cls,
  75. token: str,
  76. username: str,
  77. expires_at: datetime,
  78. challenge_id: str | None = None,
  79. ) -> AuthEphemeralToken:
  80. """Create a pre-auth token (issued after password check, before 2FA)."""
  81. return cls(
  82. token=token,
  83. token_type=TokenType.PRE_AUTH,
  84. username=username,
  85. expires_at=expires_at,
  86. challenge_id=challenge_id,
  87. )
  88. @classmethod
  89. def new_oidc_state(
  90. cls,
  91. token: str,
  92. provider_id: int,
  93. nonce: str,
  94. code_verifier: str,
  95. expires_at: datetime,
  96. ) -> AuthEphemeralToken:
  97. """Create an OIDC state token (CSRF protection + PKCE for authorize redirect)."""
  98. return cls(
  99. token=token,
  100. token_type=TokenType.OIDC_STATE,
  101. provider_id=provider_id,
  102. nonce=nonce,
  103. code_verifier=code_verifier,
  104. expires_at=expires_at,
  105. )
  106. @classmethod
  107. def new_oidc_exchange(
  108. cls,
  109. token: str,
  110. username: str,
  111. expires_at: datetime,
  112. ) -> AuthEphemeralToken:
  113. """Create an OIDC exchange token (bridge from callback to SPA)."""
  114. return cls(
  115. token=token,
  116. token_type=TokenType.OIDC_EXCHANGE,
  117. username=username,
  118. expires_at=expires_at,
  119. )
  120. @classmethod
  121. def new_password_reset(
  122. cls,
  123. token: str,
  124. username: str,
  125. expires_at: datetime,
  126. ) -> AuthEphemeralToken:
  127. """Create a password-reset token (single-use link emailed to the user)."""
  128. return cls(
  129. token=token,
  130. token_type=TokenType.PASSWORD_RESET,
  131. username=username,
  132. expires_at=expires_at,
  133. )
  134. @classmethod
  135. def new_email_otp_setup(
  136. cls,
  137. token: str,
  138. username: str,
  139. code_hash: str,
  140. expires_at: datetime,
  141. ) -> AuthEphemeralToken:
  142. """Create an email-OTP setup token.
  143. The ``code_hash`` is stored in the ``nonce`` column (field reuse
  144. documented inline in the enable_email_otp endpoint).
  145. """
  146. return cls(
  147. token=token,
  148. token_type=TokenType.EMAIL_OTP_SETUP,
  149. username=username,
  150. nonce=code_hash,
  151. expires_at=expires_at,
  152. )
  153. class AuthRateLimitEvent(Base):
  154. """Timestamped events used for sliding-window rate limiting."""
  155. __tablename__ = "auth_rate_limit_events"
  156. id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
  157. username: Mapped[str] = mapped_column(String(150), nullable=False, index=True)
  158. event_type: Mapped[str] = mapped_column(String(20), nullable=False) # '2fa_attempt' | 'email_send'
  159. occurred_at: Mapped[datetime] = mapped_column(
  160. DateTime(timezone=True),
  161. nullable=False,
  162. default=lambda: datetime.now(timezone.utc),
  163. )