users.py 35 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796
  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
  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. from backend.app.utils.local_time import utcnow_naive
  59. router = APIRouter(prefix="/users", tags=["users"])
  60. def _user_to_response(user: User) -> UserResponse:
  61. """Convert a User model to UserResponse schema."""
  62. return UserResponse(
  63. id=user.id,
  64. username=user.username,
  65. email=user.email,
  66. role=user.role,
  67. is_active=user.is_active,
  68. is_admin=user.is_admin,
  69. auth_source=getattr(user, "auth_source", "local"),
  70. groups=[GroupBrief(id=g.id, name=g.name) for g in user.groups],
  71. permissions=sorted(user.get_permissions()),
  72. created_at=user.created_at.isoformat(),
  73. )
  74. @router.get("", response_model=list[UserResponse])
  75. @router.get("/", response_model=list[UserResponse])
  76. async def list_users(
  77. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  78. db: AsyncSession = Depends(get_db),
  79. ):
  80. """List all users.
  81. Read-only — gated on ``USERS_READ`` only. Operator-visible UIs
  82. (Stats filter-by-user, Archives Print Log username column, File
  83. Manager username autocomplete) consume this endpoint via custom-
  84. group ``users:read`` grants without admin role. The admin-only
  85. boundary lives on the write endpoints below."""
  86. result = await db.execute(select(User).options(selectinload(User.groups)).order_by(User.created_at))
  87. users = result.scalars().all()
  88. return [_user_to_response(user) for user in users]
  89. async def drop_pending_tokens(db: AsyncSession, username: str) -> None:
  90. """Delete the one-time tokens pending under a username that is going away.
  91. Reset links, 2FA and OIDC steps, connect codes, camera, WebSocket and slicer
  92. tokens are looked up by username, so they would work for whoever is given
  93. the name next. The logout list (revoked_jti) stays: dropping an entry would
  94. make the logged-out token valid again.
  95. """
  96. await db.execute(
  97. delete(AuthEphemeralToken).where(
  98. # Both sides in SQL: SQLite's lower() changes only ASCII letters, so
  99. # it never equals Python's lower() of a name with an umlaut.
  100. func.lower(AuthEphemeralToken.username) == func.lower(username),
  101. AuthEphemeralToken.token_type != "revoked_jti",
  102. )
  103. )
  104. @router.post("", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  105. @router.post("/", response_model=UserResponse, status_code=status.HTTP_201_CREATED)
  106. async def create_user(
  107. user_data: UserCreate,
  108. _admin: User | None = RequireAdminIfAuthEnabled(),
  109. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_CREATE),
  110. db: AsyncSession = Depends(get_db),
  111. ):
  112. """Create a new user.
  113. When advanced authentication is enabled:
  114. - Email is required
  115. - Password is auto-generated and emailed to user
  116. - Admin cannot set or see the password
  117. """
  118. import logging
  119. logger = logging.getLogger(__name__)
  120. # Check if advanced auth is enabled
  121. result = await db.execute(select(Settings).where(Settings.key == "advanced_auth_enabled"))
  122. advanced_auth_setting = result.scalar_one_or_none()
  123. advanced_auth_enabled = advanced_auth_setting and advanced_auth_setting.value.lower() == "true"
  124. # Check if username already exists (case-insensitive)
  125. existing_user = await db.execute(select(User).where(func.lower(User.username) == func.lower(user_data.username)))
  126. if existing_user.scalar_one_or_none():
  127. raise HTTPException(
  128. status_code=status.HTTP_400_BAD_REQUEST,
  129. detail="Username already exists",
  130. )
  131. # Validate role
  132. if user_data.role not in ["admin", "user"]:
  133. raise HTTPException(
  134. status_code=status.HTTP_400_BAD_REQUEST,
  135. detail="Role must be 'admin' or 'user'",
  136. )
  137. # Advanced auth validation
  138. if advanced_auth_enabled:
  139. if not user_data.email:
  140. raise HTTPException(
  141. status_code=status.HTTP_400_BAD_REQUEST,
  142. detail="Email is required when advanced authentication is enabled",
  143. )
  144. # Check if email already exists (case-insensitive)
  145. existing_email = await db.execute(select(User).where(func.lower(User.email) == func.lower(user_data.email)))
  146. if existing_email.scalar_one_or_none():
  147. raise HTTPException(
  148. status_code=status.HTTP_400_BAD_REQUEST,
  149. detail="Email already exists",
  150. )
  151. # Generate password if advanced auth enabled, otherwise require password
  152. if advanced_auth_enabled:
  153. password = generate_secure_password()
  154. else:
  155. if not user_data.password:
  156. raise HTTPException(
  157. status_code=status.HTTP_400_BAD_REQUEST,
  158. detail="Password is required when advanced authentication is disabled",
  159. )
  160. password = user_data.password
  161. new_user = User(
  162. username=user_data.username,
  163. email=user_data.email,
  164. password_hash=get_password_hash(password),
  165. role=user_data.role,
  166. is_active=True,
  167. )
  168. # Handle group assignments
  169. if user_data.group_ids:
  170. groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
  171. groups = groups_result.scalars().all()
  172. if len(groups) != len(user_data.group_ids):
  173. raise HTTPException(
  174. status_code=status.HTTP_400_BAD_REQUEST,
  175. detail="One or more group IDs are invalid",
  176. )
  177. new_user.groups = list(groups)
  178. db.add(new_user)
  179. await db.flush()
  180. await ensure_user_finance_defaults(db, new_user)
  181. await db.commit()
  182. await db.refresh(new_user)
  183. # Send welcome email if advanced auth enabled
  184. if advanced_auth_enabled and new_user.email:
  185. try:
  186. smtp_settings = await get_smtp_settings(db)
  187. if smtp_settings:
  188. login_url = await get_external_login_url(db)
  189. subject, text_body, html_body = await create_welcome_email_from_template(
  190. db, new_user.username, password, login_url
  191. )
  192. send_email(smtp_settings, new_user.email, subject, text_body, html_body)
  193. logger.info(f"Welcome email sent to {new_user.email}")
  194. else:
  195. logger.warning(f"SMTP not configured, could not send welcome email to {new_user.email}")
  196. except Exception as e:
  197. logger.error(f"Failed to send welcome email: {e}")
  198. # Don't fail user creation if email fails
  199. return _user_to_response(new_user)
  200. @router.get("/slim", response_model=list[UserSlim])
  201. async def list_users_slim(
  202. _: User | None = RequireAnyPermissionIfAuthEnabled(Permission.USERS_READ_SLIM, Permission.USERS_READ),
  203. db: AsyncSession = Depends(get_db),
  204. ):
  205. """List users as ``{id, username}`` only (#1894).
  206. Exists so an API key -- or a group that should not see emails, roles and
  207. permission sets -- can turn the ``created_by_id`` values it already gets
  208. back from archives, stats and the queue into names.
  209. ``USERS_READ`` is accepted alongside ``USERS_READ_SLIM`` because it is
  210. strictly broader; groups that already hold it keep working without a
  211. permission backfill. For API keys only the slim permission resolves (the
  212. full one is unmapped = administrative), so a key reaches this and not the
  213. listing above.
  214. Declared before ``/{user_id}`` on purpose: FastAPI matches in declaration
  215. order, and the reverse order would parse "slim" as the int path parameter
  216. and answer 422.
  217. """
  218. result = await db.execute(select(User.id, User.username).order_by(User.username))
  219. return [UserSlim(id=row.id, username=row.username) for row in result.all()]
  220. @router.get("/{user_id}", response_model=UserResponse)
  221. async def get_user(
  222. user_id: int,
  223. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  224. db: AsyncSession = Depends(get_db),
  225. ):
  226. """Get a user by ID. Read-only — gated on ``USERS_READ`` only."""
  227. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  228. user = result.scalar_one_or_none()
  229. if not user:
  230. raise HTTPException(
  231. status_code=status.HTTP_404_NOT_FOUND,
  232. detail="User not found",
  233. )
  234. return _user_to_response(user)
  235. @router.patch("/{user_id}", response_model=UserResponse)
  236. async def update_user(
  237. user_id: int,
  238. user_data: UserUpdate,
  239. _admin: User | None = RequireAdminIfAuthEnabled(),
  240. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_UPDATE),
  241. db: AsyncSession = Depends(get_db),
  242. ):
  243. """Update a user."""
  244. close_sockets = False
  245. deactivated_keys: list[int] = []
  246. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  247. user = result.scalar_one_or_none()
  248. if not user:
  249. raise HTTPException(
  250. status_code=status.HTTP_404_NOT_FOUND,
  251. detail="User not found",
  252. )
  253. # Prevent deactivating the last admin
  254. if user_data.is_active is False and user.is_admin:
  255. # Count admins by role or Administrators group membership
  256. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
  257. role_admins = admin_count_result.scalars().all()
  258. # Also check for users in Administrators group
  259. admin_group_result = await db.execute(
  260. select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
  261. )
  262. admin_group = admin_group_result.scalar_one_or_none()
  263. group_admins = [u for u in (admin_group.users if admin_group else []) if u.is_active]
  264. # Combine unique admins
  265. all_admins = {u.id for u in role_admins} | {u.id for u in group_admins}
  266. if len(all_admins) <= 1 and user.id in all_admins:
  267. raise HTTPException(
  268. status_code=status.HTTP_400_BAD_REQUEST,
  269. detail="Cannot deactivate the last admin user",
  270. )
  271. # Prevent changing role of last admin
  272. if user_data.role and user_data.role != "admin" and user.role == "admin":
  273. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.is_active.is_(True)))
  274. admin_count = len(admin_count_result.scalars().all())
  275. if admin_count <= 1:
  276. raise HTTPException(
  277. status_code=status.HTTP_400_BAD_REQUEST,
  278. detail="Cannot change role of the last admin user",
  279. )
  280. if user_data.username is not None:
  281. # Check if new username already exists (case-insensitive)
  282. existing_user = await db.execute(
  283. select(User).where(func.lower(User.username) == func.lower(user_data.username), User.id != user_id)
  284. )
  285. if existing_user.scalar_one_or_none():
  286. raise HTTPException(
  287. status_code=status.HTTP_400_BAD_REQUEST,
  288. detail="Username already exists",
  289. )
  290. if user_data.username != user.username:
  291. # The name passes on: a deleted user's still-valid JWTs under the new
  292. # name must not sign this user in, and what is pending under the old
  293. # one must not reach whoever is given it next. This user's own
  294. # sessions carry the old name and end anyway. A change of case counts
  295. # too: SQLite compares only ASCII letters without case, so 'Ärger'
  296. # and 'ärger' are two names there.
  297. await drop_pending_tokens(db, user.username)
  298. user.password_changed_at = utcnow_naive()
  299. close_sockets = True
  300. user.username = user_data.username
  301. if user_data.email is not None:
  302. # Check if new email already exists (case-insensitive)
  303. existing_email = await db.execute(
  304. select(User).where(func.lower(User.email) == func.lower(user_data.email), User.id != user_id)
  305. )
  306. if existing_email.scalar_one_or_none():
  307. raise HTTPException(
  308. status_code=status.HTTP_400_BAD_REQUEST,
  309. detail="Email already exists",
  310. )
  311. user.email = user_data.email
  312. if user_data.password is not None:
  313. if getattr(user, "auth_source", "local") == "ldap":
  314. raise HTTPException(
  315. status_code=status.HTTP_400_BAD_REQUEST,
  316. detail="Cannot set password for LDAP users",
  317. )
  318. user.password_hash = get_password_hash(user_data.password)
  319. # As a reset: sessions and pending tokens of the old password end (an
  320. # admin setting a password is often taking an account back).
  321. await drop_pending_tokens(db, user.username)
  322. user.password_changed_at = utcnow_naive()
  323. close_sockets = True
  324. if user_data.role is not None:
  325. if user_data.role not in ["admin", "user"]:
  326. raise HTTPException(
  327. status_code=status.HTTP_400_BAD_REQUEST,
  328. detail="Role must be 'admin' or 'user'",
  329. )
  330. user.role = user_data.role
  331. if user_data.is_active is not None:
  332. if user.is_active and not user_data.is_active:
  333. # Deactivated: their printers go at the scope refresh below, but
  334. # events of no printer (inventory, SpoolBuddy tags, announcements)
  335. # would still reach their open dashboards, and their tokens would
  336. # open new ones. The same for their API keys' (a kiosk), which
  337. # REST already refuses for a deactivated owner.
  338. from backend.app.api.routes.api_keys import drop_api_key_tokens
  339. await drop_pending_tokens(db, user.username)
  340. deactivated_keys = list(
  341. (await db.execute(select(APIKey.id).where(APIKey.user_id == user_id))).scalars().all()
  342. )
  343. await drop_api_key_tokens(db, deactivated_keys)
  344. close_sockets = True
  345. user.is_active = user_data.is_active
  346. # Handle group assignments
  347. if user_data.group_ids is not None:
  348. groups_result = await db.execute(select(Group).where(Group.id.in_(user_data.group_ids)))
  349. groups = groups_result.scalars().all()
  350. if len(groups) != len(user_data.group_ids):
  351. raise HTTPException(
  352. status_code=status.HTTP_400_BAD_REQUEST,
  353. detail="One or more group IDs are invalid",
  354. )
  355. user.groups = list(groups)
  356. await ensure_user_finance_defaults(db, user)
  357. await db.commit()
  358. if deactivated_keys:
  359. await ws_manager.close_api_keys(deactivated_keys)
  360. if close_sockets:
  361. # Open dashboards are not signed in again. After a rename they keep the
  362. # name they connected with and look their printers up by it on every
  363. # scope refresh (the next user given it); after a password set they are
  364. # whoever held the account before.
  365. await ws_manager.close_user(user_id)
  366. if user_data.group_ids is not None or user_data.role is not None or user_data.is_active is not None:
  367. # Groups, admin role and deactivation all change which printers the
  368. # user's open dashboards may hear about (#1727)
  369. await ws_manager.refresh_printer_scopes()
  370. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  371. user = result.scalar_one()
  372. return _user_to_response(user)
  373. @router.get("/{user_id}/items-count")
  374. async def get_user_items_count(
  375. user_id: int,
  376. _: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_READ),
  377. db: AsyncSession = Depends(get_db),
  378. ):
  379. """Get count of items created by this user. Read-only — gated on
  380. ``USERS_READ`` only."""
  381. # Verify user exists
  382. result = await db.execute(select(User).where(User.id == user_id))
  383. if not result.scalar_one_or_none():
  384. raise HTTPException(
  385. status_code=status.HTTP_404_NOT_FOUND,
  386. detail="User not found",
  387. )
  388. # Count archives
  389. archives_result = await db.execute(select(func.count(PrintArchive.id)).where(PrintArchive.created_by_id == user_id))
  390. archives_count = archives_result.scalar() or 0
  391. # Count queue items
  392. queue_result = await db.execute(
  393. select(func.count(PrintQueueItem.id)).where(PrintQueueItem.created_by_id == user_id)
  394. )
  395. queue_items_count = queue_result.scalar() or 0
  396. # Count library files
  397. library_result = await db.execute(
  398. select(func.count(LibraryFile.id)).where(
  399. LibraryFile.created_by_id == user_id,
  400. LibraryFile.deleted_at.is_(None),
  401. )
  402. )
  403. library_files_count = library_result.scalar() or 0
  404. return {
  405. "archives": archives_count,
  406. "queue_items": queue_items_count,
  407. "library_files": library_files_count,
  408. }
  409. @router.delete("/{user_id}", status_code=status.HTTP_204_NO_CONTENT)
  410. async def delete_user(
  411. user_id: int,
  412. delete_items: bool = Query(False, description="Delete all items created by this user"),
  413. _admin: User | None = RequireAdminIfAuthEnabled(),
  414. current_user: User | None = RequirePermissionIfAuthEnabled(Permission.USERS_DELETE),
  415. db: AsyncSession = Depends(get_db),
  416. ):
  417. """Delete a user.
  418. If delete_items=True, all archives, queue items, and library files created by
  419. this user will also be deleted. Otherwise, these items will become "ownerless"
  420. (created_by_id set to NULL by the foreign key constraint).
  421. """
  422. result = await db.execute(select(User).where(User.id == user_id).options(selectinload(User.groups)))
  423. user = result.scalar_one_or_none()
  424. if not user:
  425. raise HTTPException(
  426. status_code=status.HTTP_404_NOT_FOUND,
  427. detail="User not found",
  428. )
  429. # Prevent deleting the last admin
  430. if user.is_admin:
  431. # Count admins by role or Administrators group membership
  432. admin_count_result = await db.execute(select(User).where(User.role == "admin", User.id != user_id))
  433. other_role_admins = admin_count_result.scalars().all()
  434. # Also check for users in Administrators group
  435. admin_group_result = await db.execute(
  436. select(Group).where(Group.name == "Administrators").options(selectinload(Group.users))
  437. )
  438. admin_group = admin_group_result.scalar_one_or_none()
  439. other_group_admins = [u for u in (admin_group.users if admin_group else []) if u.id != user_id and u.is_active]
  440. # Combine unique admins
  441. all_other_admins = {u.id for u in other_role_admins} | {u.id for u in other_group_admins}
  442. if len(all_other_admins) == 0:
  443. raise HTTPException(
  444. status_code=status.HTTP_400_BAD_REQUEST,
  445. detail="Cannot delete the last admin user",
  446. )
  447. # Prevent deleting yourself (only if auth is enabled and we have a current user)
  448. if current_user and user.id == current_user.id:
  449. raise HTTPException(
  450. status_code=status.HTTP_400_BAD_REQUEST,
  451. detail="Cannot delete your own account",
  452. )
  453. # Photo directories of the library rows about to go, resolved while the
  454. # rows still exist to say which ids they belong to. Removed after the
  455. # commit, so a failed delete leaves the pictures alone (#3077).
  456. doomed_library_file_ids: list[int] = []
  457. if delete_items:
  458. doomed_library_file_ids = list(
  459. (await db.execute(select(LibraryFile.id).where(LibraryFile.created_by_id == user_id))).scalars().all()
  460. )
  461. # Delete all items created by this user. The print log stays, without
  462. # the archives it pointed at; archives of other users printed from
  463. # these library files lose that link (both SET NULL on PostgreSQL).
  464. from backend.app.services.library_trash import delete_dependent_variants, release_queue_references
  465. await delete_dependent_variants(db, doomed_library_file_ids)
  466. await release_queue_references(db, doomed_library_file_ids)
  467. # A print still running keeps what it needs to be billed when it
  468. # ends: its queue row and the user's archive it prints are detached
  469. # (no user), as without delete_items, rather than deleted. Deleted, the
  470. # completion found no queue row and charged the archive's creator
  471. # instead (for a reprint someone else, who never printed it), or found
  472. # no archive and billed nothing, its reservation held for good.
  473. from sqlalchemy import and_, or_
  474. from backend.app.models.finance import BudgetReservation
  475. running_rows = (
  476. await db.execute(
  477. select(PrintQueueItem.id, PrintQueueItem.archive_id).where(
  478. PrintQueueItem.created_by_id == user_id, PrintQueueItem.status == "printing"
  479. )
  480. )
  481. ).all()
  482. running_items = [row.id for row in running_rows]
  483. running_archives = [row.archive_id for row in running_rows if row.archive_id is not None]
  484. running_archives += (
  485. (
  486. await db.execute(
  487. select(PrintArchive.id).where(
  488. PrintArchive.created_by_id == user_id, PrintArchive.status == "printing"
  489. )
  490. )
  491. )
  492. .scalars()
  493. .all()
  494. )
  495. # Also one another user is printing (a reprint of this user's archive,
  496. # dispatched before the printer's start marks the archive printing):
  497. # deleted, it took their queue row with it and their print went unbilled.
  498. own_archives = select(PrintArchive.id).where(PrintArchive.created_by_id == user_id)
  499. running_archives += (
  500. (
  501. await db.execute(
  502. select(PrintQueueItem.archive_id).where(
  503. PrintQueueItem.status == "printing", PrintQueueItem.archive_id.in_(own_archives)
  504. )
  505. )
  506. )
  507. .scalars()
  508. .all()
  509. )
  510. if running_items:
  511. await db.execute(
  512. sql_update(PrintQueueItem).where(PrintQueueItem.id.in_(running_items)).values(created_by_id=None)
  513. )
  514. if running_archives:
  515. await db.execute(
  516. sql_update(PrintArchive)
  517. .where(PrintArchive.id.in_(running_archives), PrintArchive.created_by_id == user_id)
  518. .values(created_by_id=None)
  519. )
  520. # Budget held for the queue items and archives deleted here: no print
  521. # completion can settle it any more (it finds no archive), and an
  522. # active reservation held the cost centre's budget, and kept the cost
  523. # centre from being deleted, for good. Nothing is charged for those:
  524. # they go, as the admin chose. (A running print's was detached above.)
  525. await db.execute(
  526. sql_update(BudgetReservation)
  527. .where(
  528. BudgetReservation.status == "active",
  529. or_(
  530. and_(
  531. BudgetReservation.source_type == "print_queue",
  532. BudgetReservation.source_id.in_(
  533. select(PrintQueueItem.id).where(PrintQueueItem.created_by_id == user_id)
  534. ),
  535. ),
  536. BudgetReservation.print_archive_id.in_(
  537. select(PrintArchive.id).where(PrintArchive.created_by_id == user_id)
  538. ),
  539. ),
  540. )
  541. .values(status="released", released_at=datetime.now(timezone.utc))
  542. )
  543. await db.execute(delete(PrintArchive).where(PrintArchive.created_by_id == user_id))
  544. await db.execute(delete(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id))
  545. await db.execute(delete(LibraryFile).where(LibraryFile.created_by_id == user_id))
  546. await db.execute(delete(PrintBatch).where(PrintBatch.created_by_id == user_id))
  547. else:
  548. # Explicitly set created_by_id to NULL for all items (ensures consistent behavior
  549. # across different database backends, including SQLite without foreign key support).
  550. # PrintBatch carries the same created_by_id FK with ondelete=SET NULL — admin-deleted
  551. # users would otherwise leave dangling created_by_id on SQLite (#1295 review nit).
  552. from sqlalchemy import update
  553. from backend.app.core.printer_scope import resolve_user_printer_scope
  554. # An ownerless "any <model>" job may run on any printer of that model;
  555. # the scheduler held it to this user's printers only while it was
  556. # theirs (#1727). Stage those jobs instead, so an admin decides where
  557. # they run rather than the job quietly spreading across the fleet.
  558. if not (await resolve_user_printer_scope(db, user)).is_unrestricted:
  559. await db.execute(
  560. update(PrintQueueItem)
  561. .where(
  562. PrintQueueItem.created_by_id == user_id,
  563. PrintQueueItem.printer_id.is_(None),
  564. PrintQueueItem.status == "pending",
  565. )
  566. .values(manual_start=True)
  567. )
  568. await db.execute(update(PrintArchive).where(PrintArchive.created_by_id == user_id).values(created_by_id=None))
  569. await db.execute(
  570. update(PrintQueueItem).where(PrintQueueItem.created_by_id == user_id).values(created_by_id=None)
  571. )
  572. await db.execute(update(LibraryFile).where(LibraryFile.created_by_id == user_id).values(created_by_id=None))
  573. await db.execute(update(PrintBatch).where(PrintBatch.created_by_id == user_id).values(created_by_id=None))
  574. # The print log keeps who started each run as long as the user exists, and
  575. # outlives them either way (ON DELETE SET NULL, which SQLite doesn't apply).
  576. await db.execute(sql_update(PrintLogEntry).where(PrintLogEntry.created_by_id == user_id).values(created_by_id=None))
  577. # Drop API keys owned by this user. The model declares ON DELETE CASCADE
  578. # so Postgres handles this automatically, but SQLite ships with FK
  579. # enforcement off (the project's existing pattern — same reason the
  580. # blocks above set created_by_id = NULL by hand). Without an explicit
  581. # DELETE here, deleting a user on SQLite would leave their API keys
  582. # with a dangling user_id and ``_user_from_api_key`` would return None,
  583. # silently degrading the keys to anonymous (and locking them out of
  584. # /cloud/* — but the rest of the API would still accept them, which is
  585. # exactly the orphan-key state the CASCADE was meant to prevent).
  586. from backend.app.api.routes.api_keys import drop_api_key_tokens
  587. key_ids = (await db.execute(select(APIKey.id).where(APIKey.user_id == user_id))).scalars().all()
  588. await drop_api_key_tokens(db, key_ids)
  589. await db.execute(delete(APIKey).where(APIKey.user_id == user_id))
  590. # Drop OIDC links, MFA state, and long-lived camera-stream tokens
  591. # owned by this user. Same SQLite/FK pattern as APIKey above. Without
  592. # these, deleting a user on SQLite leaves:
  593. # - UserOIDCLink: the OIDC callback finds the orphan link, fails to
  594. # resolve the (now missing) user, and falls through to
  595. # "account_inactive" instead of triggering auto_create (#1285).
  596. # - UserTOTP: MFA secrets persist in the DB after the owning user.
  597. # - UserOTPCode: pending email OTP codes linger.
  598. # - LongLivedToken: per-user camera-stream tokens whose secret_hash
  599. # is still valid — verify() would happily match them by lookup
  600. # prefix even though the user is gone.
  601. await db.execute(delete(UserOIDCLink).where(UserOIDCLink.user_id == user_id))
  602. await db.execute(delete(UserTOTP).where(UserTOTP.user_id == user_id))
  603. await db.execute(delete(UserOTPCode).where(UserOTPCode.user_id == user_id))
  604. await db.execute(delete(LongLivedToken).where(LongLivedToken.user_id == user_id))
  605. # Connected apps, same SQLite/FK pattern. A leftover consent row would
  606. # skip the consent screen for whoever is next given this user id.
  607. from sqlalchemy import update as _update
  608. await db.execute(delete(ConnectedAppGrant).where(ConnectedAppGrant.user_id == user_id))
  609. await drop_pending_tokens(db, user.username)
  610. await db.execute(_update(ConnectedApp).where(ConnectedApp.created_by_id == user_id).values(created_by_id=None))
  611. # Email 2FA is a settings key named after the id, which no foreign key covers.
  612. await db.execute(delete(Settings).where(Settings.key == f"user_{user_id}_email_2fa_enabled"))
  613. await db.delete(user)
  614. await db.commit()
  615. # What still runs for them must not be credited to whoever gets the id next
  616. # (SQLite reuses it): a print, a slice, an open dashboard.
  617. printer_manager.forget_user(user_id)
  618. from backend.app.main import forget_print_creator
  619. forget_print_creator(user_id)
  620. slice_dispatch.forget_owner(user_id)
  621. await ws_manager.close_user(user_id)
  622. await ws_manager.close_api_keys(key_ids)
  623. from backend.app.api.routes.notifications import forget_app_senders
  624. forget_app_senders(key_ids, [user_id])
  625. await ws_manager.refresh_printer_scopes()
  626. for file_id in doomed_library_file_ids:
  627. remove_library_photos_dir(file_id)
  628. @router.post("/me/change-password", response_model=dict)
  629. async def change_own_password(
  630. password_data: ChangePasswordRequest,
  631. credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
  632. current_user: User | None = Depends(get_current_user_optional),
  633. db: AsyncSession = Depends(get_db),
  634. ):
  635. """Change the current user's password. Requires current password verification."""
  636. if not current_user:
  637. raise HTTPException(
  638. status_code=status.HTTP_401_UNAUTHORIZED,
  639. detail="Authentication required to change password",
  640. )
  641. # Block password change for LDAP users
  642. if getattr(current_user, "auth_source", "local") == "ldap":
  643. raise HTTPException(
  644. status_code=status.HTTP_400_BAD_REQUEST,
  645. detail="Cannot change password for LDAP users — passwords are managed by the LDAP server",
  646. )
  647. # Verify current password
  648. if not current_user.password_hash:
  649. raise HTTPException(
  650. status_code=status.HTTP_400_BAD_REQUEST,
  651. detail="Account has no local password set",
  652. )
  653. # Rate-limit failed password-change attempts (H-R5-A)
  654. from backend.app.api.routes.mfa import MAX_2FA_ATTEMPTS, check_rate_limit, record_failed_attempt
  655. await check_rate_limit(db, current_user.username, event_type="password_change", max_attempts=MAX_2FA_ATTEMPTS)
  656. if not verify_password(password_data.current_password, current_user.password_hash):
  657. await record_failed_attempt(db, current_user.username, event_type="password_change")
  658. raise HTTPException(
  659. status_code=status.HTTP_400_BAD_REQUEST,
  660. detail="Current password is incorrect",
  661. )
  662. # Fetch user from this session to ensure changes are persisted
  663. result = await db.execute(select(User).where(User.id == current_user.id))
  664. user = result.scalar_one_or_none()
  665. if not user:
  666. raise HTTPException(
  667. status_code=status.HTTP_404_NOT_FOUND,
  668. detail="User not found",
  669. )
  670. # Update password
  671. user.password_hash = get_password_hash(password_data.new_password)
  672. user.password_changed_at = datetime.now(timezone.utc) # M-R7-B: invalidate all prior JWTs
  673. # The WebSocket and camera tokens too: reusable for an hour, they would
  674. # reconnect a closed dashboard straight away.
  675. await drop_pending_tokens(db, user.username)
  676. await db.commit()
  677. # Open dashboards are never signed in again: one someone else had open on
  678. # the account would keep receiving its printers. This tab signs in again
  679. # (the route revokes its JWT anyway).
  680. await ws_manager.close_user(user.id)
  681. # L-R6-A: Password verified successfully — reset the failure counter
  682. from backend.app.api.routes.mfa import clear_failed_attempts
  683. await clear_failed_attempts(db, user.username, event_type="password_change")
  684. # Revoke the current session token so the caller must re-authenticate (M-R5-A)
  685. if credentials is not None:
  686. try:
  687. payload = _jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
  688. jti = payload.get("jti")
  689. exp = payload.get("exp")
  690. if jti and exp:
  691. try:
  692. await revoke_jti(jti, datetime.fromtimestamp(exp, tz=timezone.utc), user.username)
  693. except Exception as exc:
  694. # B4: log so operators know revocation is broken; password was
  695. # already changed so the token will fail freshness checks anyway.
  696. import logging
  697. logging.getLogger(__name__).error(
  698. "Failed to revoke JTI after password change for user %s: %s", user.username, exc
  699. )
  700. except Exception:
  701. pass # Decode failure is harmless — token is already invalidated by password_changed_at
  702. return {"message": "Password changed successfully"}