users.py 27 KB

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