auth_ephemeral.py 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  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. CONNECT_CODE = "connect_code"
  35. # Written as bare strings where they are used; listed here so every kind is
  36. # known (see test_token_outlived_session).
  37. WEBSOCKET = "websocket"
  38. CAMERA_STREAM = "camera_stream"
  39. MEDIA = "media"
  40. REVOKED_JTI = "revoked_jti" # the logout list, not a credential
  41. class EventType(str, Enum):
  42. """T3: Enumerated event types for AuthRateLimitEvent.event_type.
  43. Using str-based Enum keeps the stored values human-readable and
  44. backward-compatible with existing rows.
  45. """
  46. TWO_FA_ATTEMPT = "2fa_attempt"
  47. EMAIL_SEND = "email_send"
  48. LOGIN_ATTEMPT = "login_attempt"
  49. LOGIN_IP = "login_ip"
  50. PASSWORD_RESET_SEND = "password_reset_send"
  51. PASSWORD_RESET_IP = "password_reset_ip"
  52. CONNECT_TOKEN_CLIENT = "connect_client"
  53. CONNECT_TOKEN_IP = "connect_ip"
  54. class AuthEphemeralToken(Base):
  55. """Single-use, time-limited token for pre-auth / OIDC flows."""
  56. __tablename__ = "auth_ephemeral_tokens"
  57. id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
  58. token: Mapped[str] = mapped_column(String(128), unique=True, nullable=False, index=True)
  59. token_type: Mapped[str] = mapped_column(String(20), nullable=False) # 'pre_auth' | 'oidc_state' | 'oidc_exchange'
  60. # pre_auth + oidc_exchange: which user this session belongs to
  61. username: Mapped[str | None] = mapped_column(String(150), nullable=True)
  62. # oidc_state: which provider initiated the flow
  63. provider_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
  64. # camera_stream + websocket: the API key that minted the token, when one
  65. # did, so the token carries that key's printer scope (#1727)
  66. api_key_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
  67. # oidc_state: replay-protection nonce embedded in the ID token
  68. nonce: Mapped[str | None] = mapped_column(String(128), nullable=True)
  69. # oidc_state: PKCE code verifier (S256 method)
  70. code_verifier: Mapped[str | None] = mapped_column(String(128), nullable=True)
  71. # pre_auth: HttpOnly cookie value bound to this token to prevent token theft
  72. # (XSS can read JS memory but cannot read HttpOnly cookies).
  73. challenge_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
  74. expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
  75. created_at: Mapped[datetime] = mapped_column(
  76. DateTime,
  77. nullable=False,
  78. default=lambda: datetime.now(timezone.utc),
  79. )
  80. # ------------------------------------------------------------------
  81. # T1: Classmethod factories — enforce required fields per token type
  82. # and prevent accidentally leaving optional fields at their defaults.
  83. # ------------------------------------------------------------------
  84. @classmethod
  85. def new_connect_code(
  86. cls,
  87. code_hash: str,
  88. username: str,
  89. app_id: int,
  90. code_challenge: str,
  91. expires_at: datetime,
  92. created_at: datetime | None = None,
  93. ) -> AuthEphemeralToken:
  94. """Create a connected-app authorization code.
  95. Only the SHA-256 of the code is stored (``token``), so a database read
  96. does not yield a usable code. Field reuse: ``provider_id`` holds the
  97. connected app's id and ``nonce`` the PKCE S256 challenge.
  98. """
  99. return cls(
  100. token=code_hash,
  101. token_type=TokenType.CONNECT_CODE,
  102. username=username,
  103. provider_id=app_id,
  104. nonce=code_challenge,
  105. expires_at=expires_at,
  106. # When the minting session began (core/auth.session_dated), if given
  107. **({"created_at": created_at} if created_at is not None else {}),
  108. )
  109. class AuthRateLimitEvent(Base):
  110. """Timestamped events used for sliding-window rate limiting."""
  111. __tablename__ = "auth_rate_limit_events"
  112. id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
  113. username: Mapped[str] = mapped_column(String(150), nullable=False, index=True)
  114. event_type: Mapped[str] = mapped_column(String(20), nullable=False) # '2fa_attempt' | 'email_send'
  115. occurred_at: Mapped[datetime] = mapped_column(
  116. DateTime,
  117. nullable=False,
  118. default=lambda: datetime.now(timezone.utc),
  119. )