auth.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592
  1. import re
  2. from typing import Literal
  3. from pydantic import BaseModel, Field, field_validator, model_validator
  4. def _validate_password_complexity(v: str) -> str:
  5. """Enforce minimum password complexity (M-C).
  6. Requires at least one uppercase letter, one lowercase letter, one digit,
  7. and one special character in addition to the min_length=8 Field constraint.
  8. """
  9. if not re.search(r"[A-Z]", v):
  10. raise ValueError("Password must contain at least one uppercase letter")
  11. if not re.search(r"[a-z]", v):
  12. raise ValueError("Password must contain at least one lowercase letter")
  13. if not re.search(r"\d", v):
  14. raise ValueError("Password must contain at least one digit")
  15. if not re.search(r"[^A-Za-z0-9]", v):
  16. raise ValueError("Password must contain at least one special character")
  17. return v
  18. class GroupBrief(BaseModel):
  19. """Brief group info for embedding in user responses."""
  20. id: int
  21. name: str
  22. class Config:
  23. from_attributes = True
  24. class LoginRequest(BaseModel):
  25. username: str = Field(..., max_length=150)
  26. password: str = Field(..., max_length=256)
  27. class LoginResponse(BaseModel):
  28. access_token: str | None = None
  29. token_type: str = "bearer"
  30. user: "UserResponse | None" = None
  31. # Set when 2FA is required; the frontend must call /auth/2fa/verify
  32. requires_2fa: bool = False
  33. pre_auth_token: str | None = None
  34. two_fa_methods: list[str] = []
  35. class UserCreate(BaseModel):
  36. username: str = Field(..., max_length=150)
  37. password: str | None = Field(default=None, max_length=256) # M-NEW-4: cap before pbkdf2
  38. email: str | None = Field(default=None, max_length=254) # L-NEW-5: RFC 5321 max
  39. role: str = "user"
  40. group_ids: list[int] | None = None
  41. @field_validator("password")
  42. @classmethod
  43. def validate_password(cls, v: str | None) -> str | None:
  44. if v is not None:
  45. _validate_password_complexity(v)
  46. return v
  47. class UserUpdate(BaseModel):
  48. username: str | None = Field(default=None, max_length=150)
  49. password: str | None = Field(default=None, max_length=256) # M-NEW-4: cap before pbkdf2
  50. email: str | None = Field(default=None, max_length=254) # L-NEW-5: RFC 5321 max
  51. role: str | None = None
  52. is_active: bool | None = None
  53. group_ids: list[int] | None = None
  54. @field_validator("password")
  55. @classmethod
  56. def validate_password(cls, v: str | None) -> str | None:
  57. if v is not None:
  58. _validate_password_complexity(v)
  59. return v
  60. class UserResponse(BaseModel):
  61. id: int
  62. username: str
  63. email: str | None = None
  64. role: str # Deprecated, kept for backward compatibility
  65. is_active: bool
  66. is_admin: bool # Computed from role and group membership
  67. auth_source: str = "local" # "local" or "ldap"
  68. groups: list[GroupBrief] = []
  69. permissions: list[str] = [] # All permissions from groups
  70. created_at: str
  71. class Config:
  72. from_attributes = True
  73. class UserSlim(BaseModel):
  74. """Just enough to resolve a user id to a display name (#1894).
  75. Deliberately narrower than ``UserResponse``: no email, role, auth source,
  76. group membership or permission set. Adding a field here widens what every
  77. ``can_read_status`` API key can read about every account, so treat this
  78. shape as the contract rather than a starting point.
  79. """
  80. id: int
  81. username: str
  82. class Config:
  83. from_attributes = True
  84. class LDAPSearchResultResponse(BaseModel):
  85. """One match from GET /auth/ldap/search — surfaced in the admin UI."""
  86. username: str
  87. email: str | None = None
  88. display_name: str | None = None
  89. dn: str
  90. already_provisioned: bool = False # True if this username already exists as a BamBuddy user
  91. class LDAPProvisionRequest(BaseModel):
  92. """Body for POST /auth/ldap/provision. Username is re-resolved via the
  93. service-account bind, so the request only carries the directory username
  94. the admin picked from the search results."""
  95. username: str = Field(..., max_length=150)
  96. class ChangePasswordRequest(BaseModel):
  97. current_password: str = Field(..., max_length=256) # M-NEW-3: cap before pbkdf2
  98. new_password: str = Field(..., min_length=8, max_length=256)
  99. @field_validator("new_password")
  100. @classmethod
  101. def validate_new_password(cls, v: str) -> str:
  102. return _validate_password_complexity(v)
  103. class SetupRequest(BaseModel):
  104. auth_enabled: bool
  105. admin_username: str | None = Field(default=None, max_length=150)
  106. admin_password: str | None = Field(default=None, max_length=256)
  107. # Password complexity is NOT validated at the schema layer. When re-enabling auth
  108. # with an existing admin user (or when LDAP is the auth backend), the frontend
  109. # still sends whatever is in the password field but the route ignores it.
  110. # Enforcing complexity here would reject those legitimate flows. The route body
  111. # applies the check only when a brand-new local admin is actually being created.
  112. class SetupResponse(BaseModel):
  113. auth_enabled: bool
  114. admin_created: bool | None = None
  115. class ForgotPasswordRequest(BaseModel):
  116. email: str = Field(..., max_length=254) # L-NEW-1: RFC 5321 max; caps memory/CPU before lookup
  117. class ForgotPasswordConfirmRequest(BaseModel):
  118. token: str = Field(..., max_length=128)
  119. new_password: str = Field(..., min_length=8, max_length=256)
  120. @field_validator("new_password")
  121. @classmethod
  122. def validate_new_password(cls, v: str) -> str:
  123. return _validate_password_complexity(v)
  124. class ForgotPasswordResponse(BaseModel):
  125. message: str
  126. class ResetPasswordRequest(BaseModel):
  127. user_id: int
  128. class ResetPasswordResponse(BaseModel):
  129. message: str
  130. class SMTPSettings(BaseModel):
  131. smtp_host: str
  132. smtp_port: int
  133. smtp_username: str | None = None # Optional when auth is disabled
  134. smtp_password: str | None = None # Optional for read operations or when auth is disabled
  135. smtp_security: str = "starttls" # 'starttls', 'ssl', 'none'
  136. smtp_auth_enabled: bool = True
  137. smtp_from_email: str
  138. smtp_from_name: str = "BamBuddy"
  139. # Deprecated field for backward compatibility
  140. smtp_use_tls: bool | None = None
  141. class TestSMTPRequest(BaseModel):
  142. test_recipient: str
  143. class TestSMTPResponse(BaseModel):
  144. success: bool
  145. message: str
  146. # ---------------------------------------------------------------------------
  147. # 2FA / MFA schemas
  148. # ---------------------------------------------------------------------------
  149. class TwoFAStatusResponse(BaseModel):
  150. totp_enabled: bool
  151. email_otp_enabled: bool
  152. backup_codes_remaining: int
  153. class TOTPSetupResponse(BaseModel):
  154. """Returned when a user initiates TOTP setup. The frontend should display
  155. the QR code image (base64 PNG) and ask the user to scan it, then call
  156. /auth/2fa/totp/enable with a valid code to confirm."""
  157. secret: str # base32 secret (shown as fallback text)
  158. qr_code_b64: str # base64-encoded PNG of the QR code
  159. issuer: str
  160. class TOTPSetupRequest(BaseModel):
  161. """Optional body for POST /auth/2fa/totp/setup.
  162. Only required when re-initialising setup while an active TOTP record exists.
  163. Provide the current TOTP code (from the existing authenticator app) to
  164. confirm intent — mirrors the verification requirement in disable_totp.
  165. """
  166. code: str | None = Field(default=None, max_length=8) # L-NEW-2: bound before pyotp
  167. class TOTPEnableRequest(BaseModel):
  168. code: str # 6-digit TOTP code from the authenticator app
  169. @field_validator("code")
  170. @classmethod
  171. def validate_code(cls, v: str) -> str:
  172. v = v.strip()
  173. if not v.isdigit() or len(v) != 6:
  174. raise ValueError("TOTP code must be exactly 6 digits")
  175. return v
  176. class TOTPEnableResponse(BaseModel):
  177. message: str
  178. backup_codes: list[str] # plain-text codes shown once; user must save them
  179. class TOTPDisableRequest(BaseModel):
  180. """Requires a valid TOTP code OR a backup code to disable TOTP."""
  181. code: str = Field(..., max_length=128)
  182. class BackupCodesResponse(BaseModel):
  183. backup_codes: list[str]
  184. message: str
  185. class EmailOTPEnableRequest(BaseModel):
  186. """No body required — email is taken from the authenticated user's profile."""
  187. pass
  188. class TwoFAVerifyRequest(BaseModel):
  189. pre_auth_token: str = Field(..., max_length=128)
  190. # TOTP/email codes are 6 digits; backup codes are 8 uppercase alphanumeric chars.
  191. # max_length=8 prevents excessively long inputs from reaching pbkdf2/pyotp.
  192. code: str = Field(..., min_length=6, max_length=8)
  193. method: Literal["totp", "email", "backup"] = "totp"
  194. @field_validator("code")
  195. @classmethod
  196. def validate_code_format(cls, v: str) -> str:
  197. v = v.strip()
  198. if not re.match(r"^[A-Za-z0-9]{6,8}$", v):
  199. raise ValueError("Code must be 6–8 alphanumeric characters")
  200. return v.upper() # normalise backup codes to uppercase
  201. class TwoFAVerifyResponse(BaseModel):
  202. access_token: str
  203. token_type: str = "bearer"
  204. user: "UserResponse"
  205. class EmailOTPSendRequest(BaseModel):
  206. pre_auth_token: str = Field(..., max_length=128)
  207. class EmailOTPEnableConfirmRequest(BaseModel):
  208. """Body for the second step of email OTP enable: verify the proof-of-possession code."""
  209. setup_token: str = Field(..., max_length=128)
  210. # L-NEW-3: email OTP setup codes are always exactly 6 digits; reject anything else.
  211. code: str = Field(..., min_length=6, max_length=6)
  212. @field_validator("code")
  213. @classmethod
  214. def validate_code_digits(cls, v: str) -> str:
  215. v = v.strip()
  216. if not v.isdigit() or len(v) != 6:
  217. raise ValueError("Email OTP setup code must be exactly 6 digits")
  218. return v
  219. class EmailOTPDisableRequest(BaseModel):
  220. """Requires the account password to disable email OTP."""
  221. password: str = Field(..., max_length=256)
  222. class AdminDisable2FARequest(BaseModel):
  223. """Admin must supply their own password as re-auth before disabling 2FA for another user.
  224. OIDC/LDAP-only admins (no local password_hash) are exempt from this check.
  225. """
  226. admin_password: str | None = Field(default=None, max_length=256)
  227. # ---------------------------------------------------------------------------
  228. # OIDC schemas
  229. # ---------------------------------------------------------------------------
  230. AUTO_LINK_REQUIREMENTS_ERROR = (
  231. "auto_link_existing_accounts requires require_email_verified=True when email_claim='email'"
  232. )
  233. def _validate_email_claim_name(v: str) -> str:
  234. # Accepts only alphanumeric/underscore/hyphen claim names starting with a letter —
  235. # prevents log injection and limits the attack surface of operator-supplied claim names.
  236. if not re.fullmatch(r"[a-zA-Z][a-zA-Z0-9_\-]{0,63}", v):
  237. raise ValueError("Invalid claim name")
  238. return v
  239. def _validate_icon_url(v: str | None) -> str | None:
  240. """Reject non-HTTPS icon URLs and SSRF-unsafe hosts.
  241. Delegates to the runtime SSRF guard ``assert_safe_public_https_url``
  242. so the Pydantic layer enforces the same allowlist as the fetcher —
  243. no policy drift between schema validation and SSRF check. Without
  244. this delegation the validator covered only ``is_private | is_loopback
  245. | is_link_local`` while the runtime additionally rejected numeric-
  246. encoded IPs, cloud-metadata endpoints, multicast, unspecified, and
  247. IPv4-mapped IPv6.
  248. Lazy-imported because ``_oidc_helpers`` lives under ``api/routes/``
  249. and schemas avoid top-level imports from that layer (matches the
  250. existing pattern in ``_validate_issuer_url`` which lazy-imports
  251. ``ipaddress``).
  252. """
  253. if v is None:
  254. return v
  255. if not v.startswith("https://"):
  256. # Surface the same wording the runtime guard would use, but pre-
  257. # checked here so the user-facing error doesn't depend on the
  258. # runtime call path.
  259. raise ValueError("icon_url must start with https://")
  260. from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
  261. try:
  262. assert_safe_public_https_url(v)
  263. except ValueError as exc:
  264. raise ValueError(f"icon_url: {exc}") from exc
  265. return v
  266. def _validate_issuer_url(v: str | None) -> str | None:
  267. """Reject non-HTTPS issuer URLs and SSRF-unsafe hosts.
  268. An OIDC provider must be reachable over TLS on the public internet, so
  269. this uses the public-internet policy: private, loopback and link-local
  270. addresses are all rejected.
  271. Delegates to the runtime guard ``assert_safe_public_https_url`` for the
  272. same reason ``_validate_icon_url`` does — no policy drift between the
  273. schema layer and the fetcher. The hand-rolled version this replaced
  274. checked only ``is_private | is_loopback | is_link_local``, which left
  275. numeric-encoded IPs (``https://2130706433/``), IPv4-mapped IPv6
  276. (``https://[::ffff:127.0.0.1]/``), multicast and unspecified addresses
  277. able to express a target the policy meant to forbid. The guard's
  278. docstring already claimed the two were consistent; now they are.
  279. Lazy-imported because ``_oidc_helpers`` lives under ``api/routes/`` and
  280. schemas avoid top-level imports from that layer.
  281. """
  282. if v is None:
  283. return v
  284. if not v.startswith("https://"):
  285. raise ValueError("issuer_url must start with https://")
  286. from backend.app.api.routes._oidc_helpers import assert_safe_public_https_url
  287. try:
  288. assert_safe_public_https_url(v)
  289. except ValueError as exc:
  290. # The guard's messages say "icon URL" — rewrite for this field so the
  291. # user sees the setting they actually submitted.
  292. detail = str(exc).replace("icon URL", "issuer_url")
  293. raise ValueError(detail) from exc
  294. return v
  295. def _validate_scopes(v: str | None) -> str | None:
  296. """Nit5: Require that the 'openid' scope is present.
  297. The OpenID Connect spec mandates the 'openid' scope; without it the
  298. response is plain OAuth2, not OIDC, and claims like sub/email are not
  299. guaranteed.
  300. """
  301. if v is None:
  302. return v
  303. scope_list = v.split()
  304. if "openid" not in scope_list:
  305. raise ValueError("scopes must include 'openid'")
  306. return v
  307. class OIDCProviderCreate(BaseModel):
  308. name: str = Field(..., max_length=100) # L-NEW-4
  309. issuer_url: str
  310. client_id: str = Field(..., max_length=256) # L-NEW-4
  311. client_secret: str = Field(..., max_length=512) # L-NEW-4: Fernet input bounded
  312. scopes: str = Field(default="openid email profile", max_length=256) # L-NEW-4
  313. is_enabled: bool = True
  314. auto_create_users: bool = False
  315. auto_link_existing_accounts: bool = False # M-2: conservative default, opt-in only
  316. email_claim: str = Field(default="email", max_length=64)
  317. require_email_verified: bool = True
  318. icon_url: str | None = None
  319. default_group_id: int | None = None
  320. is_autologin: bool = False # #1589 — at most one provider may carry this
  321. @field_validator("issuer_url")
  322. @classmethod
  323. def validate_issuer_url(cls, v: str) -> str:
  324. result = _validate_issuer_url(v)
  325. if result is None:
  326. raise ValueError("issuer_url is required")
  327. return result
  328. @field_validator("scopes")
  329. @classmethod
  330. def validate_scopes(cls, v: str) -> str:
  331. result = _validate_scopes(v)
  332. if result is None:
  333. raise ValueError("scopes is required")
  334. return result
  335. @field_validator("email_claim")
  336. @classmethod
  337. def validate_email_claim(cls, v: str) -> str:
  338. return _validate_email_claim_name(v)
  339. @field_validator("icon_url")
  340. @classmethod
  341. def validate_icon_url(cls, v: str | None) -> str | None:
  342. return _validate_icon_url(v)
  343. # SEC-1: auto_link with email_claim='email' requires require_email_verified=True.
  344. # Fall B (require_email_verified=False + email_claim='email') accepts absent email_verified → account-takeover risk.
  345. # Fall C (custom claim != 'email') is safe: no email_verified gate on that path regardless of require_email_verified.
  346. @model_validator(mode="after")
  347. def check_auto_link_requires_verified(self) -> "OIDCProviderCreate":
  348. if self.auto_link_existing_accounts and self.email_claim == "email" and not self.require_email_verified:
  349. raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
  350. return self
  351. class OIDCProviderUpdate(BaseModel):
  352. name: str | None = Field(default=None, max_length=100)
  353. issuer_url: str | None = None
  354. @field_validator("issuer_url")
  355. @classmethod
  356. def validate_issuer_url(cls, v: str | None) -> str | None:
  357. return _validate_issuer_url(v)
  358. client_id: str | None = Field(default=None, max_length=256)
  359. client_secret: str | None = Field(default=None, max_length=512)
  360. scopes: str | None = Field(default=None, max_length=256)
  361. is_enabled: bool | None = None
  362. auto_create_users: bool | None = None
  363. auto_link_existing_accounts: bool | None = None
  364. email_claim: str | None = Field(default=None, max_length=64)
  365. require_email_verified: bool | None = None
  366. icon_url: str | None = None
  367. default_group_id: int | None = None
  368. is_autologin: bool | None = None # #1589
  369. @field_validator("scopes")
  370. @classmethod
  371. def validate_scopes(cls, v: str | None) -> str | None:
  372. return _validate_scopes(v)
  373. @field_validator("email_claim")
  374. @classmethod
  375. def validate_email_claim(cls, v: str | None) -> str | None:
  376. if v is None:
  377. return None
  378. return _validate_email_claim_name(v)
  379. @field_validator("icon_url")
  380. @classmethod
  381. def validate_icon_url(cls, v: str | None) -> str | None:
  382. return _validate_icon_url(v)
  383. # SEC-1 (schema-level): blocks only when auto_link=True + email_claim='email' + require_email_verified=False
  384. # arrive in the same request. email_claim=None means the request leaves it unchanged (still 'email' by default),
  385. # so that is also treated as 'email'. Partial updates spanning two requests are caught by the
  386. # Combined-State-Guard in the route handler after the setattr loop.
  387. @model_validator(mode="after")
  388. def check_auto_link_requires_verified(self) -> "OIDCProviderUpdate":
  389. if (
  390. self.auto_link_existing_accounts is True
  391. and self.require_email_verified is False
  392. and (self.email_claim is None or self.email_claim == "email")
  393. ):
  394. raise ValueError(AUTO_LINK_REQUIREMENTS_ERROR)
  395. return self
  396. class OIDCProviderResponse(BaseModel):
  397. id: int
  398. name: str
  399. issuer_url: str
  400. client_id: str
  401. scopes: str
  402. is_enabled: bool
  403. auto_create_users: bool
  404. auto_link_existing_accounts: bool = False
  405. email_claim: str = "email"
  406. require_email_verified: bool = True
  407. icon_url: str | None = None
  408. default_group_id: int | None = None
  409. is_autologin: bool = False # #1589
  410. # #2593 — the UI renders this provider read-only; without the flag it would
  411. # offer editable fields whose writes the API then refuses with 409.
  412. is_env_managed: bool = False
  413. # Set explicitly in the route handler from `icon_content_type is not None`
  414. # rather than `@computed_field` (project policy) or `icon_data is not None`
  415. # (would trigger an async lazy-load on the deferred BLOB column).
  416. # Required (no default) so Pydantic fails loudly if any code path skips
  417. # `_build_provider_response` and tries `model_validate(provider)` directly.
  418. has_icon: bool
  419. class Config:
  420. from_attributes = True
  421. class OIDCAuthorizeResponse(BaseModel):
  422. auth_url: str
  423. class OIDCExchangeRequest(BaseModel):
  424. oidc_token: str = Field(..., max_length=128)
  425. class OIDCLinkResponse(BaseModel):
  426. id: int
  427. provider_id: int
  428. provider_name: str
  429. provider_email: str | None = None
  430. created_at: str
  431. class EncryptionRowCounts(BaseModel):
  432. oidc_providers: int
  433. user_totp: int
  434. class EncryptionStatusResponse(BaseModel):
  435. key_configured: bool
  436. key_source: Literal["env", "file", "generated", "none"]
  437. legacy_plaintext_rows: EncryptionRowCounts
  438. encrypted_rows: EncryptionRowCounts
  439. # B4: filled by the endpoint after a sample-decrypt of one encrypted row,
  440. # so a wrong-key state (where key_configured=True but rows decrypt to junk)
  441. # is detected, not just the no-key case.
  442. decryption_broken: bool = False
  443. # B2: number of rows skipped during the last legacy re-encryption migration.
  444. # Filled from backend.app.core.database.get_migration_error_count().
  445. migration_error_count: int = 0