auth.py 87 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033
  1. import logging
  2. import os
  3. import secrets
  4. from datetime import datetime, timedelta, timezone
  5. from typing import Annotated
  6. import jwt as _jwt
  7. from fastapi import APIRouter, BackgroundTasks, Depends, Header, HTTPException, Request, Response, status
  8. from fastapi.security import HTTPAuthorizationCredentials
  9. from jwt.exceptions import PyJWTError
  10. from sqlalchemy import delete, or_, select
  11. from sqlalchemy.exc import SQLAlchemyError
  12. from sqlalchemy.ext.asyncio import AsyncSession
  13. from sqlalchemy.orm import selectinload
  14. from backend.app.api.routes.settings import get_external_login_url
  15. from backend.app.core.auth import (
  16. ALGORITHM,
  17. SECRET_KEY,
  18. Permission,
  19. RequirePermissionIfAuthEnabled,
  20. _is_token_fresh,
  21. _validate_api_key,
  22. apikey_effective_permissions,
  23. authenticate_user,
  24. authenticate_user_by_email,
  25. create_access_token,
  26. create_media_token,
  27. create_websocket_token,
  28. current_api_key_if_present,
  29. get_current_active_user,
  30. get_password_hash,
  31. get_user_by_email,
  32. get_user_by_username,
  33. is_jti_revoked,
  34. require_auth_if_enabled,
  35. resolve_apikey_owner,
  36. resolve_session_max_minutes,
  37. revoke_jti,
  38. security,
  39. token_outlived_its_session,
  40. )
  41. from backend.app.core.database import async_session, get_db
  42. from backend.app.core.oidc_env import env_bool
  43. from backend.app.models.api_key import APIKey
  44. from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
  45. from backend.app.models.group import Group
  46. from backend.app.models.settings import Settings
  47. from backend.app.models.user import User
  48. from backend.app.schemas.auth import (
  49. EncryptionRowCounts,
  50. EncryptionStatusResponse,
  51. ForgotPasswordConfirmRequest,
  52. ForgotPasswordRequest,
  53. ForgotPasswordResponse,
  54. GroupBrief,
  55. LDAPProvisionRequest,
  56. LDAPSearchResultResponse,
  57. LoginRequest,
  58. LoginResponse,
  59. ResetPasswordRequest,
  60. ResetPasswordResponse,
  61. SetupRequest,
  62. SetupResponse,
  63. SMTPSettings,
  64. TestSMTPRequest,
  65. TestSMTPResponse,
  66. UserResponse,
  67. _validate_password_complexity,
  68. )
  69. from backend.app.services.email_service import (
  70. create_password_reset_link_email_from_template,
  71. get_smtp_settings,
  72. save_smtp_settings,
  73. send_email,
  74. )
  75. from backend.app.services.finance_defaults import ensure_user_finance_defaults
  76. _logger = logging.getLogger(__name__)
  77. def _user_to_response(user: User) -> UserResponse:
  78. """Convert a User model to UserResponse schema."""
  79. return UserResponse(
  80. id=user.id,
  81. username=user.username,
  82. email=user.email,
  83. role=user.role,
  84. is_active=user.is_active,
  85. is_admin=user.is_admin,
  86. auth_source=getattr(user, "auth_source", "local"),
  87. groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
  88. permissions=sorted(user.get_permissions()),
  89. created_at=user.created_at.isoformat(),
  90. )
  91. async def _api_key_to_user_response(db: AsyncSession, api_key) -> UserResponse:
  92. """Describe a valid API key as the identity it actually carries (#1894).
  93. Until 0.2.5 this returned a synthetic admin: ``id=0``, ``role="admin"``,
  94. ``is_admin=True`` and every permission in the enum. That was wrong in both
  95. directions. A key cannot perform administrative operations at all --
  96. ``_check_apikey_permissions`` denies every permission that is not in the
  97. scope allowlist -- so a client that builds its UI from this response (which
  98. is exactly what a native client does) rendered admin actions that 403 on
  99. use, and had no way to learn the id its own prints are filed under.
  100. Now: identity comes from the key's owner, and ``permissions`` is the set the
  101. key can genuinely exercise. ``is_admin`` is always False because no key can
  102. reach an administrative route regardless of who owns it.
  103. Legacy keys predating per-user ownership (``user_id IS NULL``) have no
  104. identity to report, so they keep ``id=0`` and the ``api-key:`` username --
  105. but they stop claiming admin. ``created_at`` describes the credential in
  106. both branches, unchanged.
  107. """
  108. # Same resolution the permission gate uses, so what is reported here and
  109. # what is enforced there cannot drift -- including the 403 when the owner
  110. # has been deactivated, which makes the key dead rather than anonymous.
  111. owner = await resolve_apikey_owner(db, api_key)
  112. return UserResponse(
  113. id=owner.id if owner else 0,
  114. username=owner.username if owner else f"api-key:{api_key.key_prefix}",
  115. # Withheld on purpose: the owner's email is not needed to resolve
  116. # identity, and this response is reachable by anyone holding the key.
  117. email=None,
  118. # Deprecated free-text field; "user" is the existing value meaning
  119. # "not an admin". Inventing an "api_key" role here would put a third
  120. # value into a field callers compare against string literals.
  121. role="user",
  122. is_active=True,
  123. is_admin=False,
  124. auth_source=getattr(owner, "auth_source", "local") if owner else "local",
  125. # The key is not a group member -- listing the owner's groups would
  126. # imply capabilities the key does not inherit.
  127. groups=[],
  128. permissions=apikey_effective_permissions(api_key, owner),
  129. created_at=api_key.created_at.isoformat(),
  130. )
  131. # ---------------------------------------------------------------------------
  132. # M-R9-A: Real client IP resolution for rate limiting behind reverse proxies.
  133. # Set TRUSTED_PROXY_IPS (comma-separated) to enable X-Forwarded-For trust.
  134. # Without this env var client.host is used directly (safe default).
  135. # ---------------------------------------------------------------------------
  136. _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
  137. ip.strip() for ip in os.environ.get("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()
  138. )
  139. # #1589: read at call time, not import time, so tests can monkeypatch os.environ
  140. # between cases without re-importing the module.
  141. def _local_login_env_bypass() -> bool:
  142. """Return True when ``BAMBUDDY_LOCAL_LOGIN`` env var is set truthy.
  143. Bypasses the ``local_login_enabled`` DB setting on the local-credentials
  144. code path AND the forgot-password endpoint so a server admin can recover
  145. an install whose SSO provider is unreachable. Accepted truthy values:
  146. ``true``, ``1``, ``yes`` (case-insensitive).
  147. """
  148. # strict=False: this runs on the login/forgot-password request path, not at
  149. # startup. An unrecognized value must fall back to "off" (the safe default),
  150. # never raise -- a 500 on the recovery endpoint is the opposite of what this
  151. # bypass is for.
  152. return env_bool("BAMBUDDY_LOCAL_LOGIN", False, strict=False)
  153. def _get_client_ip(request: Request) -> str:
  154. """Return the real client IP for rate-limiting purposes.
  155. When TRUSTED_PROXY_IPS is configured and the direct TCP peer is a trusted
  156. proxy, X-Forwarded-For is evaluated right-to-left: the rightmost IP that is
  157. NOT itself a trusted proxy is the true client address (M-R10-A fix).
  158. Standard nginx with proxy_add_x_forwarded_for *appends* the client IP, so
  159. the rightmost entry is always the one added by the last trusted proxy —
  160. i.e. the real client. Walking right-to-left and skipping known proxies is
  161. safe for multi-hop chains as well.
  162. Falls back to request.client.host when TRUSTED_PROXY_IPS is unset (direct
  163. deployment without a reverse proxy).
  164. """
  165. # I5: Use a per-request unique token instead of "unknown" when the transport
  166. # layer provides no client address. This prevents all such requests from
  167. # sharing one rate-limit bucket, and avoids collision with a literal username
  168. # "unknown". The token is not stable across requests, which is intentional:
  169. # we cannot track the IP so we also cannot rate-limit by it meaningfully.
  170. direct_ip = request.client.host if request.client else f"__no_ip_{secrets.token_hex(8)}__"
  171. if _TRUSTED_PROXY_IPS and direct_ip in _TRUSTED_PROXY_IPS:
  172. forwarded_for = request.headers.get("X-Forwarded-For", "")
  173. ips = [ip.strip() for ip in forwarded_for.split(",") if ip.strip()]
  174. # Walk right-to-left; skip IPs that belong to trusted proxies.
  175. for ip in reversed(ips):
  176. if ip not in _TRUSTED_PROXY_IPS:
  177. return ip
  178. # Edge case: every entry is a trusted proxy — fall back to leftmost.
  179. if ips:
  180. return ips[0]
  181. return direct_ip
  182. router = APIRouter(prefix="/auth", tags=["authentication"])
  183. async def is_auth_enabled(db: AsyncSession) -> bool:
  184. """Check if authentication is enabled."""
  185. result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
  186. setting = result.scalar_one_or_none()
  187. if setting is None:
  188. return False
  189. return setting.value.lower() == "true"
  190. async def is_advanced_auth_enabled(db: AsyncSession) -> bool:
  191. """Check if advanced authentication is enabled."""
  192. result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
  193. setting = result.scalar_one_or_none()
  194. if setting is None:
  195. return False
  196. return setting.value.lower() == "true"
  197. async def set_advanced_auth_enabled(db: AsyncSession, enabled: bool) -> None:
  198. """Set advanced authentication enabled status."""
  199. from backend.app.core.db_dialect import upsert_setting
  200. await upsert_setting(db, Settings, "advanced_auth_enabled", "true" if enabled else "false")
  201. async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
  202. """Set authentication enabled status."""
  203. from backend.app.core.auth import invalidate_auth_enabled_cache
  204. from backend.app.core.db_dialect import upsert_setting
  205. await upsert_setting(db, Settings, "auth_enabled", "true" if enabled else "false")
  206. # Drop the cached auth-enabled flag so the change takes effect immediately
  207. # instead of after the TTL (issue #2572). Safe pre-commit: only enabled=True
  208. # is ever cached, and the newly-enabled True isn't visible to other sessions
  209. # until this transaction commits, so no stale value can be re-cached here.
  210. invalidate_auth_enabled_cache()
  211. # Note: Don't commit here - let get_db handle it or commit explicitly in the route
  212. async def is_setup_completed(db: AsyncSession) -> bool:
  213. """Check if setup has been completed."""
  214. result = await db.execute(select(Settings).where(Settings.key == "setup_completed"))
  215. setting = result.scalar_one_or_none()
  216. return setting and setting.value.lower() == "true"
  217. async def set_setup_completed(db: AsyncSession, completed: bool) -> None:
  218. """Set setup completed status."""
  219. from backend.app.core.db_dialect import upsert_setting
  220. await upsert_setting(db, Settings, "setup_completed", "true" if completed else "false")
  221. # Note: Don't commit here - let get_db handle it or commit explicitly in the route
  222. @router.post("/setup", response_model=SetupResponse)
  223. async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
  224. """First-time setup: enable/disable authentication and create admin user."""
  225. import logging
  226. logger = logging.getLogger(__name__)
  227. try:
  228. # If auth is currently enabled, block unauthenticated setup changes.
  229. # Use the admin panel (/disable endpoint) to modify auth when it's already on.
  230. if await is_auth_enabled(db):
  231. raise HTTPException(
  232. status_code=status.HTTP_403_FORBIDDEN,
  233. detail="Authentication is already configured. Use the admin panel to modify auth settings.",
  234. )
  235. admin_created = False
  236. if request.auth_enabled:
  237. # Check if admin users already exist
  238. admin_users_result = await db.execute(select(User).where(User.role == "admin"))
  239. existing_admin_users = list(admin_users_result.scalars().all())
  240. has_admin_users = len(existing_admin_users) > 0
  241. if has_admin_users:
  242. # Admin users already exist, just enable auth (don't create new admin)
  243. logger.info(
  244. f"Admin users already exist ({len(existing_admin_users)} found), enabling authentication without creating new admin"
  245. )
  246. admin_created = False
  247. else:
  248. # No admin users exist, require admin credentials to create first admin
  249. if not request.admin_username or not request.admin_password:
  250. raise HTTPException(
  251. status_code=status.HTTP_400_BAD_REQUEST,
  252. detail="Admin username and password are required when enabling authentication (no admin users exist)",
  253. )
  254. # Enforce password complexity only when actually creating a new admin.
  255. # Schema-level validation was removed so that re-enabling auth with an
  256. # existing admin (or LDAP) doesn't reject whatever placeholder the form sends.
  257. try:
  258. _validate_password_complexity(request.admin_password)
  259. except ValueError as exc:
  260. raise HTTPException(
  261. status_code=status.HTTP_400_BAD_REQUEST,
  262. detail=str(exc),
  263. )
  264. # Check if username already exists (shouldn't happen if no admin users exist, but check anyway)
  265. existing_user = await get_user_by_username(db, request.admin_username)
  266. if existing_user:
  267. raise HTTPException(
  268. status_code=status.HTTP_400_BAD_REQUEST,
  269. detail="User with this username already exists",
  270. )
  271. # Create admin user FIRST (before enabling auth)
  272. try:
  273. logger.info("Creating admin user: %s", request.admin_username)
  274. admin_user = User(
  275. username=request.admin_username,
  276. password_hash=get_password_hash(request.admin_password),
  277. role="admin",
  278. is_active=True,
  279. )
  280. # Try to add user to Administrators group if it exists
  281. admin_group_result = await db.execute(select(Group).where(Group.name == "Administrators"))
  282. admin_group = admin_group_result.scalar_one_or_none()
  283. if admin_group:
  284. admin_user.groups.append(admin_group)
  285. logger.info("Added new admin user to Administrators group")
  286. db.add(admin_user)
  287. logger.info("Admin user added to session: %s", request.admin_username)
  288. admin_created = True
  289. except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); no user is created on error
  290. await db.rollback()
  291. logger.error("Failed to create admin user: %s", e, exc_info=True)
  292. raise HTTPException(
  293. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  294. detail="Failed to create admin user",
  295. )
  296. if request.auth_enabled:
  297. # Enabling auth flips cloud-credential storage from the global
  298. # Settings rows to User.cloud_token. Carry any token linked while
  299. # auth was off across to the owning admin, or /cloud/* silently
  300. # degrades to local presets with no indication anything broke
  301. # (#2530). Only migrate when there is exactly one obvious owner:
  302. # handing another admin's session a Bambu credential is not a
  303. # guess worth making.
  304. from backend.app.api.routes.cloud import migrate_global_cloud_token_to_user
  305. from backend.app.services.bambu_cloud_credentials import get_stored_token
  306. if admin_created:
  307. cloud_owner = admin_user
  308. elif len(existing_admin_users) == 1:
  309. cloud_owner = existing_admin_users[0]
  310. else:
  311. cloud_owner = None
  312. if cloud_owner is not None:
  313. if await migrate_global_cloud_token_to_user(db, cloud_owner):
  314. logger.info("Migrated global Bambu Cloud credentials to admin '%s'", cloud_owner.username)
  315. else:
  316. global_token, _, _ = await get_stored_token(db, None)
  317. if global_token:
  318. logger.warning(
  319. "A Bambu Cloud account is linked globally but %s admins exist; "
  320. "leaving it unassigned. Re-link the account from Settings after login.",
  321. len(existing_admin_users),
  322. )
  323. # Set auth enabled and mark setup as completed
  324. await set_auth_enabled(db, request.auth_enabled)
  325. await set_setup_completed(db, True)
  326. if request.auth_enabled:
  327. # Tokens minted while it was off name nobody (every tab mints a
  328. # WebSocket one): they would reconnect the dashboards closed below.
  329. await db.execute(
  330. delete(AuthEphemeralToken).where(
  331. or_(AuthEphemeralToken.username.is_(None), AuthEphemeralToken.username == ""),
  332. AuthEphemeralToken.api_key_id.is_(None),
  333. AuthEphemeralToken.token_type != "revoked_jti",
  334. )
  335. )
  336. await db.commit()
  337. if request.auth_enabled:
  338. # Dashboards opened while it was off have every printer and would
  339. # keep them. Closed, such a tab re-checks auth and goes to the login.
  340. from backend.app.core.websocket import ws_manager
  341. await ws_manager.close_anonymous()
  342. if admin_created:
  343. await db.refresh(admin_user)
  344. logger.info("Admin user created successfully: %s", admin_user.id)
  345. logger.info("Setup completed: auth_enabled=%s, admin_created=%s", request.auth_enabled, admin_created)
  346. return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created)
  347. except HTTPException:
  348. raise
  349. except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); setup state stays unchanged
  350. logger.error("Setup error: %s", e, exc_info=True)
  351. await db.rollback()
  352. raise HTTPException(
  353. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  354. detail="Setup failed",
  355. )
  356. @router.get("/status")
  357. async def get_auth_status(db: AsyncSession = Depends(get_db)):
  358. """Get authentication status (public endpoint)."""
  359. auth_enabled = await is_auth_enabled(db)
  360. setup_completed = await is_setup_completed(db)
  361. # Only require setup if it hasn't been completed yet
  362. requires_setup = not setup_completed
  363. return {"auth_enabled": auth_enabled, "requires_setup": requires_setup}
  364. @router.post("/disable", response_model=dict)
  365. async def disable_auth(
  366. current_user: User = Depends(get_current_active_user),
  367. db: AsyncSession = Depends(get_db),
  368. ):
  369. """Disable authentication (admin only)."""
  370. import logging
  371. logger = logging.getLogger(__name__)
  372. # Reload user with groups for proper is_admin check
  373. result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  374. user = result.scalar_one()
  375. # Only admins can disable authentication
  376. if not user.is_admin:
  377. raise HTTPException(
  378. status_code=status.HTTP_403_FORBIDDEN,
  379. detail="Only admins can disable authentication",
  380. )
  381. try:
  382. # Mirror of the migration in setup_auth: with auth off the cloud routes
  383. # read the global Settings rows and never look at User.cloud_token, so
  384. # hand this admin's credential over rather than stranding it (#2530).
  385. from backend.app.api.routes.cloud import migrate_user_cloud_token_to_global
  386. if await migrate_user_cloud_token_to_global(db, user):
  387. logger.info("Migrated Bambu Cloud credentials from admin '%s' to global storage", user.username)
  388. await set_auth_enabled(db, False)
  389. await db.commit()
  390. logger.info("Authentication disabled by admin user: %s", user.username)
  391. return {"message": "Authentication disabled successfully", "auth_enabled": False}
  392. except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); auth_enabled stays at its prior value
  393. await db.rollback()
  394. logger.error("Failed to disable authentication: %s", e, exc_info=True)
  395. raise HTTPException(
  396. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  397. detail="Failed to disable authentication",
  398. )
  399. @router.post("/login", response_model=LoginResponse)
  400. async def login(raw_request: Request, request: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
  401. """Login and get access token.
  402. Supports username or email-based login. Username lookup is case-insensitive.
  403. When 2FA is enabled for the user the response contains ``requires_2fa=True``
  404. and a short-lived ``pre_auth_token`` instead of the final JWT. The client
  405. must then call ``POST /auth/2fa/verify`` (or first ``POST /auth/2fa/email/send``
  406. to trigger an email OTP) to obtain the real access token.
  407. """
  408. # Check if auth is enabled
  409. auth_enabled = await is_auth_enabled(db)
  410. if not auth_enabled:
  411. raise HTTPException(
  412. status_code=status.HTTP_400_BAD_REQUEST,
  413. detail="Authentication is not enabled",
  414. )
  415. # Rate-limit repeated login failures — two independent buckets (M-R5-B / M-R6-A):
  416. # 1. Per-username (10/15 min): prevents password brute-force on a known account.
  417. # 2. Per-IP (20/15 min): prevents an attacker from locking out arbitrary accounts
  418. # (DoS) by sending failures for many usernames from a single address.
  419. from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS, check_rate_limit, record_failed_attempt
  420. await check_rate_limit(db, request.username, event_type=EventType.LOGIN_ATTEMPT, max_attempts=MAX_LOGIN_ATTEMPTS)
  421. client_ip = _get_client_ip(raw_request)
  422. await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20)
  423. # Initialize `user` up front so every downstream branch can read/write
  424. # it without UnboundLocalError. The LDAP success path sets it inside its
  425. # own block; the local-credentials and email-credentials paths set it
  426. # below. The original code relied on the local-credentials path running
  427. # unconditionally to bind `user`; #1589 made that path skippable, so the
  428. # init has to live here.
  429. user = None
  430. # Check if LDAP is enabled
  431. ldap_user = None
  432. ldap_user_provisioned = False
  433. ldap_settings = await _get_ldap_settings(db)
  434. if ldap_settings:
  435. try:
  436. from backend.app.services.ldap_service import (
  437. authenticate_ldap_user,
  438. parse_ldap_config,
  439. )
  440. ldap_config = parse_ldap_config(ldap_settings)
  441. if ldap_config:
  442. ldap_user = authenticate_ldap_user(ldap_config, request.username, request.password)
  443. if ldap_user:
  444. # LDAP auth succeeded — find or create local user
  445. user = await get_user_by_username(db, ldap_user.username)
  446. if user and user.auth_source != "ldap":
  447. # Username exists as local user — don't override
  448. user = None
  449. ldap_user = None
  450. elif not user:
  451. if not ldap_config.auto_provision:
  452. # User doesn't exist and auto-provision is off
  453. ldap_user = None
  454. else:
  455. # Auto-provision LDAP user. Provisioning already sets the
  456. # email, groups and finance defaults the sync below would,
  457. # so a new user skips it (it also logged the default-group
  458. # warning a second time, #3197).
  459. user = await _provision_ldap_user(db, ldap_user, ldap_config)
  460. ldap_user_provisioned = True
  461. if user and ldap_user and not ldap_user_provisioned:
  462. # Update email and group mappings on each login
  463. await _sync_ldap_user(db, user, ldap_user, ldap_config)
  464. # Keep finance defaults idempotently in sync for LDAP users
  465. # (wallet + private cost center + self-membership).
  466. await ensure_user_finance_defaults(db, user)
  467. except Exception as e: # SEC-AUTH-EXC: LDAP failure sets ldap_user=None, downstream local-auth path runs with its own credential check (no implicit grant)
  468. import logging
  469. logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e)
  470. ldap_user = None
  471. # #1589: local username/password gate. LDAP keeps its own switch
  472. # (ldap_enabled) and is not affected — a delegated directory has its
  473. # own policy and lockouts and is closer to SSO than to local creds.
  474. # The env-var BAMBUDDY_LOCAL_LOGIN=true bypasses this gate so a server
  475. # admin can recover an install whose SSO provider is unreachable
  476. # without editing the DB.
  477. from backend.app.models.settings import Settings as _Settings_for_local_login
  478. local_login_allowed = ldap_user is not None or _local_login_env_bypass()
  479. if not local_login_allowed:
  480. setting_row = await db.execute(
  481. select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
  482. )
  483. row = setting_row.scalar_one_or_none()
  484. # Default True when the row is absent — matches AppSettings default
  485. # so fresh installs and tests behave like every release before #1589.
  486. local_login_allowed = row is None or row.value.lower() == "true"
  487. # Try username-based authentication (skip if already authenticated via LDAP)
  488. if not ldap_user and local_login_allowed:
  489. user = await authenticate_user(db, request.username, request.password)
  490. # If username auth failed and advanced auth is enabled, try email-based authentication
  491. if not user and not ldap_user and local_login_allowed:
  492. advanced_auth = await is_advanced_auth_enabled(db)
  493. if advanced_auth:
  494. user = await authenticate_user_by_email(db, request.username, request.password)
  495. if not user:
  496. await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT)
  497. await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP)
  498. # Same generic 401 either way — never tell the client whether the
  499. # username exists or whether local login was disabled. The Settings
  500. # UI and /auth/advanced-auth/status are the channels for that state;
  501. # leaking it here would help credential-stuffing distinguish "local
  502. # disabled" from "wrong password" across an install fleet.
  503. raise HTTPException(
  504. status_code=status.HTTP_401_UNAUTHORIZED,
  505. detail="Incorrect username or password",
  506. headers={"WWW-Authenticate": "Bearer"},
  507. )
  508. # Reload user with groups for proper permission calculation
  509. result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
  510. user = result.scalar_one()
  511. # L-R6-A: Password was correct — reset login failure counters for both buckets
  512. from backend.app.api.routes.mfa import clear_failed_attempts
  513. await clear_failed_attempts(db, user.username, event_type=EventType.LOGIN_ATTEMPT)
  514. await clear_failed_attempts(db, client_ip, event_type=EventType.LOGIN_IP)
  515. # --- 2FA check ---
  516. # Determine which 2FA methods are active for this user.
  517. from backend.app.models.settings import Settings as _Settings
  518. from backend.app.models.user_totp import UserTOTP
  519. totp_result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))
  520. user_totp = totp_result.scalar_one_or_none()
  521. totp_enabled = user_totp is not None and user_totp.is_enabled
  522. email_2fa_result = await db.execute(select(_Settings).where(_Settings.key == f"user_{user.id}_email_2fa_enabled"))
  523. email_2fa_setting = email_2fa_result.scalar_one_or_none()
  524. email_otp_enabled = (
  525. email_2fa_setting is not None and email_2fa_setting.value.lower() == "true" and user.email is not None
  526. )
  527. if totp_enabled or email_otp_enabled:
  528. # Import here to avoid circular imports
  529. from backend.app.api.routes.mfa import create_pre_auth_token
  530. # Bind the pre_auth_token to an HttpOnly cookie so XSS cannot steal the
  531. # token from JS memory and complete 2FA from a different client.
  532. challenge_id = secrets.token_urlsafe(32)
  533. pre_auth_token = await create_pre_auth_token(db, user.username, challenge_id=challenge_id)
  534. response.set_cookie(
  535. key="2fa_challenge",
  536. value=challenge_id,
  537. httponly=True,
  538. # H-1: only transmit over HTTPS so the binding cookie can't be intercepted
  539. # on mixed-content deployments. Falls back to False on plain HTTP so tests
  540. # and local development still work (the client wouldn't send it otherwise).
  541. secure=raw_request.url.scheme == "https",
  542. samesite="lax",
  543. max_age=300,
  544. path="/api/v1/auth/2fa",
  545. )
  546. methods: list[str] = []
  547. if totp_enabled:
  548. methods.append("totp")
  549. if email_otp_enabled:
  550. methods.append("email")
  551. # Backup codes are always available when TOTP is set up
  552. if totp_enabled:
  553. methods.append("backup")
  554. return LoginResponse(
  555. requires_2fa=True,
  556. pre_auth_token=pre_auth_token,
  557. two_fa_methods=methods,
  558. )
  559. # No 2FA — issue full token immediately. Session lifetime honours the
  560. # admin-configurable ceiling (#1706); resolver clamps to [1h, 720h].
  561. access_token_expires = timedelta(minutes=await resolve_session_max_minutes(db))
  562. access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
  563. return LoginResponse(
  564. access_token=access_token,
  565. token_type="bearer",
  566. user=_user_to_response(user),
  567. )
  568. @router.post("/ws-token")
  569. async def mint_websocket_token(
  570. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.WEBSOCKET_CONNECT),
  571. api_key: APIKey | None = Depends(current_api_key_if_present),
  572. ):
  573. """Mint a short-lived token for ``/api/v1/ws`` connections (GHSA-r2qv follow-up).
  574. The WebSocket endpoint cannot read ``Authorization`` headers from
  575. browsers (the WebSocket handshake does not let JS attach custom
  576. headers), so we use the same opaque-token-in-query-param pattern
  577. as ``/camera/stream`` — the token is minted here behind the standard
  578. permission gate, then appended as ``?token=<value>`` on the
  579. ``ws://...`` URL. The WebSocket endpoint validates it *before*
  580. calling ``websocket.accept()``.
  581. Returns ``{"token": <opaque string>}``. The token is valid for 60
  582. minutes; the SPA refreshes it on reconnect if expired. API keys can
  583. mint tokens too — their scope flags decide whether ``WEBSOCKET_CONNECT``
  584. passes via the standard allowlist (``can_read_status`` covers it).
  585. """
  586. username = current_user.username if current_user is not None else None
  587. # The socket only carries the printers its minter may see (#1727)
  588. api_key_id = api_key.id if api_key is not None else None
  589. return {"token": await create_websocket_token(username, api_key_id)}
  590. @router.post("/media-token")
  591. async def mint_media_token(
  592. current_user: User | None = Depends(require_auth_if_enabled),
  593. ):
  594. """Mint a short-lived token for ``<img>`` / ``<video>`` media routes (#3025).
  595. Thumbnails, plate previews, timelapses, cover images and sidebar icons are
  596. loaded by the browser as element ``src`` URLs, which cannot carry an
  597. ``Authorization`` header. Those routes used to accept the *camera stream*
  598. token instead, which made ``camera:view`` a prerequisite for seeing a
  599. library thumbnail -- on a home install, handing someone the live feed of
  600. the room the printer is in just so their own files render.
  601. So this mints behind plain authentication: any signed-in user may ask, and
  602. what the token can actually reach is decided per request by the same
  603. permission and ownership rules as the resource's other routes. It is not a
  604. camera credential and does not open the camera routes.
  605. Returns ``{"token": <opaque string>}``, valid for 60 minutes.
  606. """
  607. username = current_user.username if current_user is not None else None
  608. return {"token": await create_media_token(username)}
  609. @router.get("/me", response_model=UserResponse)
  610. async def get_current_user_info(
  611. credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
  612. x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
  613. db: AsyncSession = Depends(get_db),
  614. ):
  615. """Get current user information.
  616. Accepts JWT tokens (via Authorization: Bearer header) and API keys
  617. (via X-API-Key header or Authorization: Bearer bb_xxx). API keys report
  618. their owner's identity and the permissions the key can actually exercise
  619. -- see ``_api_key_to_user_response``.
  620. """
  621. import jwt
  622. from jwt.exceptions import PyJWTError as JWTError
  623. # Check for API key via X-API-Key header
  624. if x_api_key:
  625. api_key = await _validate_api_key(db, x_api_key)
  626. if api_key:
  627. return await _api_key_to_user_response(db, api_key)
  628. # Check for Bearer token (could be JWT or API key)
  629. if credentials is not None:
  630. token = credentials.credentials
  631. # Check if it's an API key (starts with bb_)
  632. if token.startswith("bb_"):
  633. api_key = await _validate_api_key(db, token)
  634. if api_key:
  635. return await _api_key_to_user_response(db, api_key)
  636. raise HTTPException(
  637. status_code=status.HTTP_401_UNAUTHORIZED,
  638. detail="Invalid API key",
  639. headers={"WWW-Authenticate": "Bearer"},
  640. )
  641. # Otherwise treat as JWT
  642. try:
  643. payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
  644. username: str = payload.get("sub")
  645. if username is None:
  646. raise HTTPException(
  647. status_code=status.HTTP_401_UNAUTHORIZED,
  648. detail="Could not validate credentials",
  649. headers={"WWW-Authenticate": "Bearer"},
  650. )
  651. jti: str | None = payload.get("jti")
  652. if not jti or await is_jti_revoked(jti, db): # B1: logout bypass fix
  653. raise HTTPException(
  654. status_code=status.HTTP_401_UNAUTHORIZED,
  655. detail="Could not validate credentials",
  656. headers={"WWW-Authenticate": "Bearer"},
  657. )
  658. iat: int | float | None = payload.get("iat")
  659. except JWTError:
  660. raise HTTPException(
  661. status_code=status.HTTP_401_UNAUTHORIZED,
  662. detail="Could not validate credentials",
  663. headers={"WWW-Authenticate": "Bearer"},
  664. )
  665. user = await get_user_by_username(db, username)
  666. if user is None or not user.is_active:
  667. raise HTTPException(
  668. status_code=status.HTTP_401_UNAUTHORIZED,
  669. detail="Could not validate credentials",
  670. headers={"WWW-Authenticate": "Bearer"},
  671. )
  672. # Reload with groups for proper permission calculation
  673. result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
  674. user = result.scalar_one()
  675. # L-R8-A: reject tokens issued before the last password change
  676. if not _is_token_fresh(iat, user):
  677. raise HTTPException(
  678. status_code=status.HTTP_401_UNAUTHORIZED,
  679. detail="Could not validate credentials",
  680. headers={"WWW-Authenticate": "Bearer"},
  681. )
  682. return _user_to_response(user)
  683. # No credentials provided
  684. raise HTTPException(
  685. status_code=status.HTTP_401_UNAUTHORIZED,
  686. detail="Authentication required",
  687. headers={"WWW-Authenticate": "Bearer"},
  688. )
  689. @router.post("/logout")
  690. async def logout(
  691. raw_request: Request,
  692. credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
  693. ):
  694. """Logout — revokes the current JWT so it cannot be reused after logout."""
  695. if credentials is not None:
  696. raw_token = credentials.credentials
  697. # Nit2: Verify signature before revoking to prevent DoS-revoke attacks
  698. # (an attacker crafting a token with an arbitrary jti cannot force
  699. # revocation of a legitimate token because the signature check rejects it).
  700. # Expired tokens are still accepted — the user is logging out and their
  701. # token may have just expired; we still want to record the revocation.
  702. try:
  703. verified = _jwt.decode(
  704. raw_token,
  705. SECRET_KEY,
  706. algorithms=[ALGORITHM],
  707. options={"verify_exp": False}, # allow expired tokens at logout
  708. )
  709. jti: str | None = verified.get("jti")
  710. exp = verified.get("exp")
  711. username: str | None = verified.get("sub")
  712. if jti and exp:
  713. expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
  714. try:
  715. await revoke_jti(jti, expires_at, username)
  716. except Exception as exc: # SEC-AUTH-EXC: JTI-revoke failure on logout is logged only; logout removes access, never grants it (token stays valid until natural expiry — degraded but never escalation)
  717. _logger.error("Failed to revoke JTI on logout for user %s: %s", username, exc)
  718. except PyJWTError:
  719. client_ip = _get_client_ip(raw_request)
  720. ua = raw_request.headers.get("user-agent", "<unknown>")
  721. _logger.error(
  722. "Logout received token that failed signature verification — skipping revocation "
  723. "(possible tamper attempt; ip=%s ua=%s)",
  724. client_ip,
  725. ua,
  726. )
  727. return {"message": "Logged out successfully"}
  728. # Advanced Authentication Endpoints
  729. @router.post("/smtp/test", response_model=TestSMTPResponse)
  730. async def test_smtp_connection(
  731. test_request: TestSMTPRequest,
  732. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  733. db: AsyncSession = Depends(get_db),
  734. ):
  735. """Test SMTP connection using saved settings (admin only when auth enabled)."""
  736. import logging
  737. logger = logging.getLogger(__name__)
  738. try:
  739. smtp_settings = await get_smtp_settings(db)
  740. if not smtp_settings:
  741. return TestSMTPResponse(success=False, message="SMTP settings not configured. Save SMTP settings first.")
  742. # Send test email
  743. send_email(
  744. smtp_settings=smtp_settings,
  745. to_email=test_request.test_recipient,
  746. subject="BamBuddy SMTP Test",
  747. body_text="This is a test email from BamBuddy. If you received this, your SMTP settings are working correctly!",
  748. body_html="<p>This is a test email from <strong>BamBuddy</strong>.</p><p>If you received this, your SMTP settings are working correctly!</p>",
  749. )
  750. logger.info(f"Test email sent successfully to {test_request.test_recipient}")
  751. return TestSMTPResponse(success=True, message="Test email sent successfully")
  752. except Exception as e: # SEC-AUTH-EXC: SMTP test diagnostic returns success=False; no auth-relevant outcome (route is admin-gated by SETTINGS_UPDATE upstream)
  753. logger.error("Failed to send test email: %s", e)
  754. return TestSMTPResponse(success=False, message="Failed to send test email")
  755. @router.get("/smtp", response_model=SMTPSettings | None)
  756. async def get_smtp_config(
  757. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  758. db: AsyncSession = Depends(get_db),
  759. ):
  760. """Get SMTP settings (admin only when auth enabled). Password is not returned."""
  761. smtp_settings = await get_smtp_settings(db)
  762. if smtp_settings:
  763. # Don't return password in response
  764. smtp_settings.smtp_password = None
  765. return smtp_settings
  766. @router.post("/smtp", response_model=dict)
  767. async def save_smtp_config(
  768. smtp_settings: SMTPSettings,
  769. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  770. db: AsyncSession = Depends(get_db),
  771. ):
  772. """Save SMTP settings (admin only when auth enabled)."""
  773. import logging
  774. logger = logging.getLogger(__name__)
  775. try:
  776. await save_smtp_settings(db, smtp_settings)
  777. await db.commit()
  778. logger.info(f"SMTP settings updated by admin user: {current_user.username if current_user else 'anonymous'}")
  779. return {"message": "SMTP settings saved successfully"}
  780. except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); SMTP settings unchanged on error
  781. await db.rollback()
  782. logger.error("Failed to save SMTP settings: %s", e)
  783. raise HTTPException(
  784. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  785. detail="Failed to save SMTP settings",
  786. )
  787. @router.post("/advanced-auth/enable", response_model=dict)
  788. async def enable_advanced_auth(
  789. current_user: User = Depends(get_current_active_user),
  790. db: AsyncSession = Depends(get_db),
  791. ):
  792. """Enable advanced authentication (admin only).
  793. Requires SMTP settings to be configured and tested first.
  794. """
  795. import logging
  796. logger = logging.getLogger(__name__)
  797. # Reload user with groups for proper is_admin check
  798. result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  799. user = result.scalar_one()
  800. if not user.is_admin:
  801. raise HTTPException(
  802. status_code=status.HTTP_403_FORBIDDEN,
  803. detail="Only admins can enable advanced authentication",
  804. )
  805. # Verify SMTP settings are configured
  806. smtp_settings = await get_smtp_settings(db)
  807. if not smtp_settings:
  808. raise HTTPException(
  809. status_code=status.HTTP_400_BAD_REQUEST,
  810. detail="SMTP settings must be configured before enabling advanced authentication",
  811. )
  812. try:
  813. await set_advanced_auth_enabled(db, True)
  814. await db.commit()
  815. logger.info(f"Advanced authentication enabled by admin user: {user.username}")
  816. return {"message": "Advanced authentication enabled successfully", "advanced_auth_enabled": True}
  817. except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); advanced-auth setting unchanged on error
  818. await db.rollback()
  819. logger.error("Failed to enable advanced authentication: %s", e)
  820. raise HTTPException(
  821. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  822. detail="Failed to enable advanced authentication",
  823. )
  824. @router.post("/advanced-auth/disable", response_model=dict)
  825. async def disable_advanced_auth(
  826. current_user: User = Depends(get_current_active_user),
  827. db: AsyncSession = Depends(get_db),
  828. ):
  829. """Disable advanced authentication (admin only)."""
  830. import logging
  831. logger = logging.getLogger(__name__)
  832. # Reload user with groups for proper is_admin check
  833. result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  834. user = result.scalar_one()
  835. if not user.is_admin:
  836. raise HTTPException(
  837. status_code=status.HTTP_403_FORBIDDEN,
  838. detail="Only admins can disable advanced authentication",
  839. )
  840. try:
  841. await set_advanced_auth_enabled(db, False)
  842. await db.commit()
  843. logger.info(f"Advanced authentication disabled by admin user: {user.username}")
  844. return {"message": "Advanced authentication disabled successfully", "advanced_auth_enabled": False}
  845. except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); advanced-auth setting unchanged on error
  846. await db.rollback()
  847. logger.error("Failed to disable advanced authentication: %s", e)
  848. raise HTTPException(
  849. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  850. detail="Failed to disable advanced authentication",
  851. )
  852. @router.get("/advanced-auth/status")
  853. async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
  854. """Get advanced authentication status.
  855. Surfaces ``local_login_enabled`` and ``autologin_provider_id`` (#1589)
  856. so the LoginPage can decide whether to render the credentials form and
  857. whether to redirect unauthenticated visitors directly to an SSO
  858. provider, in a single query. ``BAMBUDDY_LOCAL_LOGIN=true`` flips the
  859. reported value back to True so the recovery path is visible.
  860. """
  861. from backend.app.models.oidc_provider import OIDCProvider
  862. from backend.app.models.settings import Settings as _Settings_for_local_login
  863. advanced_auth_enabled = await is_advanced_auth_enabled(db)
  864. smtp_configured = await get_smtp_settings(db) is not None
  865. setting_row = await db.execute(
  866. select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
  867. )
  868. row = setting_row.scalar_one_or_none()
  869. db_local_enabled = row is None or row.value.lower() == "true"
  870. local_login_enabled = db_local_enabled or _local_login_env_bypass()
  871. # Autologin provider must be both flagged AND enabled — disabling a
  872. # provider should not silently keep redirecting visitors to it.
  873. autologin = await db.execute(
  874. select(OIDCProvider.id).where(OIDCProvider.is_autologin.is_(True), OIDCProvider.is_enabled.is_(True)).limit(1)
  875. )
  876. autologin_provider_id = autologin.scalar_one_or_none()
  877. return {
  878. "advanced_auth_enabled": advanced_auth_enabled,
  879. "smtp_configured": smtp_configured,
  880. "local_login_enabled": local_login_enabled,
  881. "autologin_provider_id": autologin_provider_id,
  882. }
  883. # TTL for password-reset tokens (H-6)
  884. _RESET_TOKEN_TTL = timedelta(hours=1)
  885. # Rate-limit for password-reset email sends per identifier (M-A)
  886. _MAX_PWD_RESET_SENDS = 3
  887. _PWD_RESET_SEND_WINDOW = timedelta(minutes=15)
  888. # L-NEW-6: per-IP cap to prevent mass-reset flooding across many addresses
  889. _MAX_PWD_RESET_SENDS_PER_IP = 10
  890. async def _send_reset_email_or_delete_token(
  891. reset_token: str,
  892. smtp_settings,
  893. to_email: str,
  894. subject: str,
  895. text_body: str,
  896. html_body: str,
  897. log_label: str,
  898. ) -> None:
  899. """Background task: send a password-reset email and delete the token on failure.
  900. C1: FastAPI silently swallows BackgroundTask exceptions. This wrapper
  901. catches send failures, deletes the single-use token so it cannot be used
  902. (user is not locked out forever — they can request a new link), and logs at
  903. ERROR so operators are alerted without leaking details to the caller.
  904. """
  905. try:
  906. send_email(smtp_settings, to_email, subject, text_body, html_body)
  907. _logger.info("Password reset email sent (%s) to %s", log_label, to_email)
  908. except Exception as exc: # SEC-AUTH-EXC: email-send failure → defensive token cleanup so a stuck token doesn't block re-request; no access granted, just frees future workflow
  909. _logger.error(
  910. "Password reset email failed (%s) to %s — deleting token to unblock re-request: %s",
  911. log_label,
  912. to_email,
  913. exc,
  914. )
  915. try:
  916. async with async_session() as db:
  917. await db.execute(
  918. delete(AuthEphemeralToken).where(
  919. AuthEphemeralToken.token == reset_token,
  920. AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
  921. )
  922. )
  923. await db.commit()
  924. except Exception as db_exc: # SEC-AUTH-EXC: nested cleanup failure logged only; no access decision made in this branch (already handling a prior failure)
  925. _logger.error("Failed to delete reset token after send failure: %s", db_exc)
  926. @router.post("/forgot-password", response_model=ForgotPasswordResponse)
  927. async def forgot_password(
  928. request: ForgotPasswordRequest,
  929. background_tasks: BackgroundTasks,
  930. raw_request: Request,
  931. db: AsyncSession = Depends(get_db),
  932. ):
  933. """Request password reset via email (advanced auth only).
  934. H-6: Issues a short-lived single-use reset token and emails the user a
  935. secure link instead of a plaintext temporary password. The new password is
  936. set only when the user clicks the link and POSTs to /forgot-password/confirm.
  937. """
  938. # #1589: forgot-password is a local-credentials flow — useless when local
  939. # login is disabled (the reset wouldn't grant access anyway). Same gate as
  940. # /auth/login, with the same env-var bypass for SSO-broken recovery.
  941. if not _local_login_env_bypass():
  942. from backend.app.models.settings import Settings as _Settings_for_local_login
  943. setting_row = await db.execute(
  944. select(_Settings_for_local_login).where(_Settings_for_local_login.key == "local_login_enabled")
  945. )
  946. row = setting_row.scalar_one_or_none()
  947. if row is not None and row.value.lower() != "true":
  948. raise HTTPException(
  949. status_code=status.HTTP_403_FORBIDDEN,
  950. detail="Local login is disabled — use SSO instead.",
  951. )
  952. # Check if advanced auth is enabled
  953. advanced_auth = await is_advanced_auth_enabled(db)
  954. if not advanced_auth:
  955. raise HTTPException(
  956. status_code=status.HTTP_400_BAD_REQUEST,
  957. detail="Advanced authentication is not enabled",
  958. )
  959. # M-A: Rate-limit by normalised email to prevent reset-email flooding.
  960. # Apply unconditionally (before the user lookup) so unknown emails are also
  961. # throttled — this prevents both flooding and timing-based enumeration.
  962. identifier = request.email.lower()
  963. cutoff = datetime.now(timezone.utc) - _PWD_RESET_SEND_WINDOW
  964. rate_result = await db.execute(
  965. select(AuthRateLimitEvent).where(
  966. AuthRateLimitEvent.username == identifier,
  967. AuthRateLimitEvent.event_type == EventType.PASSWORD_RESET_SEND,
  968. AuthRateLimitEvent.occurred_at > cutoff,
  969. )
  970. )
  971. if len(rate_result.scalars().all()) >= _MAX_PWD_RESET_SENDS:
  972. raise HTTPException(
  973. status_code=status.HTTP_429_TOO_MANY_REQUESTS,
  974. detail=f"Too many password reset requests. Please wait {_PWD_RESET_SEND_WINDOW.seconds // 60} minutes.",
  975. )
  976. # L-NEW-6: per-IP rate limit — prevents mass-reset flooding across many
  977. # different email addresses from a single source IP.
  978. client_ip = _get_client_ip(raw_request)
  979. ip_rate_result = await db.execute(
  980. select(AuthRateLimitEvent).where(
  981. AuthRateLimitEvent.username == client_ip,
  982. AuthRateLimitEvent.event_type == EventType.PASSWORD_RESET_IP,
  983. AuthRateLimitEvent.occurred_at > cutoff,
  984. )
  985. )
  986. if len(ip_rate_result.scalars().all()) >= _MAX_PWD_RESET_SENDS_PER_IP:
  987. raise HTTPException(
  988. status_code=status.HTTP_429_TOO_MANY_REQUESTS,
  989. detail=f"Too many password reset requests. Please wait {_PWD_RESET_SEND_WINDOW.seconds // 60} minutes.",
  990. )
  991. # Nit7: Always record the IP-level event (prevents spray attacks across many
  992. # different email addresses from one IP). The email-level event is only
  993. # recorded when we actually send an email to a local user — LDAP/OIDC users
  994. # do not consume a slot because this flow is a no-op for them.
  995. db.add(AuthRateLimitEvent(username=client_ip, event_type=EventType.PASSWORD_RESET_IP))
  996. await db.commit()
  997. # Get SMTP settings
  998. smtp_settings = await get_smtp_settings(db)
  999. if not smtp_settings:
  1000. raise HTTPException(
  1001. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  1002. detail="Email service is not configured",
  1003. )
  1004. # Find user by email — always return success to prevent email enumeration.
  1005. user = await get_user_by_email(db, request.email)
  1006. # M-1: exclude LDAP and OIDC users — they must use their respective provider.
  1007. if user and user.is_active and user.auth_source not in ("ldap", "oidc"):
  1008. try:
  1009. # Record email-level slot only for local users who will actually receive
  1010. # the reset email (Nit7: don't waste the user's quota for LDAP/OIDC no-ops).
  1011. db.add(AuthRateLimitEvent(username=identifier, event_type=EventType.PASSWORD_RESET_SEND))
  1012. now = datetime.now(timezone.utc)
  1013. # Prune any outstanding reset tokens for this user before issuing a new one.
  1014. await db.execute(
  1015. delete(AuthEphemeralToken).where(
  1016. AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
  1017. AuthEphemeralToken.username == user.username,
  1018. )
  1019. )
  1020. reset_token = secrets.token_urlsafe(32)
  1021. db.add(
  1022. AuthEphemeralToken(
  1023. token=reset_token,
  1024. token_type=TokenType.PASSWORD_RESET,
  1025. username=user.username,
  1026. expires_at=now + _RESET_TOKEN_TTL,
  1027. )
  1028. )
  1029. await db.commit()
  1030. login_url = await get_external_login_url(db)
  1031. # M-B: Deliver token in the URL fragment so it never reaches the server
  1032. # in access-logs or Referer headers (mirrors H-4 for the OIDC token).
  1033. reset_url = f"{login_url}#reset_token={reset_token}"
  1034. subject, text_body, html_body = await create_password_reset_link_email_from_template(
  1035. db, user.username, reset_url
  1036. )
  1037. # L-R9-B: send asynchronously so response time is independent of
  1038. # whether the user exists (prevents email-existence timing oracle).
  1039. # C1: wrapper deletes the token if SMTP fails so the user can re-request.
  1040. background_tasks.add_task(
  1041. _send_reset_email_or_delete_token,
  1042. reset_token,
  1043. smtp_settings,
  1044. user.email,
  1045. subject,
  1046. text_body,
  1047. html_body,
  1048. "forgot_password",
  1049. )
  1050. _logger.info("Password reset email queued for %s", user.email)
  1051. except Exception as e: # SEC-AUTH-EXC: forgot-password response is intentionally generic regardless of outcome (user-enumeration defence); email failure does not grant access
  1052. _logger.error("Failed to send password reset email: %s", e)
  1053. # Don't reveal error to caller for security
  1054. return ForgotPasswordResponse(
  1055. message="If the email address is associated with an account, a password reset email has been sent."
  1056. )
  1057. @router.post("/forgot-password/confirm", response_model=ForgotPasswordResponse)
  1058. async def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: AsyncSession = Depends(get_db)):
  1059. """Complete a password reset by supplying the token from the reset email.
  1060. H-6: Atomically consumes the single-use token (DELETE…RETURNING) and sets
  1061. the new password. Expired or already-used tokens are silently rejected with
  1062. the same response to prevent oracle attacks.
  1063. """
  1064. now = datetime.now(timezone.utc)
  1065. result = await db.execute(
  1066. delete(AuthEphemeralToken)
  1067. .where(
  1068. AuthEphemeralToken.token == request.token,
  1069. AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
  1070. )
  1071. .returning(AuthEphemeralToken.username, AuthEphemeralToken.expires_at, AuthEphemeralToken.created_at)
  1072. )
  1073. row = result.one_or_none()
  1074. await db.commit()
  1075. if row is None:
  1076. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
  1077. username, expires_at, minted_at = row
  1078. if await token_outlived_its_session(db, username, minted_at):
  1079. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
  1080. # Stored as naive UTC on both databases; treat it as UTC.
  1081. if expires_at.tzinfo is None:
  1082. expires_at = expires_at.replace(tzinfo=timezone.utc)
  1083. if now > expires_at:
  1084. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
  1085. user = await get_user_by_username(db, username)
  1086. # M-1: block LDAP/OIDC users — they authenticate via their provider, not local password.
  1087. if not user or not user.is_active or user.auth_source in ("ldap", "oidc"):
  1088. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
  1089. user.password_hash = get_password_hash(request.new_password)
  1090. user.password_changed_at = now # M-R7-B: invalidate all prior JWTs
  1091. # The WebSocket and camera tokens too: reusable for an hour, they would
  1092. # reconnect a closed dashboard straight away.
  1093. from backend.app.api.routes.users import drop_pending_tokens
  1094. await drop_pending_tokens(db, user.username)
  1095. await db.commit()
  1096. # Open dashboards are never signed in again: one someone else had open on
  1097. # the account would keep receiving its printers.
  1098. from backend.app.core.websocket import ws_manager
  1099. await ws_manager.close_user(user.id)
  1100. _logger.info("Password reset completed for user '%s'", username)
  1101. return ForgotPasswordResponse(message="Password has been reset successfully.")
  1102. @router.post("/reset-password", response_model=ResetPasswordResponse)
  1103. async def reset_user_password(
  1104. request: ResetPasswordRequest,
  1105. background_tasks: BackgroundTasks,
  1106. current_user: User = Depends(get_current_active_user),
  1107. db: AsyncSession = Depends(get_db),
  1108. ):
  1109. """Reset a user's password and send them an email (admin only, advanced auth only)."""
  1110. # Reload user with groups for proper is_admin check
  1111. result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  1112. admin_user = result.scalar_one()
  1113. if not admin_user.is_admin:
  1114. raise HTTPException(
  1115. status_code=status.HTTP_403_FORBIDDEN,
  1116. detail="Only admins can reset user passwords",
  1117. )
  1118. # Check if advanced auth is enabled
  1119. advanced_auth = await is_advanced_auth_enabled(db)
  1120. if not advanced_auth:
  1121. raise HTTPException(
  1122. status_code=status.HTTP_400_BAD_REQUEST,
  1123. detail="Advanced authentication is not enabled",
  1124. )
  1125. # Get SMTP settings
  1126. smtp_settings = await get_smtp_settings(db)
  1127. if not smtp_settings:
  1128. raise HTTPException(
  1129. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  1130. detail="Email service is not configured",
  1131. )
  1132. # Find user to reset
  1133. result = await db.execute(select(User).where(User.id == request.user_id))
  1134. user = result.scalar_one_or_none()
  1135. if not user:
  1136. raise HTTPException(
  1137. status_code=status.HTTP_404_NOT_FOUND,
  1138. detail="User not found",
  1139. )
  1140. # M-1: block LDAP/OIDC users — passwords are managed by their respective providers.
  1141. if user.auth_source in ("ldap", "oidc"):
  1142. raise HTTPException(
  1143. status_code=status.HTTP_400_BAD_REQUEST,
  1144. detail="Cannot reset password for LDAP/OIDC users — authentication is managed by their provider",
  1145. )
  1146. if not user.email:
  1147. raise HTTPException(
  1148. status_code=status.HTTP_400_BAD_REQUEST,
  1149. detail="User does not have an email address configured",
  1150. )
  1151. try:
  1152. # H-B: Issue a single-use reset link instead of generating a plaintext password.
  1153. # The admin never sees the credential — the user sets their own password.
  1154. now = datetime.now(timezone.utc)
  1155. await db.execute(
  1156. delete(AuthEphemeralToken).where(
  1157. AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
  1158. AuthEphemeralToken.username == user.username,
  1159. )
  1160. )
  1161. reset_token = secrets.token_urlsafe(32)
  1162. db.add(
  1163. AuthEphemeralToken(
  1164. token=reset_token,
  1165. token_type=TokenType.PASSWORD_RESET,
  1166. username=user.username,
  1167. expires_at=now + _RESET_TOKEN_TTL,
  1168. )
  1169. )
  1170. await db.commit()
  1171. login_url = await get_external_login_url(db)
  1172. reset_url = f"{login_url}#reset_token={reset_token}"
  1173. subject, text_body, html_body = await create_password_reset_link_email_from_template(
  1174. db, user.username, reset_url
  1175. )
  1176. background_tasks.add_task(
  1177. _send_reset_email_or_delete_token,
  1178. reset_token,
  1179. smtp_settings,
  1180. user.email,
  1181. subject,
  1182. text_body,
  1183. html_body,
  1184. "admin_reset",
  1185. )
  1186. _logger.info("Admin password reset link queued for user '%s' by admin '%s'", user.username, admin_user.username)
  1187. return ResetPasswordResponse(message=f"Password reset link sent to {user.email}")
  1188. except Exception as e: # SEC-AUTH-EXC: rollback + raise 500 (fail-closed); reset token state unchanged on error
  1189. await db.rollback()
  1190. _logger.error("Failed to send admin password reset for user '%s': %s", user.username, e)
  1191. raise HTTPException(
  1192. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  1193. detail="Failed to send password reset link. Check server logs.", # L-R7-B: no internal details
  1194. )
  1195. # LDAP Authentication Helpers
  1196. async def _get_ldap_settings(db: AsyncSession) -> dict[str, str] | None:
  1197. """Get LDAP settings from the database. Returns None if LDAP is not enabled."""
  1198. ldap_keys = [
  1199. "ldap_enabled",
  1200. "ldap_server_url",
  1201. "ldap_bind_dn",
  1202. "ldap_bind_password",
  1203. "ldap_search_base",
  1204. "ldap_user_filter",
  1205. "ldap_security",
  1206. "ldap_group_mapping",
  1207. "ldap_auto_provision",
  1208. "ldap_ca_cert_path",
  1209. "ldap_default_group",
  1210. ]
  1211. result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
  1212. settings = {s.key: s.value for s in result.scalars().all()}
  1213. if settings.get("ldap_enabled", "false").lower() != "true":
  1214. return None
  1215. return settings
  1216. async def _provision_ldap_user(db: AsyncSession, ldap_user, ldap_config) -> User:
  1217. """Create a new local user from LDAP authentication."""
  1218. import logging
  1219. from backend.app.services.ldap_service import resolve_group_mapping
  1220. logger = logging.getLogger(__name__)
  1221. new_user = User(
  1222. username=ldap_user.username,
  1223. email=ldap_user.email,
  1224. password_hash=None,
  1225. role="user",
  1226. auth_source="ldap",
  1227. is_active=True,
  1228. )
  1229. # Map LDAP groups to BamBuddy groups, falling back to the configured default group
  1230. # when the user is authenticated but has no matching group mapping (#921-follow-up).
  1231. mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)
  1232. if not mapped_group_names and ldap_config.default_group:
  1233. mapped_group_names = [ldap_config.default_group]
  1234. logger.warning(
  1235. "LDAP user %s has no mapped groups — assigning configured default group '%s'",
  1236. ldap_user.username,
  1237. ldap_config.default_group,
  1238. )
  1239. if mapped_group_names:
  1240. groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))
  1241. new_user.groups = list(groups_result.scalars().all())
  1242. db.add(new_user)
  1243. await db.flush()
  1244. await ensure_user_finance_defaults(db, new_user)
  1245. await db.commit()
  1246. await db.refresh(new_user)
  1247. logger.info("Auto-provisioned LDAP user: %s (groups: %s)", new_user.username, mapped_group_names)
  1248. return new_user
  1249. async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config) -> None:
  1250. """Sync LDAP user attributes (email, groups) on each login.
  1251. Group sync only touches BamBuddy groups that LDAP is configured to manage —
  1252. that is, the values of `group_mapping` plus `default_group`. Any group
  1253. outside that set is assumed to be a manual admin assignment and is
  1254. preserved across logins (#1292). Manual assignments to a BamBuddy group
  1255. that IS LDAP-managed are still overridden by LDAP truth, because revoking
  1256. access in LDAP must propagate to BamBuddy on next login.
  1257. """
  1258. import logging
  1259. from backend.app.services.ldap_service import resolve_group_mapping
  1260. logger = logging.getLogger(__name__)
  1261. changed = False
  1262. # Update email if changed
  1263. if ldap_user.email and ldap_user.email != user.email:
  1264. user.email = ldap_user.email
  1265. changed = True
  1266. # Compute the set of BamBuddy groups LDAP is allowed to manage. Anything
  1267. # outside this set is left alone so manual admin assignments survive logins.
  1268. ldap_managed_names: set[str] = set(ldap_config.group_mapping.values())
  1269. if ldap_config.default_group:
  1270. ldap_managed_names.add(ldap_config.default_group)
  1271. # Resolve what LDAP says the user should currently be in.
  1272. mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)
  1273. if not mapped_group_names and ldap_config.default_group:
  1274. mapped_group_names = [ldap_config.default_group]
  1275. logger.warning(
  1276. "LDAP user %s has no mapped groups — assigning configured default group '%s'",
  1277. user.username,
  1278. ldap_config.default_group,
  1279. )
  1280. if mapped_group_names:
  1281. groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))
  1282. new_ldap_groups = list(groups_result.scalars().all())
  1283. else:
  1284. new_ldap_groups = []
  1285. # Preserve manual assignments to non-LDAP-managed groups; replace only
  1286. # the LDAP-managed slice with the resolved set.
  1287. preserved_manual_groups = [g for g in user.groups if g.name not in ldap_managed_names]
  1288. new_groups = preserved_manual_groups + new_ldap_groups
  1289. current_group_ids = {g.id for g in user.groups}
  1290. new_group_ids = {g.id for g in new_groups}
  1291. groups_changed = current_group_ids != new_group_ids
  1292. if groups_changed:
  1293. user.groups = new_groups
  1294. changed = True
  1295. if changed:
  1296. await db.commit()
  1297. logger.info("Synced LDAP user attributes: %s", user.username)
  1298. if groups_changed:
  1299. # The user's open dashboards may now see other printers (#1727)
  1300. from backend.app.core.websocket import ws_manager
  1301. await ws_manager.refresh_printer_scopes()
  1302. @router.post("/ldap/test")
  1303. async def test_ldap(
  1304. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  1305. db: AsyncSession = Depends(get_db),
  1306. ):
  1307. """Test LDAP connection using saved settings (admin only when auth enabled)."""
  1308. import logging
  1309. from backend.app.services.ldap_service import parse_ldap_config, test_ldap_connection
  1310. logger = logging.getLogger(__name__)
  1311. ldap_settings = await _get_ldap_settings(db)
  1312. if not ldap_settings:
  1313. # LDAP might not be enabled yet but settings might still exist — read all keys
  1314. ldap_keys = [
  1315. "ldap_enabled",
  1316. "ldap_server_url",
  1317. "ldap_bind_dn",
  1318. "ldap_bind_password",
  1319. "ldap_search_base",
  1320. "ldap_user_filter",
  1321. "ldap_security",
  1322. "ldap_group_mapping",
  1323. "ldap_auto_provision",
  1324. ]
  1325. result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
  1326. ldap_settings = {s.key: s.value for s in result.scalars().all()}
  1327. # Force enabled for test
  1328. ldap_settings["ldap_enabled"] = "true"
  1329. config = parse_ldap_config(ldap_settings)
  1330. if not config:
  1331. return {"success": False, "message": "LDAP server URL is not configured"}
  1332. success, message = test_ldap_connection(config)
  1333. if success:
  1334. logger.info("LDAP connection test successful")
  1335. else:
  1336. logger.warning("LDAP connection test failed: %s", message)
  1337. return {"success": success, "message": message}
  1338. @router.get("/ldap/status")
  1339. async def get_ldap_status(db: AsyncSession = Depends(get_db)):
  1340. """Get LDAP authentication status."""
  1341. # Only fetch the minimum keys needed — never load secrets
  1342. ldap_keys = ["ldap_enabled", "ldap_server_url"]
  1343. result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
  1344. settings = {s.key: s.value for s in result.scalars().all()}
  1345. return {
  1346. "ldap_enabled": settings.get("ldap_enabled", "false").lower() == "true",
  1347. "ldap_configured": bool(settings.get("ldap_server_url")),
  1348. }
  1349. # =============================================================================
  1350. # Manual LDAP user provisioning (#1298)
  1351. # =============================================================================
  1352. # Admins can search the directory and provision users directly from the UI
  1353. # without enabling auto-provision on login. The two endpoints below pair with
  1354. # the new "LDAP" tab in the user-create modal.
  1355. @router.get("/ldap/search", response_model=list[LDAPSearchResultResponse])
  1356. async def search_ldap_directory(
  1357. q: str,
  1358. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
  1359. db: AsyncSession = Depends(get_db),
  1360. ):
  1361. """Search the LDAP directory for users matching `q`.
  1362. Returns up to 25 candidates. The query is matched (case-insensitively, with
  1363. wildcards on both sides) against sAMAccountName, uid, mail, displayName,
  1364. and cn — covering both AD and OpenLDAP layouts. Each result is annotated
  1365. with `already_provisioned` so the UI can grey out usernames that already
  1366. exist as BamBuddy users.
  1367. Requires USERS_CREATE permission. Minimum query length is 2 characters.
  1368. """
  1369. from sqlalchemy import func as sa_func
  1370. from backend.app.services.ldap_service import parse_ldap_config, search_ldap_users
  1371. query = q.strip()
  1372. if len(query) < 2:
  1373. raise HTTPException(
  1374. status_code=status.HTTP_400_BAD_REQUEST,
  1375. detail="Query must be at least 2 characters",
  1376. )
  1377. ldap_settings = await _get_ldap_settings(db)
  1378. if not ldap_settings:
  1379. raise HTTPException(
  1380. status_code=status.HTTP_400_BAD_REQUEST,
  1381. detail="LDAP is not enabled",
  1382. )
  1383. config = parse_ldap_config(ldap_settings)
  1384. if not config:
  1385. raise HTTPException(
  1386. status_code=status.HTTP_400_BAD_REQUEST,
  1387. detail="LDAP server URL is not configured",
  1388. )
  1389. try:
  1390. results = search_ldap_users(config, query, limit=25)
  1391. except Exception as e: # SEC-AUTH-EXC: raise 503 (fail-closed); route gated upstream by USERS_CREATE permission so detail leak is admin-only
  1392. _logger.exception("LDAP directory search failed")
  1393. # Admin-only endpoint — surface the underlying reason so the operator
  1394. # can fix it (auth_middleware already restricted access to USERS_CREATE).
  1395. raise HTTPException(
  1396. status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
  1397. detail=f"LDAP search failed: {type(e).__name__}: {e}",
  1398. )
  1399. if not results:
  1400. return []
  1401. # Annotate `already_provisioned` so the SPA can dim/disable rows that map
  1402. # to an existing local row. Case-insensitive lookup mirrors create_user.
  1403. usernames_lower = [r.username.lower() for r in results]
  1404. existing_query = await db.execute(select(User.username).where(sa_func.lower(User.username).in_(usernames_lower)))
  1405. existing_lower = {str(name).lower() for name in existing_query.scalars().all()}
  1406. return [
  1407. LDAPSearchResultResponse(
  1408. username=r.username,
  1409. email=r.email,
  1410. display_name=r.display_name,
  1411. dn=r.dn,
  1412. already_provisioned=r.username.lower() in existing_lower,
  1413. )
  1414. for r in results
  1415. ]
  1416. @router.post("/ldap/provision", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  1417. async def provision_ldap_user(
  1418. payload: LDAPProvisionRequest,
  1419. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
  1420. db: AsyncSession = Depends(get_db),
  1421. ):
  1422. """Provision a BamBuddy user from an existing LDAP directory entry.
  1423. Re-resolves the username via the service-account bind (rather than trusting
  1424. the request body) so group mappings and email come from a fresh LDAP read.
  1425. Applies the same group-mapping / default-group logic as the auto-provision
  1426. login path (`_provision_ldap_user`), so behavior stays identical regardless
  1427. of whether the user was created here or on first login.
  1428. Requires USERS_CREATE.
  1429. """
  1430. from sqlalchemy import func as sa_func
  1431. from backend.app.services.ldap_service import lookup_ldap_user, parse_ldap_config
  1432. username = payload.username.strip()
  1433. if not username:
  1434. raise HTTPException(
  1435. status_code=status.HTTP_400_BAD_REQUEST,
  1436. detail="Username is required",
  1437. )
  1438. ldap_settings = await _get_ldap_settings(db)
  1439. if not ldap_settings:
  1440. raise HTTPException(
  1441. status_code=status.HTTP_400_BAD_REQUEST,
  1442. detail="LDAP is not enabled",
  1443. )
  1444. config = parse_ldap_config(ldap_settings)
  1445. if not config:
  1446. raise HTTPException(
  1447. status_code=status.HTTP_400_BAD_REQUEST,
  1448. detail="LDAP server URL is not configured",
  1449. )
  1450. # Look up via service bind. Service-bind failures bubble up as 503; missing
  1451. # entries surface as 404 to distinguish "directory unreachable" from
  1452. # "username doesn't exist in the directory" in the UI.
  1453. try:
  1454. ldap_user = lookup_ldap_user(config, username)
  1455. except Exception as e: # SEC-AUTH-EXC: raise 503 (fail-closed); LDAP provision never succeeds on lookup failure
  1456. _logger.exception("LDAP lookup failed during provision")
  1457. raise HTTPException(
  1458. status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
  1459. detail=f"LDAP lookup failed: {type(e).__name__}: {e}",
  1460. )
  1461. if ldap_user is None:
  1462. raise HTTPException(
  1463. status_code=status.HTTP_404_NOT_FOUND,
  1464. detail=f"User '{username}' not found in LDAP directory",
  1465. )
  1466. # Reject duplicates — the canonical username from LDAP is what gets stored,
  1467. # so the conflict check uses that rather than the request payload.
  1468. existing = await db.execute(select(User).where(sa_func.lower(User.username) == sa_func.lower(ldap_user.username)))
  1469. existing_user = existing.scalar_one_or_none()
  1470. if existing_user is not None:
  1471. if existing_user.auth_source == "ldap":
  1472. detail = f"LDAP user '{ldap_user.username}' is already provisioned"
  1473. else:
  1474. detail = f"A local user with the username '{ldap_user.username}' already exists"
  1475. raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=detail)
  1476. new_user = await _provision_ldap_user(db, ldap_user, config)
  1477. # Reload with groups eagerly loaded so _user_to_response can serialize them
  1478. # without lazy-load warnings (matches create_user / list_users pattern).
  1479. result = await db.execute(select(User).where(User.id == new_user.id).options(selectinload(User.groups)))
  1480. new_user = result.scalar_one()
  1481. _logger.info("Manually provisioned LDAP user %s (id=%d)", new_user.username, new_user.id)
  1482. return _user_to_response(new_user)
  1483. # =============================================================================
  1484. # Long-lived camera-stream tokens (#1108)
  1485. # =============================================================================
  1486. # A token a user can paste into Home Assistant / Frigate / a kiosk and have it
  1487. # keep working for days/weeks rather than refreshing the 60-minute ephemeral
  1488. # token. Permission gate: CAMERA_VIEW (same blast radius as the existing 60-min
  1489. # token-mint endpoint).
  1490. #
  1491. # Two scopes, both minted here — see ALLOWED_SCOPES in services/long_lived_tokens
  1492. # for what each one reaches: "camera_stream" (video only) and "camwall" (video
  1493. # plus the Cam Wall's read-only tile metadata, #2531).
  1494. def _long_lived_token_to_response(record, *, plaintext: str | None = None) -> dict:
  1495. """Serialise a LongLivedToken row for the SPA. Plaintext is included
  1496. only at create time (and then never again), per the issue's "shown once"
  1497. contract.
  1498. """
  1499. return {
  1500. "id": record.id,
  1501. "user_id": record.user_id,
  1502. "name": record.name,
  1503. "scope": record.scope,
  1504. "lookup_prefix": record.lookup_prefix,
  1505. "created_at": record.created_at.isoformat() if record.created_at else None,
  1506. "expires_at": record.expires_at.isoformat() if record.expires_at else None,
  1507. "last_used_at": record.last_used_at.isoformat() if record.last_used_at else None,
  1508. # Plaintext is the ONLY field the user ever sees in full — copied once
  1509. # to a clipboard / kiosk config and then forgotten.
  1510. "token": plaintext,
  1511. }
  1512. @router.post("/tokens", response_model=dict, status_code=status.HTTP_201_CREATED)
  1513. async def create_long_lived_camera_token(
  1514. payload: dict,
  1515. response: Response,
  1516. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
  1517. db: AsyncSession = Depends(get_db),
  1518. ):
  1519. """Mint a long-lived camera-stream token (#1108).
  1520. Body: ``{"name": str, "expires_in_days": int, "scope": "camera_stream"}``.
  1521. The plaintext token is returned **exactly once** in the response. The DB
  1522. only ever stores a pbkdf2 hash, so a leaked DB dump cannot replay the
  1523. token. Hard cap of 365 days; the issue's ``expire_in: 0`` (never) is
  1524. explicitly rejected.
  1525. """
  1526. from backend.app.services.long_lived_tokens import (
  1527. ALLOWED_SCOPES,
  1528. MAX_TOKEN_LIFETIME_DAYS,
  1529. create_token,
  1530. )
  1531. # Auth-disabled path: tokens are user-owned, but if auth is off there is
  1532. # no user to own them. Refuse rather than silently picking a random user.
  1533. if current_user is None:
  1534. raise HTTPException(
  1535. status_code=status.HTTP_403_FORBIDDEN,
  1536. detail="Long-lived tokens require authentication to be enabled",
  1537. )
  1538. name = payload.get("name")
  1539. if not isinstance(name, str) or not name.strip():
  1540. raise HTTPException(status_code=400, detail="name is required")
  1541. expires_in_days = payload.get("expires_in_days")
  1542. if not isinstance(expires_in_days, int) or expires_in_days <= 0:
  1543. raise HTTPException(
  1544. status_code=400,
  1545. detail=(
  1546. f"expires_in_days must be a positive integer (max {MAX_TOKEN_LIFETIME_DAYS}; #1108: no infinite tokens)"
  1547. ),
  1548. )
  1549. scope = payload.get("scope", "camera_stream")
  1550. if scope not in ALLOWED_SCOPES:
  1551. raise HTTPException(status_code=400, detail=f"unsupported scope: {scope!r}")
  1552. try:
  1553. created = await create_token(
  1554. db,
  1555. user_id=current_user.id,
  1556. name=name,
  1557. expires_in_days=expires_in_days,
  1558. scope=scope,
  1559. )
  1560. except ValueError as e:
  1561. raise HTTPException(status_code=400, detail=str(e))
  1562. _logger.info(
  1563. "Long-lived camera token created: user=%s name=%r scope=%s expires=%s",
  1564. current_user.username,
  1565. name,
  1566. scope,
  1567. created.record.expires_at.isoformat(),
  1568. )
  1569. response.headers["Cache-Control"] = "no-store"
  1570. return _long_lived_token_to_response(created.record, plaintext=created.plaintext)
  1571. @router.get("/tokens", response_model=list[dict])
  1572. async def list_long_lived_tokens(
  1573. user_id: int | None = None,
  1574. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
  1575. db: AsyncSession = Depends(get_db),
  1576. ):
  1577. """List long-lived tokens.
  1578. Default: caller's own tokens.
  1579. Admins can pass ``?user_id=N`` to see another user's tokens, or omit it
  1580. to see everything (handy for leak triage).
  1581. """
  1582. from backend.app.services.long_lived_tokens import list_user_tokens
  1583. # Auth-disabled installs don't have a notion of "my tokens" — refuse so
  1584. # we don't leak a global list to whoever can hit the API.
  1585. if current_user is None:
  1586. raise HTTPException(
  1587. status_code=status.HTTP_403_FORBIDDEN,
  1588. detail="Long-lived tokens require authentication to be enabled",
  1589. )
  1590. # Reload with groups so is_admin reflects group membership reliably.
  1591. user_with_groups = (
  1592. await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  1593. ).scalar_one()
  1594. if user_id is None or user_id == current_user.id:
  1595. records = await list_user_tokens(db, current_user.id)
  1596. elif user_with_groups.is_admin:
  1597. records = await list_user_tokens(db, user_id)
  1598. else:
  1599. raise HTTPException(
  1600. status_code=status.HTTP_403_FORBIDDEN,
  1601. detail="Only admins can list other users' tokens",
  1602. )
  1603. return [_long_lived_token_to_response(r) for r in records]
  1604. @router.get("/tokens/all", response_model=list[dict])
  1605. async def list_all_long_lived_tokens(
  1606. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
  1607. db: AsyncSession = Depends(get_db),
  1608. ):
  1609. """Admin-only: every active long-lived token in the system, newest first.
  1610. Used by the leak-triage view in admin settings.
  1611. """
  1612. from backend.app.services.long_lived_tokens import list_all_tokens
  1613. if current_user is None:
  1614. raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Auth required")
  1615. user_with_groups = (
  1616. await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  1617. ).scalar_one()
  1618. if not user_with_groups.is_admin:
  1619. raise HTTPException(
  1620. status_code=status.HTTP_403_FORBIDDEN,
  1621. detail="Admin only",
  1622. )
  1623. records = await list_all_tokens(db)
  1624. return [_long_lived_token_to_response(r) for r in records]
  1625. @router.delete("/tokens/{token_id}", status_code=status.HTTP_204_NO_CONTENT)
  1626. async def revoke_long_lived_token(
  1627. token_id: int,
  1628. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.CAMERA_VIEW),
  1629. db: AsyncSession = Depends(get_db),
  1630. ):
  1631. """Revoke a long-lived token. Owners can revoke their own; admins any."""
  1632. from backend.app.models.long_lived_token import LongLivedToken
  1633. from backend.app.services.long_lived_tokens import revoke_token
  1634. if current_user is None:
  1635. raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Auth required")
  1636. record = (await db.execute(select(LongLivedToken).where(LongLivedToken.id == token_id))).scalar_one_or_none()
  1637. if record is None:
  1638. raise HTTPException(status_code=404, detail="Token not found")
  1639. if record.user_id != current_user.id:
  1640. # Reload for is_admin so admins can revoke any user's token (leak response).
  1641. user_with_groups = (
  1642. await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  1643. ).scalar_one()
  1644. if not user_with_groups.is_admin:
  1645. raise HTTPException(
  1646. status_code=status.HTTP_403_FORBIDDEN,
  1647. detail="You can only revoke your own tokens",
  1648. )
  1649. revoked = await revoke_token(db, token_id)
  1650. if not revoked:
  1651. # Already revoked is treated as 404 for idempotency from the UI side.
  1652. raise HTTPException(status_code=404, detail="Token not found or already revoked")
  1653. _logger.info(
  1654. "Long-lived camera token revoked: id=%d by user=%s",
  1655. token_id,
  1656. current_user.username,
  1657. )
  1658. return Response(status_code=status.HTTP_204_NO_CONTENT)
  1659. @router.get("/encryption-status", response_model=EncryptionStatusResponse)
  1660. async def get_encryption_status(
  1661. _: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  1662. db: AsyncSession = Depends(get_db),
  1663. ) -> EncryptionStatusResponse:
  1664. """Report at-rest encryption status for OIDC + TOTP secrets.
  1665. Surfaces:
  1666. (a) whether a key is configured and where it came from
  1667. (b) how many rows are still legacy plaintext
  1668. (c) whether decryption is broken (no key OR key cannot decrypt existing rows)
  1669. (d) the count of rows skipped during the last re-encryption migration
  1670. S2: gated on SETTINGS_UPDATE so Viewers (who only have SETTINGS_READ)
  1671. cannot read encryption-status — admin/operator only.
  1672. """
  1673. from sqlalchemy import case, func, not_, select
  1674. from backend.app.core.database import get_migration_error_count
  1675. from backend.app.core.encryption import get_key_source, is_encryption_active, mfa_decrypt
  1676. from backend.app.models.oidc_provider import OIDCProvider
  1677. from backend.app.models.user_totp import UserTOTP
  1678. key_configured = is_encryption_active()
  1679. key_source = get_key_source() or "none"
  1680. try:
  1681. oidc_row = await db.execute(
  1682. select(
  1683. func.sum(case((not_(OIDCProvider._client_secret_enc.like("fernet:%")), 1), else_=0)),
  1684. func.sum(case((OIDCProvider._client_secret_enc.like("fernet:%"), 1), else_=0)),
  1685. )
  1686. )
  1687. legacy_oidc, encrypted_oidc = oidc_row.one()
  1688. totp_row = await db.execute(
  1689. select(
  1690. func.sum(case((not_(UserTOTP._secret_enc.like("fernet:%")), 1), else_=0)),
  1691. func.sum(case((UserTOTP._secret_enc.like("fernet:%"), 1), else_=0)),
  1692. )
  1693. )
  1694. legacy_totp, encrypted_totp = totp_row.one()
  1695. except SQLAlchemyError:
  1696. _logger.exception("Failed to query encryption row counts")
  1697. raise HTTPException(status_code=500, detail="Failed to retrieve encryption status")
  1698. legacy_plaintext_rows = EncryptionRowCounts(
  1699. oidc_providers=int(legacy_oidc or 0),
  1700. user_totp=int(legacy_totp or 0),
  1701. )
  1702. encrypted_rows = EncryptionRowCounts(
  1703. oidc_providers=int(encrypted_oidc or 0),
  1704. user_totp=int(encrypted_totp or 0),
  1705. )
  1706. # B4: detect "wrong key" state — sample-decrypt one encrypted row to
  1707. # distinguish "no key" from "key configured but cannot decrypt these rows".
  1708. # The legacy computed-field check (key_configured=False AND encrypted>0)
  1709. # missed the case where an operator pasted a different valid Fernet key
  1710. # (rotation, cross-deployment restore, env override) — status would show
  1711. # green while every encrypted row was unrecoverable.
  1712. decryption_broken = False
  1713. total_encrypted = encrypted_rows.oidc_providers + encrypted_rows.user_totp
  1714. if not key_configured and total_encrypted > 0:
  1715. decryption_broken = True
  1716. elif key_configured and total_encrypted > 0:
  1717. sample_value: str | None = None
  1718. try:
  1719. if encrypted_rows.oidc_providers > 0:
  1720. r = await db.execute(
  1721. select(OIDCProvider._client_secret_enc)
  1722. .where(OIDCProvider._client_secret_enc.like("fernet:%"))
  1723. .limit(1)
  1724. )
  1725. sample_value = r.scalar_one_or_none()
  1726. if sample_value is None and encrypted_rows.user_totp > 0:
  1727. r = await db.execute(select(UserTOTP._secret_enc).where(UserTOTP._secret_enc.like("fernet:%")).limit(1))
  1728. sample_value = r.scalar_one_or_none()
  1729. except SQLAlchemyError:
  1730. _logger.exception("Failed to query sample encrypted row for decryption probe")
  1731. # Over-alert is safer than silent corruption — surface as broken.
  1732. decryption_broken = True
  1733. sample_value = None
  1734. if sample_value:
  1735. try:
  1736. mfa_decrypt(sample_value)
  1737. except RuntimeError:
  1738. decryption_broken = True
  1739. return EncryptionStatusResponse(
  1740. key_configured=key_configured,
  1741. key_source=key_source,
  1742. legacy_plaintext_rows=legacy_plaintext_rows,
  1743. encrypted_rows=encrypted_rows,
  1744. decryption_broken=decryption_broken,
  1745. migration_error_count=get_migration_error_count(),
  1746. )