users.py 28 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. from datetime import datetime, timezone
  2. from typing import Annotated
  3. import jwt as _jwt
  4. from fastapi import APIRouter, Depends, HTTPException, Query, status
  5. from fastapi.security import HTTPAuthorizationCredentials
  6. from sqlalchemy import delete, func, select, update as sql_update
  7. from sqlalchemy.ext.asyncio import AsyncSession
  8. from sqlalchemy.orm import selectinload
  9. from backend.app.api.routes.settings import get_external_login_url
  10. from backend.app.core.auth import (
  11. ALGORITHM,
  12. SECRET_KEY,
  13. RequireAdminIfAuthEnabled,
  14. RequireAnyPermissionIfAuthEnabled,
  15. RequirePermissionIfAuthEnabled,
  16. get_current_user_optional,
  17. get_password_hash,
  18. revoke_jti,
  19. security,
  20. verify_password,
  21. )
  22. from backend.app.core.database import get_db
  23. from backend.app.core.permissions import Permission
  24. from backend.app.core.websocket import ws_manager
  25. from backend.app.models.api_key import APIKey
  26. from backend.app.models.archive import PrintArchive
  27. from backend.app.models.auth_ephemeral import AuthEphemeralToken, TokenType
  28. from backend.app.models.connected_app import ConnectedApp, ConnectedAppGrant
  29. from backend.app.models.group import Group
  30. from backend.app.models.library import LibraryFile
  31. from backend.app.models.long_lived_token import LongLivedToken
  32. from backend.app.models.oidc_provider import UserOIDCLink
  33. from backend.app.models.print_batch import PrintBatch
  34. from backend.app.models.print_log import PrintLogEntry
  35. from backend.app.models.print_queue import PrintQueueItem
  36. from backend.app.models.settings import Settings
  37. from backend.app.models.user import User
  38. from backend.app.models.user_otp_code import UserOTPCode
  39. from backend.app.models.user_totp import UserTOTP
  40. from backend.app.schemas.auth import (
  41. ChangePasswordRequest,
  42. GroupBrief,
  43. UserCreate,
  44. UserResponse,
  45. UserSlim,
  46. UserUpdate,
  47. )
  48. from backend.app.services.email_service import (
  49. create_welcome_email_from_template,
  50. generate_secure_password,
  51. get_smtp_settings,
  52. send_email,
  53. )
  54. from backend.app.services.finance_defaults import ensure_user_finance_defaults
  55. from backend.app.services.printer_manager import printer_manager
  56. from backend.app.services.slice_dispatch import slice_dispatch
  57. from backend.app.utils.library_paths import remove_library_photos_dir
  58. router = APIRouter(prefix="/users", tags=["users"])
  59. def _user_to_response(user: User) -> UserResponse:
  60. """Convert a User model to UserResponse schema."""
  61. return UserResponse(
  62. id=user.id,
  63. username=user.username,
  64. email=user.email,
  65. role=user.role,
  66. is_active=user.is_active,
  67. is_admin=user.is_admin,
  68. auth_source=getattr(user, "auth_source", "local"),
  69. groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
  70. permissions=sorted(user.get_permissions()),
  71. created_at=user.created_at.isoformat(),
  72. )
  73. @router.get("", response_model=list[UserResponse])
  74. @router.get("/", response_model=list[UserResponse])
  75. async def list_users(
  76. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  77. db: AsyncSession = Depends(get_db),
  78. ):
  79. """List all users.
  80. Read-only — gated on ``USERS_READ`` only. Operator-visible UIs
  81. (Stats filter-by-user, Archives Print Log username column, File
  82. Manager username autocomplete) consume this endpoint via custom-
  83. group ``users:read`` grants without admin role. The admin-only
  84. boundary lives on the write endpoints below."""
  85. result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
  86. users = result.scalars().all()
  87. return [_user_to_response(user) for user in users]
  88. @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  89. @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  90. async def create_user(
  91. user_data: UserCreate,
  92. _admin: User | None = RequireAdminIfAuthEnabled(),
  93. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
  94. db: AsyncSession = Depends(get_db),
  95. ):
  96. """Create a new user.
  97. When advanced authentication is enabled:
  98. - Email is required
  99. - Password is auto-generated and emailed to user
  100. - Admin cannot set or see the password
  101. """
  102. import logging
  103. logger = logging.getLogger(__name__)
  104. # Check if advanced auth is enabled
  105. result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
  106. advanced_auth_setting = result.scalar_one_or_none()
  107. advanced_auth_enabled = advanced_auth_setting and advanced_auth_setting.value.lower() == "true"
  108. # Check if username already exists (case-insensitive)
  109. existing_user = await db.execute(select(User).where(func.lower(User.username) == func.lower(user_data.username)))
  110. if existing_user.scalar_one_or_none():
  111. raise HTTPException(
  112. status_code=status.HTTP_400_BAD_REQUEST,
  113. detail="Username already exists",
  114. )
  115. # Validate role
  116. if user_data.role not in ["admin", "user"]:
  117. raise HTTPException(
  118. status_code=status.HTTP_400_BAD_REQUEST,
  119. detail="Role must be 'admin' or 'user'",
  120. )
  121. # Advanced auth validation
  122. if advanced_auth_enabled:
  123. if not user_data.email:
  124. raise HTTPException(
  125. status_code=status.HTTP_400_BAD_REQUEST,
  126. detail="Email is required when advanced authentication is enabled",
  127. )
  128. # Check if email already exists (case-insensitive)
  129. existing_email = await db.execute(select(User).where(func.lower(User.email) == func.lower(user_data.email)))
  130. if existing_email.scalar_one_or_none():
  131. raise HTTPException(
  132. status_code=status.HTTP_400_BAD_REQUEST,
  133. detail="Email already exists",
  134. )
  135. # Generate password if advanced auth enabled, otherwise require password
  136. if advanced_auth_enabled:
  137. password = generate_secure_password()
  138. else:
  139. if not user_data.password:
  140. raise HTTPException(
  141. status_code=status.HTTP_400_BAD_REQUEST,
  142. detail="Password is required when advanced authentication is disabled",
  143. )
  144. password = user_data.password
  145. new_user = User(
  146. username=user_data.username,
  147. email=user_data.email,
  148. password_hash=get_password_hash(password),
  149. role=user_data.role,
  150. is_active=True,
  151. )
  152. # Handle group assignments
  153. if user_data.group_ids:
  154. groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
  155. groups = groups_result.scalars().all()
  156. if len(groups) != len(user_data.group_ids):
  157. raise HTTPException(
  158. status_code=status.HTTP_400_BAD_REQUEST,
  159. detail="One or more group IDs are invalid",
  160. )
  161. new_user.groups = list(groups)
  162. db.add(new_user)
  163. await db.flush()
  164. await ensure_user_finance_defaults(db, new_user)
  165. await db.commit()
  166. await db.refresh(new_user)
  167. # Send welcome email if advanced auth enabled
  168. if advanced_auth_enabled and new_user.email:
  169. try:
  170. smtp_settings = await get_smtp_settings(db)
  171. if smtp_settings:
  172. login_url = await get_external_login_url(db)
  173. subject, text_body, html_body = await create_welcome_email_from_template(
  174. db, new_user.username, password, login_url
  175. )
  176. send_email(smtp_settings, new_user.email, subject, text_body, html_body)
  177. logger.info(f"Welcome email sent to {new_user.email}")
  178. else:
  179. logger.warning(f"SMTP not configured, could not send welcome email to {new_user.email}")
  180. except Exception as e:
  181. logger.error(f"Failed to send welcome email: {e}")
  182. # Don't fail user creation if email fails
  183. return _user_to_response(new_user)
  184. @router.get("/slim", response_model=list[UserSlim])
  185. async def list_users_slim(
  186. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.USERS_READ_SLIM, Permission.USERS_READ),
  187. db: AsyncSession = Depends(get_db),
  188. ):
  189. """List users as ``{id, username}`` only (#1894).
  190. Exists so an API key -- or a group that should not see emails, roles and
  191. permission sets -- can turn the ``created_by_id`` values it already gets
  192. back from archives, stats and the queue into names.
  193. ``USERS_READ`` is accepted alongside ``USERS_READ_SLIM`` because it is
  194. strictly broader; groups that already hold it keep working without a
  195. permission backfill. For API keys only the slim permission resolves (the
  196. full one is unmapped = administrative), so a key reaches this and not the
  197. listing above.
  198. Declared before ``/{user_id}`` on purpose: FastAPI matches in declaration
  199. order, and the reverse order would parse "slim" as the int path parameter
  200. and answer 422.
  201. """
  202. result = await db.execute(select(User.id, User.username).order_by(User.username))
  203. return [UserSlim(id=row.id, username=row.username) for row in result.all()]
  204. @router.get("/{user_id}", response_model=UserResponse)
  205. async def get_user(
  206. user_id: int,
  207. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  208. db: AsyncSession = Depends(get_db),
  209. ):
  210. """Get a user by ID. Read-only — gated on ``USERS_READ`` only."""
  211. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  212. user = result.scalar_one_or_none()
  213. if not user:
  214. raise HTTPException(
  215. status_code=status.HTTP_404_NOT_FOUND,
  216. detail="User not found",
  217. )
  218. return _user_to_response(user)
  219. @router.patch("/{user_id}", response_model=UserResponse)
  220. async def update_user(
  221. user_id: int,
  222. user_data: UserUpdate,
  223. _admin: User | None = RequireAdminIfAuthEnabled(),
  224. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
  225. db: AsyncSession = Depends(get_db),
  226. ):
  227. """Update a user."""
  228. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  229. user = result.scalar_one_or_none()
  230. if not user:
  231. raise HTTPException(
  232. status_code=status.HTTP_404_NOT_FOUND,
  233. detail="User not found",
  234. )
  235. # Prevent deactivating the last admin
  236. if user_data.is_active is False and user.is_admin:
  237. # Count admins by role or Administrators group membership
  238. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
  239. role_admins = admin_count_result.scalars().all()
  240. # Also check for users in Administrators group
  241. admin_group_result = await db.execute(
  242. select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
  243. )
  244. admin_group = admin_group_result.scalar_one_or_none()
  245. group_admins = [u for u in (admin_group.users if admin_group else []) if u.is_active]
  246. # Combine unique admins
  247. all_admins = {u.id for u in role_admins} | {u.id for u in group_admins}
  248. if len(all_admins) <= 1 and user.id in all_admins:
  249. raise HTTPException(
  250. status_code=status.HTTP_400_BAD_REQUEST,
  251. detail="Cannot deactivate the last admin user",
  252. )
  253. # Prevent changing role of last admin
  254. if user_data.role and user_data.role != "admin" and user.role == "admin":
  255. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
  256. admin_count = len(admin_count_result.scalars().all())
  257. if admin_count <= 1:
  258. raise HTTPException(
  259. status_code=status.HTTP_400_BAD_REQUEST,
  260. detail="Cannot change role of the last admin user",
  261. )
  262. if user_data.username is not None:
  263. # Check if new username already exists (case-insensitive)
  264. existing_user = await db.execute(
  265. select(User).where(func.lower(User.username) == func.lower(user_data.username), User.id != user_id)
  266. )
  267. if existing_user.scalar_one_or_none():
  268. raise HTTPException(
  269. status_code=status.HTTP_400_BAD_REQUEST,
  270. detail="Username already exists",
  271. )
  272. user.username = user_data.username
  273. if user_data.email is not None:
  274. # Check if new email already exists (case-insensitive)
  275. existing_email = await db.execute(
  276. select(User).where(func.lower(User.email) == func.lower(user_data.email), User.id != user_id)
  277. )
  278. if existing_email.scalar_one_or_none():
  279. raise HTTPException(
  280. status_code=status.HTTP_400_BAD_REQUEST,
  281. detail="Email already exists",
  282. )
  283. user.email = user_data.email
  284. if user_data.password is not None:
  285. if getattr(user, "auth_source", "local") == "ldap":
  286. raise HTTPException(
  287. status_code=status.HTTP_400_BAD_REQUEST,
  288. detail="Cannot set password for LDAP users",
  289. )
  290. user.password_hash = get_password_hash(user_data.password)
  291. if user_data.role is not None:
  292. if user_data.role not in ["admin", "user"]:
  293. raise HTTPException(
  294. status_code=status.HTTP_400_BAD_REQUEST,
  295. detail="Role must be 'admin' or 'user'",
  296. )
  297. user.role = user_data.role
  298. if user_data.is_active is not None:
  299. user.is_active = user_data.is_active
  300. # Handle group assignments
  301. if user_data.group_ids is not None:
  302. groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
  303. groups = groups_result.scalars().all()
  304. if len(groups) != len(user_data.group_ids):
  305. raise HTTPException(
  306. status_code=status.HTTP_400_BAD_REQUEST,
  307. detail="One or more group IDs are invalid",
  308. )
  309. user.groups = list(groups)
  310. await ensure_user_finance_defaults(db, user)
  311. await db.commit()
  312. if user_data.group_ids is not None or user_data.role is not None or user_data.is_active is not None:
  313. # Groups, admin role and deactivation all change which printers the
  314. # user's open dashboards may hear about (#1727)
  315. await ws_manager.refresh_printer_scopes()
  316. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  317. user = result.scalar_one()
  318. return _user_to_response(user)
  319. @router.get("/{user_id}/items-count")
  320. async def get_user_items_count(
  321. user_id: int,
  322. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  323. db: AsyncSession = Depends(get_db),
  324. ):
  325. """Get count of items created by this user. Read-only — gated on
  326. ``USERS_READ`` only."""
  327. # Verify user exists
  328. result = await db.execute(select(User).where(User.id == user_id))
  329. if not result.scalar_one_or_none():
  330. raise HTTPException(
  331. status_code=status.HTTP_404_NOT_FOUND,
  332. detail="User not found",
  333. )
  334. # Count archives
  335. archives_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.created_by_id == user_id))
  336. archives_count = archives_result.scalar() or 0
  337. # Count queue items
  338. queue_result = await db.execute(
  339. select(func.count(PrintQueueItem.id)).where(PrintQueueItem.created_by_id == user_id)
  340. )
  341. queue_items_count = queue_result.scalar() or 0
  342. # Count library files
  343. library_result = await db.execute(
  344. select(func.count(LibraryFile.id)).where(
  345. LibraryFile.created_by_id == user_id,
  346. LibraryFile.deleted_at.is_(None),
  347. )
  348. )
  349. library_files_count = library_result.scalar() or 0
  350. return {
  351. "archives": archives_count,
  352. "queue_items": queue_items_count,
  353. "library_files": library_files_count,
  354. }
  355. @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
  356. async def delete_user(
  357. user_id: int,
  358. delete_items: bool = Query(False, description="Delete all items created by this user"),
  359. _admin: User | None = RequireAdminIfAuthEnabled(),
  360. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
  361. db: AsyncSession = Depends(get_db),
  362. ):
  363. """Delete a user.
  364. If delete_items=True, all archives, queue items, and library files created by
  365. this user will also be deleted. Otherwise, these items will become "ownerless"
  366. (created_by_id set to NULL by the foreign key constraint).
  367. """
  368. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  369. user = result.scalar_one_or_none()
  370. if not user:
  371. raise HTTPException(
  372. status_code=status.HTTP_404_NOT_FOUND,
  373. detail="User not found",
  374. )
  375. # Prevent deleting the last admin
  376. if user.is_admin:
  377. # Count admins by role or Administrators group membership
  378. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.id != user_id))
  379. other_role_admins = admin_count_result.scalars().all()
  380. # Also check for users in Administrators group
  381. admin_group_result = await db.execute(
  382. select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
  383. )
  384. admin_group = admin_group_result.scalar_one_or_none()
  385. other_group_admins = [u for u in (admin_group.users if admin_group else []) if u.id != user_id and u.is_active]
  386. # Combine unique admins
  387. all_other_admins = {u.id for u in other_role_admins} | {u.id for u in other_group_admins}
  388. if len(all_other_admins) == 0:
  389. raise HTTPException(
  390. status_code=status.HTTP_400_BAD_REQUEST,
  391. detail="Cannot delete the last admin user",
  392. )
  393. # Prevent deleting yourself (only if auth is enabled and we have a current user)
  394. if current_user and user.id == current_user.id:
  395. raise HTTPException(
  396. status_code=status.HTTP_400_BAD_REQUEST,
  397. detail="Cannot delete your own account",
  398. )
  399. # Photo directories of the library rows about to go, resolved while the
  400. # rows still exist to say which ids they belong to. Removed after the
  401. # commit, so a failed delete leaves the pictures alone (#3077).
  402. doomed_library_file_ids: list[int] = []
  403. if delete_items:
  404. doomed_library_file_ids = list(
  405. (await db.execute(select(LibraryFile.id).where(LibraryFile.created_by_id == user_id))).scalars().all()
  406. )
  407. # Delete all items created by this user. The print log stays, without
  408. # the archives it pointed at; archives of other users printed from
  409. # these library files lose that link (both SET NULL on PostgreSQL).
  410. from backend.app.services.library_trash import delete_dependent_variants, release_queue_references
  411. await delete_dependent_variants(db, doomed_library_file_ids)
  412. await release_queue_references(db, doomed_library_file_ids)
  413. await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
  414. await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
  415. await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id))
  416. await db.execute(delete(PrintBatch).where(PrintBatch.created_by_id == user_id))
  417. else:
  418. # Explicitly set created_by_id to NULL for all items (ensures consistent behavior
  419. # across different database backends, including SQLite without foreign key support).
  420. # PrintBatch carries the same created_by_id FK with ondelete=SET NULL — admin-deleted
  421. # users would otherwise leave dangling created_by_id on SQLite (#1295 review nit).
  422. from sqlalchemy import update
  423. from backend.app.core.printer_scope import resolve_user_printer_scope
  424. # An ownerless "any <model>" job may run on any printer of that model;
  425. # the scheduler held it to this user's printers only while it was
  426. # theirs (#1727). Stage those jobs instead, so an admin decides where
  427. # they run rather than the job quietly spreading across the fleet.
  428. if not (await resolve_user_printer_scope(db, user)).is_unrestricted:
  429. await db.execute(
  430. update(PrintQueueItem)
  431. .where(
  432. PrintQueueItem.created_by_id == user_id,
  433. PrintQueueItem.printer_id.is_(None),
  434. PrintQueueItem.status == "pending",
  435. )
  436. .values(manual_start=True)
  437. )
  438. await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))
  439. await db.execute(
  440. update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)
  441. )
  442. await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
  443. await db.execute(update(PrintBatch).where(PrintBatch.created_by_id == user_id).values(created_by_id=None))
  444. # The print log keeps who started each run as long as the user exists, and
  445. # outlives them either way (ON DELETE SET NULL, which SQLite doesn't apply).
  446. await db.execute(sql_update(PrintLogEntry).where(PrintLogEntry.created_by_id == user_id).values(created_by_id=None))
  447. # Drop API keys owned by this user. The model declares ON DELETE CASCADE
  448. # so Postgres handles this automatically, but SQLite ships with FK
  449. # enforcement off (the project's existing pattern — same reason the
  450. # blocks above set created_by_id = NULL by hand). Without an explicit
  451. # DELETE here, deleting a user on SQLite would leave their API keys
  452. # with a dangling user_id and ``_user_from_api_key`` would return None,
  453. # silently degrading the keys to anonymous (and locking them out of
  454. # /cloud/* — but the rest of the API would still accept them, which is
  455. # exactly the orphan-key state the CASCADE was meant to prevent).
  456. await db.execute(delete(APIKey).where(APIKey.user_id == user_id))
  457. # Drop OIDC links, MFA state, and long-lived camera-stream tokens
  458. # owned by this user. Same SQLite/FK pattern as APIKey above. Without
  459. # these, deleting a user on SQLite leaves:
  460. # - UserOIDCLink: the OIDC callback finds the orphan link, fails to
  461. # resolve the (now missing) user, and falls through to
  462. # "account_inactive" instead of triggering auto_create (#1285).
  463. # - UserTOTP: MFA secrets persist in the DB after the owning user.
  464. # - UserOTPCode: pending email OTP codes linger.
  465. # - LongLivedToken: per-user camera-stream tokens whose secret_hash
  466. # is still valid — verify() would happily match them by lookup
  467. # prefix even though the user is gone.
  468. await db.execute(delete(UserOIDCLink).where(UserOIDCLink.user_id == user_id))
  469. await db.execute(delete(UserTOTP).where(UserTOTP.user_id == user_id))
  470. await db.execute(delete(UserOTPCode).where(UserOTPCode.user_id == user_id))
  471. await db.execute(delete(LongLivedToken).where(LongLivedToken.user_id == user_id))
  472. # Connected apps, same SQLite/FK pattern. A leftover consent row would
  473. # skip the consent screen for whoever is next given this user id, and a
  474. # code issued in the last minute is keyed by username, not id.
  475. from sqlalchemy import update as _update
  476. await db.execute(delete(ConnectedAppGrant).where(ConnectedAppGrant.user_id == user_id))
  477. await db.execute(
  478. delete(AuthEphemeralToken).where(
  479. AuthEphemeralToken.token_type == TokenType.CONNECT_CODE,
  480. func.lower(AuthEphemeralToken.username) == user.username.lower(),
  481. )
  482. )
  483. await db.execute(_update(ConnectedApp).where(ConnectedApp.created_by_id == user_id).values(created_by_id=None))
  484. # Email 2FA is a settings key named after the id, which no foreign key covers.
  485. await db.execute(delete(Settings).where(Settings.key == f"user_{user_id}_email_2fa_enabled"))
  486. await db.delete(user)
  487. await db.commit()
  488. # What still runs for them must not be credited to whoever gets the id next
  489. # (SQLite reuses it): a print, a slice, an open dashboard.
  490. printer_manager.forget_user(user_id)
  491. from backend.app.main import forget_print_creator
  492. forget_print_creator(user_id)
  493. slice_dispatch.forget_owner(user_id)
  494. await ws_manager.close_user(user_id)
  495. await ws_manager.refresh_printer_scopes()
  496. for file_id in doomed_library_file_ids:
  497. remove_library_photos_dir(file_id)
  498. @router.post("/me/change-password", response_model=dict)
  499. async def change_own_password(
  500. password_data: ChangePasswordRequest,
  501. credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
  502. current_user: User | None = Depends(get_current_user_optional),
  503. db: AsyncSession = Depends(get_db),
  504. ):
  505. """Change the current user's password. Requires current password verification."""
  506. if not current_user:
  507. raise HTTPException(
  508. status_code=status.HTTP_401_UNAUTHORIZED,
  509. detail="Authentication required to change password",
  510. )
  511. # Block password change for LDAP users
  512. if getattr(current_user, "auth_source", "local") == "ldap":
  513. raise HTTPException(
  514. status_code=status.HTTP_400_BAD_REQUEST,
  515. detail="Cannot change password for LDAP users — passwords are managed by the LDAP server",
  516. )
  517. # Verify current password
  518. if not current_user.password_hash:
  519. raise HTTPException(
  520. status_code=status.HTTP_400_BAD_REQUEST,
  521. detail="Account has no local password set",
  522. )
  523. # Rate-limit failed password-change attempts (H-R5-A)
  524. from backend.app.api.routes.mfa import MAX_2FA_ATTEMPTS, check_rate_limit, record_failed_attempt
  525. await check_rate_limit(db, current_user.username, event_type="password_change", max_attempts=MAX_2FA_ATTEMPTS)
  526. if not verify_password(password_data.current_password, current_user.password_hash):
  527. await record_failed_attempt(db, current_user.username, event_type="password_change")
  528. raise HTTPException(
  529. status_code=status.HTTP_400_BAD_REQUEST,
  530. detail="Current password is incorrect",
  531. )
  532. # Fetch user from this session to ensure changes are persisted
  533. result = await db.execute(select(User).where(User.id == current_user.id))
  534. user = result.scalar_one_or_none()
  535. if not user:
  536. raise HTTPException(
  537. status_code=status.HTTP_404_NOT_FOUND,
  538. detail="User not found",
  539. )
  540. # Update password
  541. user.password_hash = get_password_hash(password_data.new_password)
  542. user.password_changed_at = datetime.now(timezone.utc) # M-R7-B: invalidate all prior JWTs
  543. await db.commit()
  544. # L-R6-A: Password verified successfully — reset the failure counter
  545. from backend.app.api.routes.mfa import clear_failed_attempts
  546. await clear_failed_attempts(db, user.username, event_type="password_change")
  547. # Revoke the current session token so the caller must re-authenticate (M-R5-A)
  548. if credentials is not None:
  549. try:
  550. payload = _jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
  551. jti = payload.get("jti")
  552. exp = payload.get("exp")
  553. if jti and exp:
  554. try:
  555. await revoke_jti(jti, datetime.fromtimestamp(exp, tz=timezone.utc), user.username)
  556. except Exception as exc:
  557. # B4: log so operators know revocation is broken; password was
  558. # already changed so the token will fail freshness checks anyway.
  559. import logging
  560. logging.getLogger(__name__).error(
  561. "Failed to revoke JTI after password change for user %s: %s", user.username, exc
  562. )
  563. except Exception:
  564. pass # Decode failure is harmless — token is already invalidated by password_changed_at
  565. return {"message": "Password changed successfully"}