auth.py 50 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264126512661267
  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, select
  11. from sqlalchemy.ext.asyncio import AsyncSession
  12. from sqlalchemy.orm import selectinload
  13. from backend.app.api.routes.settings import get_external_login_url
  14. from backend.app.core.auth import (
  15. ACCESS_TOKEN_EXPIRE_MINUTES,
  16. ALGORITHM,
  17. SECRET_KEY,
  18. Permission,
  19. RequirePermissionIfAuthEnabled,
  20. _is_token_fresh,
  21. _validate_api_key,
  22. authenticate_user,
  23. authenticate_user_by_email,
  24. create_access_token,
  25. get_current_active_user,
  26. get_password_hash,
  27. get_user_by_email,
  28. get_user_by_username,
  29. is_jti_revoked,
  30. revoke_jti,
  31. security,
  32. )
  33. from backend.app.core.database import async_session, get_db
  34. from backend.app.core.permissions import ALL_PERMISSIONS
  35. from backend.app.models.auth_ephemeral import AuthEphemeralToken, AuthRateLimitEvent, EventType, TokenType
  36. from backend.app.models.group import Group
  37. from backend.app.models.settings import Settings
  38. from backend.app.models.user import User
  39. from backend.app.schemas.auth import (
  40. ForgotPasswordConfirmRequest,
  41. ForgotPasswordRequest,
  42. ForgotPasswordResponse,
  43. GroupBrief,
  44. LoginRequest,
  45. LoginResponse,
  46. ResetPasswordRequest,
  47. ResetPasswordResponse,
  48. SetupRequest,
  49. SetupResponse,
  50. SMTPSettings,
  51. TestSMTPRequest,
  52. TestSMTPResponse,
  53. UserResponse,
  54. )
  55. from backend.app.services.email_service import (
  56. create_password_reset_link_email_from_template,
  57. get_smtp_settings,
  58. save_smtp_settings,
  59. send_email,
  60. )
  61. _logger = logging.getLogger(__name__)
  62. def _user_to_response(user: User) -> UserResponse:
  63. """Convert a User model to UserResponse schema."""
  64. return UserResponse(
  65. id=user.id,
  66. username=user.username,
  67. email=user.email,
  68. role=user.role,
  69. is_active=user.is_active,
  70. is_admin=user.is_admin,
  71. auth_source=getattr(user, "auth_source", "local"),
  72. groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
  73. permissions=sorted(user.get_permissions()),
  74. created_at=user.created_at.isoformat(),
  75. )
  76. def _api_key_to_user_response(api_key) -> UserResponse:
  77. """Create a synthetic admin UserResponse for a valid API key."""
  78. return UserResponse(
  79. id=0,
  80. username=f"api-key:{api_key.key_prefix}",
  81. email=None,
  82. role="admin",
  83. is_active=True,
  84. is_admin=True,
  85. groups=[],
  86. permissions=sorted(ALL_PERMISSIONS),
  87. created_at=api_key.created_at.isoformat(),
  88. )
  89. # ---------------------------------------------------------------------------
  90. # M-R9-A: Real client IP resolution for rate limiting behind reverse proxies.
  91. # Set TRUSTED_PROXY_IPS (comma-separated) to enable X-Forwarded-For trust.
  92. # Without this env var client.host is used directly (safe default).
  93. # ---------------------------------------------------------------------------
  94. _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
  95. ip.strip() for ip in os.environ.get("TRUSTED_PROXY_IPS", "").split(",") if ip.strip()
  96. )
  97. def _get_client_ip(request: Request) -> str:
  98. """Return the real client IP for rate-limiting purposes.
  99. When TRUSTED_PROXY_IPS is configured and the direct TCP peer is a trusted
  100. proxy, X-Forwarded-For is evaluated right-to-left: the rightmost IP that is
  101. NOT itself a trusted proxy is the true client address (M-R10-A fix).
  102. Standard nginx with proxy_add_x_forwarded_for *appends* the client IP, so
  103. the rightmost entry is always the one added by the last trusted proxy —
  104. i.e. the real client. Walking right-to-left and skipping known proxies is
  105. safe for multi-hop chains as well.
  106. Falls back to request.client.host when TRUSTED_PROXY_IPS is unset (direct
  107. deployment without a reverse proxy).
  108. """
  109. # I5: Use a per-request unique token instead of "unknown" when the transport
  110. # layer provides no client address. This prevents all such requests from
  111. # sharing one rate-limit bucket, and avoids collision with a literal username
  112. # "unknown". The token is not stable across requests, which is intentional:
  113. # we cannot track the IP so we also cannot rate-limit by it meaningfully.
  114. direct_ip = request.client.host if request.client else f"__no_ip_{secrets.token_hex(8)}__"
  115. if _TRUSTED_PROXY_IPS and direct_ip in _TRUSTED_PROXY_IPS:
  116. forwarded_for = request.headers.get("X-Forwarded-For", "")
  117. ips = [ip.strip() for ip in forwarded_for.split(",") if ip.strip()]
  118. # Walk right-to-left; skip IPs that belong to trusted proxies.
  119. for ip in reversed(ips):
  120. if ip not in _TRUSTED_PROXY_IPS:
  121. return ip
  122. # Edge case: every entry is a trusted proxy — fall back to leftmost.
  123. if ips:
  124. return ips[0]
  125. return direct_ip
  126. router = APIRouter(prefix="/auth", tags=["authentication"])
  127. async def is_auth_enabled(db: AsyncSession) -> bool:
  128. """Check if authentication is enabled."""
  129. result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
  130. setting = result.scalar_one_or_none()
  131. if setting is None:
  132. return False
  133. return setting.value.lower() == "true"
  134. async def is_advanced_auth_enabled(db: AsyncSession) -> bool:
  135. """Check if advanced authentication is enabled."""
  136. result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
  137. setting = result.scalar_one_or_none()
  138. if setting is None:
  139. return False
  140. return setting.value.lower() == "true"
  141. async def set_advanced_auth_enabled(db: AsyncSession, enabled: bool) -> None:
  142. """Set advanced authentication enabled status."""
  143. from backend.app.core.db_dialect import upsert_setting
  144. await upsert_setting(db, Settings, "advanced_auth_enabled", "true" if enabled else "false")
  145. async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
  146. """Set authentication enabled status."""
  147. from backend.app.core.db_dialect import upsert_setting
  148. await upsert_setting(db, Settings, "auth_enabled", "true" if enabled else "false")
  149. # Note: Don't commit here - let get_db handle it or commit explicitly in the route
  150. async def is_setup_completed(db: AsyncSession) -> bool:
  151. """Check if setup has been completed."""
  152. result = await db.execute(select(Settings).where(Settings.key == "setup_completed"))
  153. setting = result.scalar_one_or_none()
  154. return setting and setting.value.lower() == "true"
  155. async def set_setup_completed(db: AsyncSession, completed: bool) -> None:
  156. """Set setup completed status."""
  157. from backend.app.core.db_dialect import upsert_setting
  158. await upsert_setting(db, Settings, "setup_completed", "true" if completed else "false")
  159. # Note: Don't commit here - let get_db handle it or commit explicitly in the route
  160. @router.post("/setup", response_model=SetupResponse)
  161. async def setup_auth(request: SetupRequest, db: AsyncSession = Depends(get_db)):
  162. """First-time setup: enable/disable authentication and create admin user."""
  163. import logging
  164. logger = logging.getLogger(__name__)
  165. try:
  166. # If auth is currently enabled, block unauthenticated setup changes.
  167. # Use the admin panel (/disable endpoint) to modify auth when it's already on.
  168. if await is_auth_enabled(db):
  169. raise HTTPException(
  170. status_code=status.HTTP_403_FORBIDDEN,
  171. detail="Authentication is already configured. Use the admin panel to modify auth settings.",
  172. )
  173. admin_created = False
  174. if request.auth_enabled:
  175. # Check if admin users already exist
  176. admin_users_result = await db.execute(select(User).where(User.role == "admin"))
  177. existing_admin_users = list(admin_users_result.scalars().all())
  178. has_admin_users = len(existing_admin_users) > 0
  179. if has_admin_users:
  180. # Admin users already exist, just enable auth (don't create new admin)
  181. logger.info(
  182. f"Admin users already exist ({len(existing_admin_users)} found), enabling authentication without creating new admin"
  183. )
  184. admin_created = False
  185. else:
  186. # No admin users exist, require admin credentials to create first admin
  187. if not request.admin_username or not request.admin_password:
  188. raise HTTPException(
  189. status_code=status.HTTP_400_BAD_REQUEST,
  190. detail="Admin username and password are required when enabling authentication (no admin users exist)",
  191. )
  192. # Check if username already exists (shouldn't happen if no admin users exist, but check anyway)
  193. existing_user = await get_user_by_username(db, request.admin_username)
  194. if existing_user:
  195. raise HTTPException(
  196. status_code=status.HTTP_400_BAD_REQUEST,
  197. detail="User with this username already exists",
  198. )
  199. # Create admin user FIRST (before enabling auth)
  200. try:
  201. logger.info("Creating admin user: %s", request.admin_username)
  202. admin_user = User(
  203. username=request.admin_username,
  204. password_hash=get_password_hash(request.admin_password),
  205. role="admin",
  206. is_active=True,
  207. )
  208. # Try to add user to Administrators group if it exists
  209. admin_group_result = await db.execute(select(Group).where(Group.name == "Administrators"))
  210. admin_group = admin_group_result.scalar_one_or_none()
  211. if admin_group:
  212. admin_user.groups.append(admin_group)
  213. logger.info("Added new admin user to Administrators group")
  214. db.add(admin_user)
  215. logger.info("Admin user added to session: %s", request.admin_username)
  216. admin_created = True
  217. except Exception as e:
  218. await db.rollback()
  219. logger.error("Failed to create admin user: %s", e, exc_info=True)
  220. raise HTTPException(
  221. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  222. detail="Failed to create admin user",
  223. )
  224. # Set auth enabled and mark setup as completed
  225. await set_auth_enabled(db, request.auth_enabled)
  226. await set_setup_completed(db, True)
  227. await db.commit()
  228. if admin_created:
  229. await db.refresh(admin_user)
  230. logger.info("Admin user created successfully: %s", admin_user.id)
  231. logger.info("Setup completed: auth_enabled=%s, admin_created=%s", request.auth_enabled, admin_created)
  232. return SetupResponse(auth_enabled=request.auth_enabled, admin_created=admin_created)
  233. except HTTPException:
  234. raise
  235. except Exception as e:
  236. logger.error("Setup error: %s", e, exc_info=True)
  237. await db.rollback()
  238. raise HTTPException(
  239. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  240. detail="Setup failed",
  241. )
  242. @router.get("/status")
  243. async def get_auth_status(db: AsyncSession = Depends(get_db)):
  244. """Get authentication status (public endpoint)."""
  245. auth_enabled = await is_auth_enabled(db)
  246. setup_completed = await is_setup_completed(db)
  247. # Only require setup if it hasn't been completed yet
  248. requires_setup = not setup_completed
  249. return {"auth_enabled": auth_enabled, "requires_setup": requires_setup}
  250. @router.post("/disable", response_model=dict)
  251. async def disable_auth(
  252. current_user: User = Depends(get_current_active_user),
  253. db: AsyncSession = Depends(get_db),
  254. ):
  255. """Disable authentication (admin only)."""
  256. import logging
  257. logger = logging.getLogger(__name__)
  258. # Reload user with groups for proper is_admin check
  259. result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  260. user = result.scalar_one()
  261. # Only admins can disable authentication
  262. if not user.is_admin:
  263. raise HTTPException(
  264. status_code=status.HTTP_403_FORBIDDEN,
  265. detail="Only admins can disable authentication",
  266. )
  267. try:
  268. await set_auth_enabled(db, False)
  269. await db.commit()
  270. logger.info("Authentication disabled by admin user: %s", user.username)
  271. return {"message": "Authentication disabled successfully", "auth_enabled": False}
  272. except Exception as e:
  273. await db.rollback()
  274. logger.error("Failed to disable authentication: %s", e, exc_info=True)
  275. raise HTTPException(
  276. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  277. detail="Failed to disable authentication",
  278. )
  279. @router.post("/login", response_model=LoginResponse)
  280. async def login(raw_request: Request, request: LoginRequest, response: Response, db: AsyncSession = Depends(get_db)):
  281. """Login and get access token.
  282. Supports username or email-based login. Username lookup is case-insensitive.
  283. When 2FA is enabled for the user the response contains ``requires_2fa=True``
  284. and a short-lived ``pre_auth_token`` instead of the final JWT. The client
  285. must then call ``POST /auth/2fa/verify`` (or first ``POST /auth/2fa/email/send``
  286. to trigger an email OTP) to obtain the real access token.
  287. """
  288. # Check if auth is enabled
  289. auth_enabled = await is_auth_enabled(db)
  290. if not auth_enabled:
  291. raise HTTPException(
  292. status_code=status.HTTP_400_BAD_REQUEST,
  293. detail="Authentication is not enabled",
  294. )
  295. # Rate-limit repeated login failures — two independent buckets (M-R5-B / M-R6-A):
  296. # 1. Per-username (10/15 min): prevents password brute-force on a known account.
  297. # 2. Per-IP (20/15 min): prevents an attacker from locking out arbitrary accounts
  298. # (DoS) by sending failures for many usernames from a single address.
  299. from backend.app.api.routes.mfa import MAX_LOGIN_ATTEMPTS, check_rate_limit, record_failed_attempt
  300. await check_rate_limit(db, request.username, event_type=EventType.LOGIN_ATTEMPT, max_attempts=MAX_LOGIN_ATTEMPTS)
  301. client_ip = _get_client_ip(raw_request)
  302. await check_rate_limit(db, client_ip, event_type=EventType.LOGIN_IP, max_attempts=20)
  303. # Check if LDAP is enabled
  304. ldap_user = None
  305. ldap_settings = await _get_ldap_settings(db)
  306. if ldap_settings:
  307. try:
  308. from backend.app.services.ldap_service import (
  309. authenticate_ldap_user,
  310. parse_ldap_config,
  311. )
  312. ldap_config = parse_ldap_config(ldap_settings)
  313. if ldap_config:
  314. ldap_user = authenticate_ldap_user(ldap_config, request.username, request.password)
  315. if ldap_user:
  316. # LDAP auth succeeded — find or create local user
  317. user = await get_user_by_username(db, ldap_user.username)
  318. if user and user.auth_source != "ldap":
  319. # Username exists as local user — don't override
  320. user = None
  321. ldap_user = None
  322. elif not user:
  323. if not ldap_config.auto_provision:
  324. # User doesn't exist and auto-provision is off
  325. ldap_user = None
  326. else:
  327. # Auto-provision LDAP user
  328. user = await _provision_ldap_user(db, ldap_user, ldap_config)
  329. if user and ldap_user:
  330. # Update email and group mappings on each login
  331. await _sync_ldap_user(db, user, ldap_user, ldap_config)
  332. except Exception as e:
  333. import logging
  334. logging.getLogger(__name__).warning("LDAP authentication error, falling back to local: %s", e)
  335. ldap_user = None
  336. # Try username-based authentication (skip if already authenticated via LDAP)
  337. if not ldap_user:
  338. user = await authenticate_user(db, request.username, request.password)
  339. # If username auth failed and advanced auth is enabled, try email-based authentication
  340. if not user and not ldap_user:
  341. advanced_auth = await is_advanced_auth_enabled(db)
  342. if advanced_auth:
  343. user = await authenticate_user_by_email(db, request.username, request.password)
  344. if not user:
  345. await record_failed_attempt(db, request.username, event_type=EventType.LOGIN_ATTEMPT)
  346. await record_failed_attempt(db, client_ip, event_type=EventType.LOGIN_IP)
  347. raise HTTPException(
  348. status_code=status.HTTP_401_UNAUTHORIZED,
  349. detail="Incorrect username or password",
  350. headers={"WWW-Authenticate": "Bearer"},
  351. )
  352. # Reload user with groups for proper permission calculation
  353. result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
  354. user = result.scalar_one()
  355. # L-R6-A: Password was correct — reset login failure counters for both buckets
  356. from backend.app.api.routes.mfa import clear_failed_attempts
  357. await clear_failed_attempts(db, user.username, event_type=EventType.LOGIN_ATTEMPT)
  358. await clear_failed_attempts(db, client_ip, event_type=EventType.LOGIN_IP)
  359. # --- 2FA check ---
  360. # Determine which 2FA methods are active for this user.
  361. from backend.app.models.settings import Settings as _Settings
  362. from backend.app.models.user_totp import UserTOTP
  363. totp_result = await db.execute(select(UserTOTP).where(UserTOTP.user_id == user.id))
  364. user_totp = totp_result.scalar_one_or_none()
  365. totp_enabled = user_totp is not None and user_totp.is_enabled
  366. email_2fa_result = await db.execute(select(_Settings).where(_Settings.key == f"user_{user.id}_email_2fa_enabled"))
  367. email_2fa_setting = email_2fa_result.scalar_one_or_none()
  368. email_otp_enabled = (
  369. email_2fa_setting is not None and email_2fa_setting.value.lower() == "true" and user.email is not None
  370. )
  371. if totp_enabled or email_otp_enabled:
  372. # Import here to avoid circular imports
  373. from backend.app.api.routes.mfa import create_pre_auth_token
  374. # Bind the pre_auth_token to an HttpOnly cookie so XSS cannot steal the
  375. # token from JS memory and complete 2FA from a different client.
  376. challenge_id = secrets.token_urlsafe(32)
  377. pre_auth_token = await create_pre_auth_token(db, user.username, challenge_id=challenge_id)
  378. response.set_cookie(
  379. key="2fa_challenge",
  380. value=challenge_id,
  381. httponly=True,
  382. # H-1: only transmit over HTTPS so the binding cookie can't be intercepted
  383. # on mixed-content deployments. Falls back to False on plain HTTP so tests
  384. # and local development still work (the client wouldn't send it otherwise).
  385. secure=raw_request.url.scheme == "https",
  386. samesite="lax",
  387. max_age=300,
  388. path="/api/v1/auth/2fa",
  389. )
  390. methods: list[str] = []
  391. if totp_enabled:
  392. methods.append("totp")
  393. if email_otp_enabled:
  394. methods.append("email")
  395. # Backup codes are always available when TOTP is set up
  396. if totp_enabled:
  397. methods.append("backup")
  398. return LoginResponse(
  399. requires_2fa=True,
  400. pre_auth_token=pre_auth_token,
  401. two_fa_methods=methods,
  402. )
  403. # No 2FA — issue full token immediately
  404. access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
  405. access_token = create_access_token(data={"sub": user.username}, expires_delta=access_token_expires)
  406. return LoginResponse(
  407. access_token=access_token,
  408. token_type="bearer",
  409. user=_user_to_response(user),
  410. )
  411. @router.get("/me", response_model=UserResponse)
  412. async def get_current_user_info(
  413. credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
  414. x_api_key: Annotated[str | None, Header(alias="X-API-Key")] = None,
  415. db: AsyncSession = Depends(get_db),
  416. ):
  417. """Get current user information.
  418. Accepts JWT tokens (via Authorization: Bearer header) and API keys
  419. (via X-API-Key header or Authorization: Bearer bb_xxx).
  420. API keys return a synthetic admin user with all permissions.
  421. """
  422. import jwt
  423. from jwt.exceptions import PyJWTError as JWTError
  424. # Check for API key via X-API-Key header
  425. if x_api_key:
  426. api_key = await _validate_api_key(db, x_api_key)
  427. if api_key:
  428. return _api_key_to_user_response(api_key)
  429. # Check for Bearer token (could be JWT or API key)
  430. if credentials is not None:
  431. token = credentials.credentials
  432. # Check if it's an API key (starts with bb_)
  433. if token.startswith("bb_"):
  434. api_key = await _validate_api_key(db, token)
  435. if api_key:
  436. return _api_key_to_user_response(api_key)
  437. raise HTTPException(
  438. status_code=status.HTTP_401_UNAUTHORIZED,
  439. detail="Invalid API key",
  440. headers={"WWW-Authenticate": "Bearer"},
  441. )
  442. # Otherwise treat as JWT
  443. try:
  444. payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
  445. username: str = payload.get("sub")
  446. if username is None:
  447. raise HTTPException(
  448. status_code=status.HTTP_401_UNAUTHORIZED,
  449. detail="Could not validate credentials",
  450. headers={"WWW-Authenticate": "Bearer"},
  451. )
  452. jti: str | None = payload.get("jti")
  453. if not jti or await is_jti_revoked(jti): # B1: logout bypass fix
  454. raise HTTPException(
  455. status_code=status.HTTP_401_UNAUTHORIZED,
  456. detail="Could not validate credentials",
  457. headers={"WWW-Authenticate": "Bearer"},
  458. )
  459. iat: int | float | None = payload.get("iat")
  460. except JWTError:
  461. raise HTTPException(
  462. status_code=status.HTTP_401_UNAUTHORIZED,
  463. detail="Could not validate credentials",
  464. headers={"WWW-Authenticate": "Bearer"},
  465. )
  466. user = await get_user_by_username(db, username)
  467. if user is None or not user.is_active:
  468. raise HTTPException(
  469. status_code=status.HTTP_401_UNAUTHORIZED,
  470. detail="Could not validate credentials",
  471. headers={"WWW-Authenticate": "Bearer"},
  472. )
  473. # Reload with groups for proper permission calculation
  474. result = await db.execute(select(User).where(User.id == user.id).options(selectinload(User.groups)))
  475. user = result.scalar_one()
  476. # L-R8-A: reject tokens issued before the last password change
  477. if not _is_token_fresh(iat, user):
  478. raise HTTPException(
  479. status_code=status.HTTP_401_UNAUTHORIZED,
  480. detail="Could not validate credentials",
  481. headers={"WWW-Authenticate": "Bearer"},
  482. )
  483. return _user_to_response(user)
  484. # No credentials provided
  485. raise HTTPException(
  486. status_code=status.HTTP_401_UNAUTHORIZED,
  487. detail="Authentication required",
  488. headers={"WWW-Authenticate": "Bearer"},
  489. )
  490. @router.post("/logout")
  491. async def logout(
  492. raw_request: Request,
  493. credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
  494. ):
  495. """Logout — revokes the current JWT so it cannot be reused after logout."""
  496. if credentials is not None:
  497. raw_token = credentials.credentials
  498. # Nit2: Verify signature before revoking to prevent DoS-revoke attacks
  499. # (an attacker crafting a token with an arbitrary jti cannot force
  500. # revocation of a legitimate token because the signature check rejects it).
  501. # Expired tokens are still accepted — the user is logging out and their
  502. # token may have just expired; we still want to record the revocation.
  503. try:
  504. verified = _jwt.decode(
  505. raw_token,
  506. SECRET_KEY,
  507. algorithms=[ALGORITHM],
  508. options={"verify_exp": False}, # allow expired tokens at logout
  509. )
  510. jti: str | None = verified.get("jti")
  511. exp = verified.get("exp")
  512. username: str | None = verified.get("sub")
  513. if jti and exp:
  514. expires_at = datetime.fromtimestamp(exp, tz=timezone.utc)
  515. try:
  516. await revoke_jti(jti, expires_at, username)
  517. except Exception as exc:
  518. _logger.error("Failed to revoke JTI on logout for user %s: %s", username, exc)
  519. except PyJWTError:
  520. client_ip = _get_client_ip(raw_request)
  521. ua = raw_request.headers.get("user-agent", "<unknown>")
  522. _logger.error(
  523. "Logout received token that failed signature verification — skipping revocation "
  524. "(possible tamper attempt; ip=%s ua=%s)",
  525. client_ip,
  526. ua,
  527. )
  528. return {"message": "Logged out successfully"}
  529. # Advanced Authentication Endpoints
  530. @router.post("/smtp/test", response_model=TestSMTPResponse)
  531. async def test_smtp_connection(
  532. test_request: TestSMTPRequest,
  533. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  534. db: AsyncSession = Depends(get_db),
  535. ):
  536. """Test SMTP connection using saved settings (admin only when auth enabled)."""
  537. import logging
  538. logger = logging.getLogger(__name__)
  539. try:
  540. smtp_settings = await get_smtp_settings(db)
  541. if not smtp_settings:
  542. return TestSMTPResponse(success=False, message="SMTP settings not configured. Save SMTP settings first.")
  543. # Send test email
  544. send_email(
  545. smtp_settings=smtp_settings,
  546. to_email=test_request.test_recipient,
  547. subject="BamBuddy SMTP Test",
  548. body_text="This is a test email from BamBuddy. If you received this, your SMTP settings are working correctly!",
  549. 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>",
  550. )
  551. logger.info(f"Test email sent successfully to {test_request.test_recipient}")
  552. return TestSMTPResponse(success=True, message="Test email sent successfully")
  553. except Exception as e:
  554. logger.error("Failed to send test email: %s", e)
  555. return TestSMTPResponse(success=False, message="Failed to send test email")
  556. @router.get("/smtp", response_model=SMTPSettings | None)
  557. async def get_smtp_config(
  558. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_READ),
  559. db: AsyncSession = Depends(get_db),
  560. ):
  561. """Get SMTP settings (admin only when auth enabled). Password is not returned."""
  562. smtp_settings = await get_smtp_settings(db)
  563. if smtp_settings:
  564. # Don't return password in response
  565. smtp_settings.smtp_password = None
  566. return smtp_settings
  567. @router.post("/smtp", response_model=dict)
  568. async def save_smtp_config(
  569. smtp_settings: SMTPSettings,
  570. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  571. db: AsyncSession = Depends(get_db),
  572. ):
  573. """Save SMTP settings (admin only when auth enabled)."""
  574. import logging
  575. logger = logging.getLogger(__name__)
  576. try:
  577. await save_smtp_settings(db, smtp_settings)
  578. await db.commit()
  579. logger.info(f"SMTP settings updated by admin user: {current_user.username if current_user else 'anonymous'}")
  580. return {"message": "SMTP settings saved successfully"}
  581. except Exception as e:
  582. await db.rollback()
  583. logger.error("Failed to save SMTP settings: %s", e)
  584. raise HTTPException(
  585. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  586. detail="Failed to save SMTP settings",
  587. )
  588. @router.post("/advanced-auth/enable", response_model=dict)
  589. async def enable_advanced_auth(
  590. current_user: User = Depends(get_current_active_user),
  591. db: AsyncSession = Depends(get_db),
  592. ):
  593. """Enable advanced authentication (admin only).
  594. Requires SMTP settings to be configured and tested first.
  595. """
  596. import logging
  597. logger = logging.getLogger(__name__)
  598. # Reload user with groups for proper is_admin check
  599. result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  600. user = result.scalar_one()
  601. if not user.is_admin:
  602. raise HTTPException(
  603. status_code=status.HTTP_403_FORBIDDEN,
  604. detail="Only admins can enable advanced authentication",
  605. )
  606. # Verify SMTP settings are configured
  607. smtp_settings = await get_smtp_settings(db)
  608. if not smtp_settings:
  609. raise HTTPException(
  610. status_code=status.HTTP_400_BAD_REQUEST,
  611. detail="SMTP settings must be configured before enabling advanced authentication",
  612. )
  613. try:
  614. await set_advanced_auth_enabled(db, True)
  615. await db.commit()
  616. logger.info(f"Advanced authentication enabled by admin user: {user.username}")
  617. return {"message": "Advanced authentication enabled successfully", "advanced_auth_enabled": True}
  618. except Exception as e:
  619. await db.rollback()
  620. logger.error("Failed to enable advanced authentication: %s", e)
  621. raise HTTPException(
  622. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  623. detail="Failed to enable advanced authentication",
  624. )
  625. @router.post("/advanced-auth/disable", response_model=dict)
  626. async def disable_advanced_auth(
  627. current_user: User = Depends(get_current_active_user),
  628. db: AsyncSession = Depends(get_db),
  629. ):
  630. """Disable advanced authentication (admin only)."""
  631. import logging
  632. logger = logging.getLogger(__name__)
  633. # Reload user with groups for proper is_admin check
  634. result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  635. user = result.scalar_one()
  636. if not user.is_admin:
  637. raise HTTPException(
  638. status_code=status.HTTP_403_FORBIDDEN,
  639. detail="Only admins can disable advanced authentication",
  640. )
  641. try:
  642. await set_advanced_auth_enabled(db, False)
  643. await db.commit()
  644. logger.info(f"Advanced authentication disabled by admin user: {user.username}")
  645. return {"message": "Advanced authentication disabled successfully", "advanced_auth_enabled": False}
  646. except Exception as e:
  647. await db.rollback()
  648. logger.error("Failed to disable advanced authentication: %s", e)
  649. raise HTTPException(
  650. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  651. detail="Failed to disable advanced authentication",
  652. )
  653. @router.get("/advanced-auth/status")
  654. async def get_advanced_auth_status(db: AsyncSession = Depends(get_db)):
  655. """Get advanced authentication status."""
  656. advanced_auth_enabled = await is_advanced_auth_enabled(db)
  657. smtp_configured = await get_smtp_settings(db) is not None
  658. return {
  659. "advanced_auth_enabled": advanced_auth_enabled,
  660. "smtp_configured": smtp_configured,
  661. }
  662. # TTL for password-reset tokens (H-6)
  663. _RESET_TOKEN_TTL = timedelta(hours=1)
  664. # Rate-limit for password-reset email sends per identifier (M-A)
  665. _MAX_PWD_RESET_SENDS = 3
  666. _PWD_RESET_SEND_WINDOW = timedelta(minutes=15)
  667. # L-NEW-6: per-IP cap to prevent mass-reset flooding across many addresses
  668. _MAX_PWD_RESET_SENDS_PER_IP = 10
  669. async def _send_reset_email_or_delete_token(
  670. reset_token: str,
  671. smtp_settings,
  672. to_email: str,
  673. subject: str,
  674. text_body: str,
  675. html_body: str,
  676. log_label: str,
  677. ) -> None:
  678. """Background task: send a password-reset email and delete the token on failure.
  679. C1: FastAPI silently swallows BackgroundTask exceptions. This wrapper
  680. catches send failures, deletes the single-use token so it cannot be used
  681. (user is not locked out forever — they can request a new link), and logs at
  682. ERROR so operators are alerted without leaking details to the caller.
  683. """
  684. try:
  685. send_email(smtp_settings, to_email, subject, text_body, html_body)
  686. _logger.info("Password reset email sent (%s) to %s", log_label, to_email)
  687. except Exception as exc:
  688. _logger.error(
  689. "Password reset email failed (%s) to %s — deleting token to unblock re-request: %s",
  690. log_label,
  691. to_email,
  692. exc,
  693. )
  694. try:
  695. async with async_session() as db:
  696. await db.execute(
  697. delete(AuthEphemeralToken).where(
  698. AuthEphemeralToken.token == reset_token,
  699. AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
  700. )
  701. )
  702. await db.commit()
  703. except Exception as db_exc:
  704. _logger.error("Failed to delete reset token after send failure: %s", db_exc)
  705. @router.post("/forgot-password", response_model=ForgotPasswordResponse)
  706. async def forgot_password(
  707. request: ForgotPasswordRequest,
  708. background_tasks: BackgroundTasks,
  709. raw_request: Request,
  710. db: AsyncSession = Depends(get_db),
  711. ):
  712. """Request password reset via email (advanced auth only).
  713. H-6: Issues a short-lived single-use reset token and emails the user a
  714. secure link instead of a plaintext temporary password. The new password is
  715. set only when the user clicks the link and POSTs to /forgot-password/confirm.
  716. """
  717. # Check if advanced auth is enabled
  718. advanced_auth = await is_advanced_auth_enabled(db)
  719. if not advanced_auth:
  720. raise HTTPException(
  721. status_code=status.HTTP_400_BAD_REQUEST,
  722. detail="Advanced authentication is not enabled",
  723. )
  724. # M-A: Rate-limit by normalised email to prevent reset-email flooding.
  725. # Apply unconditionally (before the user lookup) so unknown emails are also
  726. # throttled — this prevents both flooding and timing-based enumeration.
  727. identifier = request.email.lower()
  728. cutoff = datetime.now(timezone.utc) - _PWD_RESET_SEND_WINDOW
  729. rate_result = await db.execute(
  730. select(AuthRateLimitEvent).where(
  731. AuthRateLimitEvent.username == identifier,
  732. AuthRateLimitEvent.event_type == EventType.PASSWORD_RESET_SEND,
  733. AuthRateLimitEvent.occurred_at > cutoff,
  734. )
  735. )
  736. if len(rate_result.scalars().all()) >= _MAX_PWD_RESET_SENDS:
  737. raise HTTPException(
  738. status_code=status.HTTP_429_TOO_MANY_REQUESTS,
  739. detail=f"Too many password reset requests. Please wait {_PWD_RESET_SEND_WINDOW.seconds // 60} minutes.",
  740. )
  741. # L-NEW-6: per-IP rate limit — prevents mass-reset flooding across many
  742. # different email addresses from a single source IP.
  743. client_ip = _get_client_ip(raw_request)
  744. ip_rate_result = await db.execute(
  745. select(AuthRateLimitEvent).where(
  746. AuthRateLimitEvent.username == client_ip,
  747. AuthRateLimitEvent.event_type == EventType.PASSWORD_RESET_IP,
  748. AuthRateLimitEvent.occurred_at > cutoff,
  749. )
  750. )
  751. if len(ip_rate_result.scalars().all()) >= _MAX_PWD_RESET_SENDS_PER_IP:
  752. raise HTTPException(
  753. status_code=status.HTTP_429_TOO_MANY_REQUESTS,
  754. detail=f"Too many password reset requests. Please wait {_PWD_RESET_SEND_WINDOW.seconds // 60} minutes.",
  755. )
  756. # Nit7: Always record the IP-level event (prevents spray attacks across many
  757. # different email addresses from one IP). The email-level event is only
  758. # recorded when we actually send an email to a local user — LDAP/OIDC users
  759. # do not consume a slot because this flow is a no-op for them.
  760. db.add(AuthRateLimitEvent(username=client_ip, event_type=EventType.PASSWORD_RESET_IP))
  761. await db.commit()
  762. # Get SMTP settings
  763. smtp_settings = await get_smtp_settings(db)
  764. if not smtp_settings:
  765. raise HTTPException(
  766. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  767. detail="Email service is not configured",
  768. )
  769. # Find user by email — always return success to prevent email enumeration.
  770. user = await get_user_by_email(db, request.email)
  771. # M-1: exclude LDAP and OIDC users — they must use their respective provider.
  772. if user and user.is_active and user.auth_source not in ("ldap", "oidc"):
  773. try:
  774. # Record email-level slot only for local users who will actually receive
  775. # the reset email (Nit7: don't waste the user's quota for LDAP/OIDC no-ops).
  776. db.add(AuthRateLimitEvent(username=identifier, event_type=EventType.PASSWORD_RESET_SEND))
  777. now = datetime.now(timezone.utc)
  778. # Prune any outstanding reset tokens for this user before issuing a new one.
  779. await db.execute(
  780. delete(AuthEphemeralToken).where(
  781. AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
  782. AuthEphemeralToken.username == user.username,
  783. )
  784. )
  785. reset_token = secrets.token_urlsafe(32)
  786. db.add(
  787. AuthEphemeralToken(
  788. token=reset_token,
  789. token_type=TokenType.PASSWORD_RESET,
  790. username=user.username,
  791. expires_at=now + _RESET_TOKEN_TTL,
  792. )
  793. )
  794. await db.commit()
  795. login_url = await get_external_login_url(db)
  796. # M-B: Deliver token in the URL fragment so it never reaches the server
  797. # in access-logs or Referer headers (mirrors H-4 for the OIDC token).
  798. reset_url = f"{login_url}#reset_token={reset_token}"
  799. subject, text_body, html_body = await create_password_reset_link_email_from_template(
  800. db, user.username, reset_url
  801. )
  802. # L-R9-B: send asynchronously so response time is independent of
  803. # whether the user exists (prevents email-existence timing oracle).
  804. # C1: wrapper deletes the token if SMTP fails so the user can re-request.
  805. background_tasks.add_task(
  806. _send_reset_email_or_delete_token,
  807. reset_token,
  808. smtp_settings,
  809. user.email,
  810. subject,
  811. text_body,
  812. html_body,
  813. "forgot_password",
  814. )
  815. _logger.info("Password reset email queued for %s", user.email)
  816. except Exception as e:
  817. _logger.error("Failed to send password reset email: %s", e)
  818. # Don't reveal error to caller for security
  819. return ForgotPasswordResponse(
  820. message="If the email address is associated with an account, a password reset email has been sent."
  821. )
  822. @router.post("/forgot-password/confirm", response_model=ForgotPasswordResponse)
  823. async def forgot_password_confirm(request: ForgotPasswordConfirmRequest, db: AsyncSession = Depends(get_db)):
  824. """Complete a password reset by supplying the token from the reset email.
  825. H-6: Atomically consumes the single-use token (DELETE…RETURNING) and sets
  826. the new password. Expired or already-used tokens are silently rejected with
  827. the same response to prevent oracle attacks.
  828. """
  829. now = datetime.now(timezone.utc)
  830. result = await db.execute(
  831. delete(AuthEphemeralToken)
  832. .where(
  833. AuthEphemeralToken.token == request.token,
  834. AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
  835. )
  836. .returning(AuthEphemeralToken.username, AuthEphemeralToken.expires_at)
  837. )
  838. row = result.one_or_none()
  839. await db.commit()
  840. if row is None:
  841. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
  842. username, expires_at = row
  843. # SQLite returns naive datetimes; treat them as UTC.
  844. if expires_at.tzinfo is None:
  845. expires_at = expires_at.replace(tzinfo=timezone.utc)
  846. if now > expires_at:
  847. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
  848. user = await get_user_by_username(db, username)
  849. # M-1: block LDAP/OIDC users — they authenticate via their provider, not local password.
  850. if not user or not user.is_active or user.auth_source in ("ldap", "oidc"):
  851. raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid or expired password reset token")
  852. user.password_hash = get_password_hash(request.new_password)
  853. user.password_changed_at = now # M-R7-B: invalidate all prior JWTs
  854. await db.commit()
  855. _logger.info("Password reset completed for user '%s'", username)
  856. return ForgotPasswordResponse(message="Password has been reset successfully.")
  857. @router.post("/reset-password", response_model=ResetPasswordResponse)
  858. async def reset_user_password(
  859. request: ResetPasswordRequest,
  860. background_tasks: BackgroundTasks,
  861. current_user: User = Depends(get_current_active_user),
  862. db: AsyncSession = Depends(get_db),
  863. ):
  864. """Reset a user's password and send them an email (admin only, advanced auth only)."""
  865. # Reload user with groups for proper is_admin check
  866. result = await db.execute(select(User).where(User.id == current_user.id).options(selectinload(User.groups)))
  867. admin_user = result.scalar_one()
  868. if not admin_user.is_admin:
  869. raise HTTPException(
  870. status_code=status.HTTP_403_FORBIDDEN,
  871. detail="Only admins can reset user passwords",
  872. )
  873. # Check if advanced auth is enabled
  874. advanced_auth = await is_advanced_auth_enabled(db)
  875. if not advanced_auth:
  876. raise HTTPException(
  877. status_code=status.HTTP_400_BAD_REQUEST,
  878. detail="Advanced authentication is not enabled",
  879. )
  880. # Get SMTP settings
  881. smtp_settings = await get_smtp_settings(db)
  882. if not smtp_settings:
  883. raise HTTPException(
  884. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  885. detail="Email service is not configured",
  886. )
  887. # Find user to reset
  888. result = await db.execute(select(User).where(User.id == request.user_id))
  889. user = result.scalar_one_or_none()
  890. if not user:
  891. raise HTTPException(
  892. status_code=status.HTTP_404_NOT_FOUND,
  893. detail="User not found",
  894. )
  895. # M-1: block LDAP/OIDC users — passwords are managed by their respective providers.
  896. if user.auth_source in ("ldap", "oidc"):
  897. raise HTTPException(
  898. status_code=status.HTTP_400_BAD_REQUEST,
  899. detail="Cannot reset password for LDAP/OIDC users — authentication is managed by their provider",
  900. )
  901. if not user.email:
  902. raise HTTPException(
  903. status_code=status.HTTP_400_BAD_REQUEST,
  904. detail="User does not have an email address configured",
  905. )
  906. try:
  907. # H-B: Issue a single-use reset link instead of generating a plaintext password.
  908. # The admin never sees the credential — the user sets their own password.
  909. now = datetime.now(timezone.utc)
  910. await db.execute(
  911. delete(AuthEphemeralToken).where(
  912. AuthEphemeralToken.token_type == TokenType.PASSWORD_RESET,
  913. AuthEphemeralToken.username == user.username,
  914. )
  915. )
  916. reset_token = secrets.token_urlsafe(32)
  917. db.add(
  918. AuthEphemeralToken(
  919. token=reset_token,
  920. token_type=TokenType.PASSWORD_RESET,
  921. username=user.username,
  922. expires_at=now + _RESET_TOKEN_TTL,
  923. )
  924. )
  925. await db.commit()
  926. login_url = await get_external_login_url(db)
  927. reset_url = f"{login_url}#reset_token={reset_token}"
  928. subject, text_body, html_body = await create_password_reset_link_email_from_template(
  929. db, user.username, reset_url
  930. )
  931. background_tasks.add_task(
  932. _send_reset_email_or_delete_token,
  933. reset_token,
  934. smtp_settings,
  935. user.email,
  936. subject,
  937. text_body,
  938. html_body,
  939. "admin_reset",
  940. )
  941. _logger.info("Admin password reset link queued for user '%s' by admin '%s'", user.username, admin_user.username)
  942. return ResetPasswordResponse(message=f"Password reset link sent to {user.email}")
  943. except Exception as e:
  944. await db.rollback()
  945. _logger.error("Failed to send admin password reset for user '%s': %s", user.username, e)
  946. raise HTTPException(
  947. status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
  948. detail="Failed to send password reset link. Check server logs.", # L-R7-B: no internal details
  949. )
  950. # LDAP Authentication Helpers
  951. async def _get_ldap_settings(db: AsyncSession) -> dict[str, str] | None:
  952. """Get LDAP settings from the database. Returns None if LDAP is not enabled."""
  953. ldap_keys = [
  954. "ldap_enabled",
  955. "ldap_server_url",
  956. "ldap_bind_dn",
  957. "ldap_bind_password",
  958. "ldap_search_base",
  959. "ldap_user_filter",
  960. "ldap_security",
  961. "ldap_group_mapping",
  962. "ldap_auto_provision",
  963. "ldap_ca_cert_path",
  964. "ldap_default_group",
  965. ]
  966. result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
  967. settings = {s.key: s.value for s in result.scalars().all()}
  968. if settings.get("ldap_enabled", "false").lower() != "true":
  969. return None
  970. return settings
  971. async def _provision_ldap_user(db: AsyncSession, ldap_user, ldap_config) -> User:
  972. """Create a new local user from LDAP authentication."""
  973. import logging
  974. from backend.app.services.ldap_service import resolve_group_mapping
  975. logger = logging.getLogger(__name__)
  976. new_user = User(
  977. username=ldap_user.username,
  978. email=ldap_user.email,
  979. password_hash=None,
  980. role="user",
  981. auth_source="ldap",
  982. is_active=True,
  983. )
  984. # Map LDAP groups to BamBuddy groups, falling back to the configured default group
  985. # when the user is authenticated but has no matching group mapping (#921-follow-up).
  986. mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)
  987. if not mapped_group_names and ldap_config.default_group:
  988. mapped_group_names = [ldap_config.default_group]
  989. logger.warning(
  990. "LDAP user %s has no mapped groups — assigning configured default group '%s'",
  991. ldap_user.username,
  992. ldap_config.default_group,
  993. )
  994. if mapped_group_names:
  995. groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))
  996. new_user.groups = list(groups_result.scalars().all())
  997. db.add(new_user)
  998. await db.commit()
  999. await db.refresh(new_user)
  1000. logger.info("Auto-provisioned LDAP user: %s (groups: %s)", new_user.username, mapped_group_names)
  1001. return new_user
  1002. async def _sync_ldap_user(db: AsyncSession, user: User, ldap_user, ldap_config) -> None:
  1003. """Sync LDAP user attributes (email, groups) on each login."""
  1004. import logging
  1005. from backend.app.services.ldap_service import resolve_group_mapping
  1006. logger = logging.getLogger(__name__)
  1007. changed = False
  1008. # Update email if changed
  1009. if ldap_user.email and ldap_user.email != user.email:
  1010. user.email = ldap_user.email
  1011. changed = True
  1012. # Sync group mappings — always update to match LDAP state (including revocation).
  1013. # Fall back to the configured default group when the user has no mapped groups,
  1014. # so authenticated LDAP users are never left permission-less.
  1015. mapped_group_names = resolve_group_mapping(ldap_user.groups, ldap_config.group_mapping)
  1016. if not mapped_group_names and ldap_config.default_group:
  1017. mapped_group_names = [ldap_config.default_group]
  1018. logger.warning(
  1019. "LDAP user %s has no mapped groups — assigning configured default group '%s'",
  1020. user.username,
  1021. ldap_config.default_group,
  1022. )
  1023. if mapped_group_names:
  1024. groups_result = await db.execute(select(Group).where(Group.name.in_(mapped_group_names)))
  1025. new_groups = list(groups_result.scalars().all())
  1026. else:
  1027. new_groups = []
  1028. current_group_ids = {g.id for g in user.groups}
  1029. new_group_ids = {g.id for g in new_groups}
  1030. if current_group_ids != new_group_ids:
  1031. user.groups = new_groups
  1032. changed = True
  1033. if changed:
  1034. await db.commit()
  1035. logger.info("Synced LDAP user attributes: %s", user.username)
  1036. @router.post("/ldap/test")
  1037. async def test_ldap(
  1038. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.SETTINGS_UPDATE),
  1039. db: AsyncSession = Depends(get_db),
  1040. ):
  1041. """Test LDAP connection using saved settings (admin only when auth enabled)."""
  1042. import logging
  1043. from backend.app.services.ldap_service import parse_ldap_config, test_ldap_connection
  1044. logger = logging.getLogger(__name__)
  1045. ldap_settings = await _get_ldap_settings(db)
  1046. if not ldap_settings:
  1047. # LDAP might not be enabled yet but settings might still exist — read all keys
  1048. ldap_keys = [
  1049. "ldap_enabled",
  1050. "ldap_server_url",
  1051. "ldap_bind_dn",
  1052. "ldap_bind_password",
  1053. "ldap_search_base",
  1054. "ldap_user_filter",
  1055. "ldap_security",
  1056. "ldap_group_mapping",
  1057. "ldap_auto_provision",
  1058. ]
  1059. result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
  1060. ldap_settings = {s.key: s.value for s in result.scalars().all()}
  1061. # Force enabled for test
  1062. ldap_settings["ldap_enabled"] = "true"
  1063. config = parse_ldap_config(ldap_settings)
  1064. if not config:
  1065. return {"success": False, "message": "LDAP server URL is not configured"}
  1066. success, message = test_ldap_connection(config)
  1067. if success:
  1068. logger.info("LDAP connection test successful")
  1069. else:
  1070. logger.warning("LDAP connection test failed: %s", message)
  1071. return {"success": success, "message": message}
  1072. @router.get("/ldap/status")
  1073. async def get_ldap_status(db: AsyncSession = Depends(get_db)):
  1074. """Get LDAP authentication status."""
  1075. # Only fetch the minimum keys needed — never load secrets
  1076. ldap_keys = ["ldap_enabled", "ldap_server_url"]
  1077. result = await db.execute(select(Settings).where(Settings.key.in_(ldap_keys)))
  1078. settings = {s.key: s.value for s in result.scalars().all()}
  1079. return {
  1080. "ldap_enabled": settings.get("ldap_enabled", "false").lower() == "true",
  1081. "ldap_configured": bool(settings.get("ldap_server_url")),
  1082. }