Coverage for src/local_deep_research/web/routers/auth.py: 91%

489 statements  

« prev     ^ index     » next       coverage.py v7.16.2, created at 2026-10-22 21:20 +0000

1""" 

2Authentication routes for login, register, and logout. 

3Uses SQLCipher encrypted databases with browser password manager support. 

4""" 

5 

6from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request 

7from fastapi.responses import JSONResponse, RedirectResponse 

8 

9from ..dependencies.auth import require_auth 

10from ..dependencies.flash import flash 

11from ..dependencies.rate_limit import ( 

12 INTEGRITY_CHECK_RATE_LIMIT, 

13 LOGIN_RATE_LIMIT, 

14 PASSWORD_CHANGE_RATE_LIMIT, 

15 REGISTRATION_RATE_LIMIT, 

16 VALIDATE_PASSWORD_RATE_LIMIT, 

17 limiter, 

18) 

19from ..dependencies.template_helpers import render_template 

20 

21import contextvars 

22import threading 

23import time 

24from datetime import datetime, timezone, UTC 

25 

26from loguru import logger 

27 

28from ...database.auth_db import auth_db_session 

29from ...database.encrypted_db import DatabaseInitializationError, db_manager 

30from ...database.rekey_lock import ( 

31 RekeyIndeterminateError, 

32 RekeyLockContentionError, 

33 RekeyOperationalError, 

34 RekeySidecarIOError, 

35) 

36from ...database.models.auth import User 

37from ...database.thread_local_session import thread_cleanup 

38from sqlalchemy.exc import IntegrityError 

39from ..auth.session_manager import ( 

40 session_manager, 

41) # singleton from session_manager module 

42from ..server_config import load_server_config 

43 

44import posixpath 

45from urllib.parse import ( 

46 unquote, 

47 unquote_plus, 

48 urljoin, 

49 urlparse, 

50 urlsplit, 

51 urlunsplit, 

52) 

53 

54from ...security.url_validator import URLValidator 

55from ...security.account_lockout import get_account_lockout_manager 

56from ...security.password_validator import PasswordValidator 

57from ...security.log_sanitizer import redact_and_bound_for_log 

58from .notes import _log_value_preview 

59from typing import Annotated 

60 

61router = APIRouter(prefix="/auth", tags=["auth"]) 

62 

63 

64@router.get("/csrf-token") 

65def get_csrf_token(request: Request): 

66 """Get CSRF token for API requests.""" 

67 from ..dependencies.csrf import generate_csrf_token, get_auth_context 

68 

69 context = get_auth_context(request.session) 

70 expected_context = request.headers.get("X-LDR-Auth-Context") 

71 if expected_context is not None and expected_context != context: 

72 # A recovery fetch is tied to the page's original login. Ordinary 

73 # anonymous token fetches (login/register forms) remain supported. 

74 return JSONResponse( 

75 { 

76 "error": "Your sign-in changed. Reload this page before trying again." 

77 if context 

78 else "Authentication required" 

79 }, 

80 status_code=409 if context else 401, 

81 headers={"Cache-Control": "no-store"}, 

82 ) 

83 token = generate_csrf_token(request) 

84 return JSONResponse( 

85 {"csrf_token": token, "auth_context": context}, 

86 headers={"Cache-Control": "no-store"}, 

87 ) 

88 

89 

90def _rollback_partial_session(request: Request, username: str) -> None: 

91 """Tear down a half-created login/registration session. 

92 

93 Session creation sets the cookie keys (session_id/username) and creates a 

94 server-side session BEFORE the temp-auth / password-store steps. If a later 

95 step fails, the caller returns a 500 — but SessionMiddleware would still 

96 write the partially-set cookie on that response, effectively logging the 

97 user in despite the reported failure (and leaking a server-side session + 

98 password entry). Clear the cookie and best-effort tear down the server-side 

99 session + password-store entry so the reported outcome matches reality. 

100 

101 Best-effort: cleanup must never mask the caller's original error. Ports the 

102 Flask fix from #5006 (the FastAPI branch creates sessions inline rather than 

103 via a shared ``_create_user_session`` helper). 

104 """ 

105 session_id = request.session.get("session_id") 

106 request.session.clear() 

107 if session_id: 107 ↛ exitline 107 didn't return from function '_rollback_partial_session' because the condition on line 107 was always true

108 try: 

109 session_manager.destroy_session(session_id) 

110 from ...database.session_passwords import session_password_store 

111 

112 session_password_store.clear_session(username, session_id) 

113 except Exception: 

114 logger.exception("Failed to roll back a partial user session") 

115 

116 

117def _disconnect_user_sockets(username: str) -> None: 

118 """Disconnect ALL of ``username``'s live sockets and drop subscriptions. 

119 

120 A Socket.IO connection is authorised once at handshake and then keeps 

121 delivering that user's events (including ``settings_changed``) for its 

122 whole lifetime. Used by the 

123 password-change flow, which destroys every session for the user, so all 

124 of their sockets must go. For single-session logout use 

125 ``_disconnect_session_sockets`` instead so other tabs / devices survive. 

126 

127 Retargeted from main's deleted Flask ``SocketIOService`` (#5535) onto the 

128 ASGI socket layer. Best-effort: socket teardown must never break the auth 

129 flow, and a non-web context (tests, CLI) simply has no loop running. 

130 """ 

131 try: 

132 from ..services.socketio_asgi import disconnect_user 

133 

134 disconnect_user(username) 

135 except Exception: 

136 logger.exception( 

137 f"Failed to disconnect sockets for {_log_value_preview(username)}" 

138 ) 

139 

140 

141def _disconnect_session_sockets(session_id: str) -> None: 

142 """Disconnect only the sockets belonging to ``session_id``. 

143 

144 Used by single-session logout: it tears down the sockets of the session 

145 being logged out without disconnecting the user's other still-valid 

146 sessions. Best-effort — socket teardown must never break logout. 

147 """ 

148 try: 

149 from ..services.socketio_asgi import disconnect_session 

150 

151 disconnect_session(session_id) 

152 except Exception: 

153 logger.exception("Failed to disconnect sockets for session") 

154 

155 

156# chat.js pre-fills the chat box from this query parameter of a chat URL. 

157_CHAT_QUERY_PARAM = "q" 

158 

159 

160def _opens_chat_page(path: str) -> bool: 

161 """Whether a redirect to ``path`` would load the chat page. 

162 

163 The browser removes dot segments (``%2e`` included) from a redirect 

164 target and the server percent-decodes the path before routing, so the 

165 check uses the decoded path without dot segments. ``/chat`` counts too: 

166 the router redirects it to ``/chat/`` and keeps the query. Ignoring 

167 letter case can only make the check match more paths. 

168 """ 

169 decoded = posixpath.normpath("/" + unquote(path).lstrip("/")).lower() 

170 return decoded == "/chat" or decoded.startswith("/chat/") 

171 

172 

173def _drop_chat_query(target: str, base_url: str) -> str: 

174 """Remove ``q`` from a login ``next`` that leads to the chat page. 

175 

176 A link from another site can open 

177 ``/auth/login?next=/chat/?q=<text>``. After the login POST the browser 

178 follows this app's own redirect, so the chat page sees a same-origin 

179 navigation, ``_drop_foreign_chat_query`` (chat.py) keeps ``q``, and 

180 chat.js would pre-fill the linked text. So the login page drops ``q`` 

181 from such a target, both in the form it renders and in the redirect 

182 after login. The rest of ``next`` is kept as written, and any other 

183 target is returned unchanged. This runs alongside, not instead of, 

184 ``URLValidator.get_safe_redirect_path``. 

185 """ 

186 try: 

187 parts = urlsplit(target) 

188 resolved_path = urlsplit(urljoin(base_url, target)).path 

189 except ValueError: 

190 # Unparseable (for example "http://["). get_safe_redirect_path 

191 # raises on the same input, so it never redirects anywhere. 

192 return target 

193 if not parts.query or not _opens_chat_page(resolved_path): 

194 return target 

195 kept = [ 

196 item 

197 for item in parts.query.split("&") 

198 # Decode the name the way chat.js's URLSearchParams does. 

199 if unquote_plus(item.split("=", 1)[0]) != _CHAT_QUERY_PARAM 

200 ] 

201 return urlunsplit(parts._replace(query="&".join(kept))) 

202 

203 

204@router.get("/login") 

205def login_page(request: Request, next: Annotated[str, Query()] = ""): 

206 """Login page (GET only).""" 

207 config = load_server_config() 

208 if request.session.get("username"): 

209 return RedirectResponse(url="/", status_code=302) 

210 

211 return render_template( 

212 request, 

213 "auth/login.html", 

214 { 

215 "has_encryption": db_manager.has_encryption, 

216 "allow_registrations": config.get("allow_registrations", True), 

217 "next_page": _drop_chat_query(next, str(request.base_url)), 

218 }, 

219 ) 

220 

221 

222@router.post("/login") 

223@limiter.limit(LOGIN_RATE_LIMIT) 

224def login( 

225 request: Request, 

226 username: Annotated[str, Form()] = "", 

227 password: Annotated[str, Form()] = "", # noqa: S107 

228 remember: Annotated[str, Form()] = "false", 

229 next: Annotated[str, Query()] = "", 

230): 

231 """Login handler (POST only). Rate limited to prevent brute force.""" 

232 config = load_server_config() 

233 username = username.strip() 

234 remember_bool = remember == "true" 

235 

236 if not username or not password: 

237 flash(request, "Username and password are required", "error") 

238 return render_template( 

239 request, 

240 "auth/login.html", 

241 { 

242 "has_encryption": db_manager.has_encryption, 

243 "allow_registrations": config.get("allow_registrations", True), 

244 }, 

245 status_code=400, 

246 ) 

247 

248 # Check account lockout 

249 lockout_mgr = get_account_lockout_manager() 

250 if lockout_mgr.is_locked(username): 

251 logger.warning( 

252 f"Login attempt for locked account: {redact_and_bound_for_log(username, 50)}" 

253 ) 

254 flash( 

255 request, 

256 "Account is temporarily locked. Please try again later.", 

257 "error", 

258 ) 

259 return render_template( 

260 request, 

261 "auth/login.html", 

262 { 

263 "has_encryption": db_manager.has_encryption, 

264 "allow_registrations": config.get("allow_registrations", True), 

265 }, 

266 status_code=429, 

267 ) 

268 

269 # Opening the database is this route's password check only with SQLCipher. 

270 # The plaintext development fallback has no independent credential check: 

271 # a successful open there does not prove the caller knows the account's 

272 # password. A supported shared plaintext mode needs separate authentication. 

273 # With SQLCipher, distinguish these two failure modes: 

274 # - return None → credentials invalid OR DB missing → 401, count toward lockout 

275 # - raise DatabaseInitializationError → credentials valid but schema 

276 # can't be brought up (e.g. world-writable migrations dir tripping 

277 # the alembic_runner permission check) → 503, do NOT count toward 

278 # lockout. The user's password is correct; punishing them with a 

279 # lockout for a server-side configuration problem would be wrong. 

280 try: 

281 engine = db_manager.open_user_database(username, password) 

282 except DatabaseInitializationError: 

283 logger.warning( 

284 f"Login refused for {redact_and_bound_for_log(username, 50)}: " 

285 "database initialisation failed (see traceback above). " 

286 "Lockout counter NOT incremented — credentials are valid." 

287 ) 

288 flash( 

289 request, 

290 "Database initialisation failed. The server is misconfigured — " 

291 "please check the server logs or contact the administrator.", 

292 "error", 

293 ) 

294 return render_template( 

295 request, 

296 "auth/login.html", 

297 { 

298 "has_encryption": db_manager.has_encryption, 

299 "allow_registrations": config.get("allow_registrations", True), 

300 }, 

301 status_code=503, 

302 ) 

303 except RekeyOperationalError as error: 

304 # Durable rekey state is indeterminate, pending recovery, or the 

305 # sidecar metadata cannot be read, or another holder (a password 

306 # change of a large database, a stopped process) kept the sidecar 

307 # past the open's wait bound (RekeyLockContentionError). Credentials 

308 # cannot be validated in any of these cases, so fail closed WITHOUT 

309 # counting toward lockout — the password may well be correct. 

310 sidecar_detail = "" 

311 if isinstance(error, RekeySidecarIOError): 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true

312 sidecar_detail = ( 

313 f" (sidecar {error.operation.value} failed, " 

314 f"errno={error.errno})" 

315 ) 

316 logger.warning( 

317 f"Login refused for {redact_and_bound_for_log(username, 50)}: " 

318 f"rekey state requires operator attention " 

319 f"({type(error).__name__}){sidecar_detail}" 

320 ) 

321 flash( 

322 request, 

323 "Your account database is pending maintenance. " 

324 "Please try again shortly or contact the administrator.", 

325 "error", 

326 ) 

327 return render_template( 

328 request, 

329 "auth/login.html", 

330 { 

331 "has_encryption": db_manager.has_encryption, 

332 "allow_registrations": config.get("allow_registrations", True), 

333 }, 

334 status_code=503, 

335 ) 

336 

337 if engine is None: 

338 lockout_mgr.record_failure(username) 

339 logger.warning( 

340 f"Failed login attempt for username: {redact_and_bound_for_log(username, 50)}" 

341 ) 

342 flash(request, "Invalid username or password", "error") 

343 return render_template( 

344 request, 

345 "auth/login.html", 

346 { 

347 "has_encryption": db_manager.has_encryption, 

348 "allow_registrations": config.get("allow_registrations", True), 

349 }, 

350 status_code=401, 

351 ) 

352 

353 # Success 

354 lockout_mgr.record_success(username) 

355 

356 # Prevent session fixation 

357 request.session.clear() 

358 

359 # Create the session atomically: on any post-creation failure below, roll 

360 # back the half-set cookie + server-side session so the error isn't a 

361 # silent login (see _rollback_partial_session / #5006). 

362 try: 

363 # Create session 

364 session_id = session_manager.create_session(username, remember_bool) 

365 request.session["session_id"] = session_id 

366 request.session["username"] = username 

367 # RememberMeMiddleware reads this to decide whether to strip 

368 # Max-Age/Expires from the session cookie (making it a browser 

369 # session cookie that's discarded on close). 

370 request.session["_remember_me"] = remember_bool 

371 

372 # Store password temporarily for post-login database access 

373 from ...database.temp_auth import temp_auth_store 

374 

375 auth_token = temp_auth_store.store_auth(username, password) 

376 request.session["temp_auth_token"] = auth_token 

377 

378 # Also store in session password store for metrics access 

379 from ...database.session_passwords import session_password_store 

380 

381 session_password_store.store_session_password( 

382 username, session_id, password 

383 ) 

384 except Exception: 

385 _rollback_partial_session(request, username) 

386 raise 

387 

388 logger.info(f"User {_log_value_preview(username)} logged in successfully") 

389 

390 # Defer non-critical post-login work to a background thread so the 

391 # redirect returns immediately (settings migration, library init, 

392 # news scheduler notify, and backup scheduling are all idempotent 

393 # and can safely run after the response). Copy the request context 

394 # so `get_current_username()` inside the worker resolves the user. 

395 _login_ctx = contextvars.copy_context() 

396 

397 def _ctx_post_login(): 

398 _login_ctx.run( 

399 _perform_post_login_tasks, username, password, session_id 

400 ) 

401 

402 thread = threading.Thread(target=_ctx_post_login, daemon=True) 

403 thread.start() 

404 

405 safe_path = URLValidator.get_safe_redirect_path(next, str(request.base_url)) 

406 if safe_path: 

407 safe_path = _drop_chat_query( 

408 safe_path.replace("\\", "/"), str(request.base_url) 

409 ) 

410 parsed = urlparse(safe_path) 

411 if not parsed.scheme and not parsed.netloc: 411 ↛ 413line 411 didn't jump to line 413 because the condition on line 411 was always true

412 return RedirectResponse(url=safe_path, status_code=302) 

413 return RedirectResponse(url="/", status_code=302) 

414 

415 

416@thread_cleanup 

417def _perform_post_login_tasks( 

418 username: str, password: str, session_id: str 

419) -> None: 

420 """Run non-critical post-login operations in a background thread. 

421 

422 Each operation is wrapped in its own try/except so that one failure 

423 does not prevent the others from running. All operations here are 

424 idempotent and safe to retry on the next login. 

425 

426 An outer try/except wraps the whole body so any exception that 

427 escapes the per-step handlers (for example a failure inside a 

428 ``with`` context manager's __enter__ / __exit__) is logged loudly 

429 with a traceback instead of dying silently in the daemon thread. 

430 """ 

431 try: 

432 _perform_post_login_tasks_body(username, password, session_id) 

433 except Exception: 

434 logger.exception( 

435 f"Post-login background thread crashed for user {_log_value_preview(username)}" 

436 ) 

437 

438 

439def _perform_post_login_tasks_body( 

440 username: str, password: str, session_id: str 

441) -> None: 

442 """Body of _perform_post_login_tasks — split out so the outer 

443 try/except in the wrapper catches anything the per-step handlers 

444 miss. See _perform_post_login_tasks for rationale.""" 

445 total_start = time.perf_counter() 

446 

447 # 1. Settings version check + migration 

448 # 

449 # ATOMICITY INVARIANT: the defaults import and the `app.version` 

450 # marker MUST be written in one `get_user_db_session(...)` scope 

451 # with a single terminal `db_session.commit()`. SQLite WAL rollback 

452 # then guarantees either both land or neither does — the only 

453 # acceptable states for `db_version_matches_package()` to behave 

454 # correctly on the next login. Splitting into two commits regresses 

455 # to the "sticky loop": `app.version` stays unwritten, every 

456 # subsequent login re-runs the ~498-row bulk insert (app.version is 

457 # not in default_settings.json, only `update_db_version()` writes 

458 # it). Do not factor these calls into separate sessions or allow 

459 # the `import_settings`/`update_db_version` calls to commit 

460 # internally here — both must be called with `commit=False`. 

461 step_start = time.perf_counter() 

462 try: 

463 from ...settings.manager import SettingsManager 

464 from ...database.session_context import get_user_db_session 

465 

466 with get_user_db_session(username, password) as db_session: 

467 settings_manager = SettingsManager(db_session) 

468 if not settings_manager.db_version_matches_package(): 

469 logger.info( 

470 f"Database version mismatch for {_log_value_preview(username)} " 

471 "- loading missing default settings" 

472 ) 

473 # override_locked: this only adds keys the upgrade 

474 # introduced, so a locked account still needs it. Spent via 

475 # a direct import_settings call — the 

476 # load_from_defaults_file wrapper no longer accepts 

477 # override_locked (#5841). 

478 settings_manager.import_settings( 

479 settings_manager.default_settings, 

480 commit=False, 

481 overwrite=False, 

482 override_locked=True, 

483 ) 

484 settings_manager.update_db_version(commit=False) 

485 db_session.commit() 

486 logger.info( 

487 f"Missing default settings loaded and version " 

488 f"updated for user {_log_value_preview(username)}" 

489 ) 

490 except Exception: 

491 logger.exception( 

492 f"Post-login settings migration failed for {_log_value_preview(username)}" 

493 ) 

494 _log_step_duration("step 1 (settings version check)", step_start, username) 

495 

496 # 2. Initialize library system (source types and default collection) 

497 step_start = time.perf_counter() 

498 try: 

499 from ...database.library_init import initialize_library_for_user 

500 

501 init_results = initialize_library_for_user(username, password) 

502 if init_results.get("success"): 

503 logger.info( 

504 f"Library system initialized for user {_log_value_preview(username)}" 

505 ) 

506 else: 

507 logger.warning( 

508 f"Library initialization issue for {_log_value_preview(username)}: " 

509 f"{init_results.get('error', 'Unknown error')}" 

510 ) 

511 except Exception: 

512 logger.exception( 

513 f"Post-login library init failed for {_log_value_preview(username)}" 

514 ) 

515 _log_step_duration("step 2 (library init)", step_start, username) 

516 

517 # 3. Update last_login in auth DB + notify news scheduler 

518 step_start = time.perf_counter() 

519 try: 

520 with auth_db_session() as auth_db: 

521 user = auth_db.query(User).filter_by(username=username).first() 

522 if user: 

523 user.last_login = datetime.now(UTC) 

524 

525 try: 

526 from ...scheduler.background import ( 

527 get_background_job_scheduler, 

528 ) 

529 

530 scheduler = get_background_job_scheduler() 

531 if scheduler.is_running: 

532 scheduler.update_user_info(username, password) 

533 logger.info( 

534 f"Updated scheduler with user info for {_log_value_preview(username)}" 

535 ) 

536 except Exception: 

537 logger.exception("Could not update scheduler on login") 

538 

539 auth_db.commit() 

540 except Exception: 

541 logger.exception( 

542 f"Post-login auth DB update failed for {_log_value_preview(username)}" 

543 ) 

544 _log_step_duration( 

545 "step 3 (auth DB + scheduler notify)", step_start, username 

546 ) 

547 

548 # Model cache refresh is handled by POST /settings/api/available-models; 

549 # ordinary GET reads use its 24h TTL. 

550 

551 # 4. Schedule background database backup if enabled 

552 step_start = time.perf_counter() 

553 try: 

554 from ...database.backup import get_backup_executor 

555 from ...settings.manager import SettingsManager 

556 from ...database.session_context import get_user_db_session 

557 

558 with get_user_db_session(username, password) as db_session: 

559 sm = SettingsManager(db_session) 

560 backup_enabled = sm.get_setting("backup.enabled", True) 

561 

562 if backup_enabled: 

563 max_backups = sm.get_setting("backup.max_count", 1) 

564 max_age_days = sm.get_setting("backup.max_age_days", 7) 

565 

566 get_backup_executor().submit_backup( 

567 username, password, max_backups, max_age_days 

568 ) 

569 logger.info( 

570 f"Background backup scheduled for user {_log_value_preview(username)}" 

571 ) 

572 except Exception: 

573 logger.exception( 

574 f"Post-login backup scheduling failed for {_log_value_preview(username)}" 

575 ) 

576 _log_step_duration("step 4 (schedule backup)", step_start, username) 

577 

578 # 6. Reconcile orphan IN_PROGRESS research records. If the server was 

579 # restarted mid-research, those rows point to dead threads and would 

580 # otherwise appear as "running forever" in the UI with no way to cancel. 

581 step_start = time.perf_counter() 

582 try: 

583 from ...database.session_context import get_user_db_session 

584 from ..queue.processor_v2 import queue_processor 

585 from ..research_state import user_research_start_gate 

586 

587 # Earlier post-login steps reuse this thread-local session. End any 

588 # read transaction before taking the process-wide gate -> DB order. 

589 with get_user_db_session(username, password) as ordering_session: 

590 ordering_session.rollback() 

591 

592 # A concurrent start holds this same gate until worker registration, 

593 # so login reconciliation cannot mistake its fresh durable claim for 

594 # a server-restart orphan. 

595 with user_research_start_gate(username): 

596 with get_user_db_session(username, password) as db_session: 

597 queue_processor.reconcile_orphan_active_research( 

598 username, db_session 

599 ) 

600 except Exception: 

601 logger.exception( 

602 f"Post-login research reconciliation failed for {_log_value_preview(username)}" 

603 ) 

604 _log_step_duration( 

605 "step 6 (reconcile orphan research)", step_start, username 

606 ) 

607 

608 # 7. Resume queued researches. The queue processor's wake-list 

609 # (_users_to_check) is in-memory, so after a server restart any QUEUED 

610 # rows would never dispatch — registration normally happens at queue 

611 # time (notify_research_queued), and here at login for recovery. Under 

612 # Flask this was fed by a before_request hook on every authenticated 

613 # request; login is the FastAPI-native anchor where username, 

614 # session_id, and the session password are all freshly available. If 

615 # the user has nothing queued, the first loop tick deregisters them. 

616 step_start = time.perf_counter() 

617 try: 

618 from ..queue.processor_v2 import queue_processor 

619 

620 queue_processor.notify_user_activity(username, session_id) 

621 except Exception: 

622 logger.exception( 

623 f"Post-login queue-resume registration failed for {_log_value_preview(username)}" 

624 ) 

625 _log_step_duration("step 7 (resume queued research)", step_start, username) 

626 

627 total_ms = (time.perf_counter() - total_start) * 1000 

628 if total_ms > 1000: 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true

629 logger.info( 

630 f"Post-login tasks completed for user {_log_value_preview(username)} " 

631 f"(total: {total_ms:.0f}ms)" 

632 ) 

633 else: 

634 logger.info( 

635 f"Post-login tasks completed for user {_log_value_preview(username)} ({total_ms:.0f}ms)" 

636 ) 

637 

638 

639def _log_step_duration(step_label: str, start: float, username: str) -> None: 

640 """Log post-login step duration at INFO if > 100ms, else DEBUG.""" 

641 elapsed_ms = (time.perf_counter() - start) * 1000 

642 if elapsed_ms > 100: 

643 logger.info( 

644 f"Post-login {step_label} for {_log_value_preview(username)} took {elapsed_ms:.0f}ms" 

645 ) 

646 else: 

647 logger.opt(lazy=True).debug( 

648 "Post-login {} for {} took {:.0f}ms", 

649 lambda: step_label, 

650 lambda: _log_value_preview(username), 

651 lambda: elapsed_ms, 

652 ) 

653 

654 

655@router.post("/validate-password") 

656@limiter.limit(VALIDATE_PASSWORD_RATE_LIMIT) 

657def validate_password( 

658 request: Request, 

659 password: Annotated[str, Form()] = "", # noqa: S107 

660): 

661 """Validate password strength via API (used by client-side forms). 

662 

663 Rate-limited to prevent using this endpoint as a complexity oracle 

664 against unknown candidate passwords. 

665 """ 

666 errors = PasswordValidator.validate_strength(password) 

667 return {"valid": len(errors) == 0, "errors": errors} 

668 

669 

670@router.get("/register") 

671def register_page(request: Request): 

672 """ 

673 Registration page (GET only). 

674 Not rate limited - viewing the page should always work. 

675 """ 

676 config = load_server_config() 

677 if not config.get("allow_registrations", True): 

678 flash( 

679 request, "New user registrations are currently disabled.", "error" 

680 ) 

681 return RedirectResponse(url="/auth/login", status_code=302) 

682 

683 return render_template( 

684 request, 

685 "auth/register.html", 

686 { 

687 "has_encryption": db_manager.has_encryption, 

688 "password_requirements": PasswordValidator.get_requirements(), 

689 }, 

690 ) 

691 

692 

693@router.post("/register") 

694@limiter.limit(REGISTRATION_RATE_LIMIT) 

695def register( 

696 request: Request, 

697 username: Annotated[str, Form()] = "", 

698 password: Annotated[str, Form()] = "", # noqa: S107 

699 confirm_password: Annotated[str, Form()] = "", # noqa: S107 

700 acknowledge: Annotated[str, Form()] = "false", 

701): 

702 """Registration handler (POST only).""" 

703 config = load_server_config() 

704 if not config.get("allow_registrations", True): 

705 flash( 

706 request, "New user registrations are currently disabled.", "error" 

707 ) 

708 return RedirectResponse(url="/auth/login", status_code=302) 

709 

710 username = username.strip() 

711 acknowledge_bool = acknowledge == "true" 

712 

713 # Validation 

714 errors = [] 

715 

716 if not username: 

717 errors.append("Username is required") 

718 elif len(username) < 3: 

719 errors.append("Username must be at least 3 characters") 

720 elif not username.replace("_", "").replace("-", "").isalnum(): 

721 errors.append( 

722 "Username can only contain letters, numbers, underscores, and hyphens" 

723 ) 

724 

725 if not password: 

726 errors.append("Password is required") 

727 else: 

728 errors.extend(PasswordValidator.validate_strength(password)) 

729 

730 if password != confirm_password: 

731 errors.append("Passwords do not match") 

732 

733 if not acknowledge_bool: 

734 errors.append( 

735 "You must acknowledge that password recovery is not possible" 

736 ) 

737 

738 # Check if user already exists 

739 # Use generic error message to prevent account enumeration 

740 # Note: While this creates a minor timing difference, it's acceptable because: 

741 # 1. Rate limiting prevents automated timing analysis 

742 # 2. Generic error message prevents content-based enumeration 

743 # 3. Local database query timing is minimal (no network calls) 

744 # 4. Better UX with immediate feedback outweighs minor timing risk 

745 # See: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html 

746 if not errors and username and db_manager.user_exists(username): 

747 errors.append("Registration failed. Please try a different username.") 

748 

749 if errors: 

750 for error in errors: 

751 flash(request, error, "error") 

752 return render_template( 

753 request, 

754 "auth/register.html", 

755 { 

756 "has_encryption": db_manager.has_encryption, 

757 "password_requirements": PasswordValidator.get_requirements(), 

758 }, 

759 status_code=400, 

760 ) 

761 

762 # (#6938: the form username has a minimum length but no maximum, so 

763 # every registration log line below interpolates this bounded preview, 

764 # never the raw value.) 

765 log_username = _log_value_preview(username) 

766 

767 # Create user in auth database 

768 with auth_db_session() as auth_db: 

769 try: 

770 new_user = User(username=username) 

771 auth_db.add(new_user) 

772 auth_db.flush() 

773 # Capture the PK after flush (the INSERT has run and assigned the 

774 # id) but before commit: reading it here needs no post-commit 

775 # refresh SELECT (expire_on_commit), and if the commit below fails 

776 # nothing is persisted, so there is no orphan to clean up. Cleanup 

777 # deletes by this id rather than re-querying by username, which 

778 # could race a concurrent registration reusing the same username. 

779 new_user_id = new_user.id 

780 auth_db.commit() 

781 except IntegrityError: 

782 # Catch duplicate username specifically (race condition case) 

783 # This handles the edge case where two requests for the same username 

784 # pass the user_exists() check simultaneously 

785 logger.warning(f"Duplicate username attempted: {log_username}") 

786 auth_db.rollback() 

787 flash( 

788 request, 

789 "Registration failed. Please try a different username.", 

790 "error", 

791 ) 

792 return render_template( 

793 request, 

794 "auth/register.html", 

795 { 

796 "has_encryption": db_manager.has_encryption, 

797 "password_requirements": PasswordValidator.get_requirements(), 

798 }, 

799 status_code=400, 

800 ) 

801 except Exception: 

802 logger.exception( 

803 f"Registration failed for {log_username} while creating the " 

804 f"auth record" 

805 ) 

806 auth_db.rollback() 

807 flash(request, "Registration failed. Please try again.", "error") 

808 return render_template( 

809 request, 

810 "auth/register.html", 

811 { 

812 "has_encryption": db_manager.has_encryption, 

813 "password_requirements": PasswordValidator.get_requirements(), 

814 }, 

815 status_code=500, 

816 ) 

817 

818 # Create encrypted database for user. 

819 # 

820 # This is the only step that can leave an orphaned auth row: the User 

821 # row is already committed, but no encrypted DB exists yet, so the 

822 # username is taken (blocking re-registration via user_exists()) while 

823 # login also fails (no DB to open) — a permanently bricked account. 

824 # The cleanup scope is intentionally narrow: later failures (session 

825 # setup, library init) must NOT delete the auth row, because by then 

826 # the user DB exists and the account is legitimate. 

827 try: 

828 db_manager.create_user_database(username, password) 

829 except Exception: 

830 logger.exception( 

831 f"Registration failed for {log_username} while creating the " 

832 f"encrypted database" 

833 ) 

834 # Delete the orphaned auth row by primary key in a fresh session 

835 # (the original auth_db session has already closed). Deleting by ID 

836 # avoids racing a concurrent registration that re-uses the username. 

837 try: 

838 with auth_db_session() as cleanup_db: 

839 orphaned_user = ( 

840 cleanup_db.query(User).filter_by(id=new_user_id).first() 

841 ) 

842 if orphaned_user is not None: 842 ↛ 854line 842 didn't jump to line 854

843 cleanup_db.delete(orphaned_user) 

844 cleanup_db.commit() 

845 logger.info( 

846 f"Cleaned up orphaned auth entry for {log_username}" 

847 ) 

848 except Exception: 

849 # Surface a stuck orphan so it's diagnosable, but don't mask the 

850 # original registration failure returned to the user. 

851 logger.exception( 

852 f"Failed to clean up orphaned auth entry for {log_username}" 

853 ) 

854 flash(request, "Registration failed. Please try again.", "error") 

855 return render_template( 

856 request, 

857 "auth/register.html", 

858 { 

859 "has_encryption": db_manager.has_encryption, 

860 "password_requirements": PasswordValidator.get_requirements(), 

861 }, 

862 status_code=500, 

863 ) 

864 

865 try: 

866 # Prevent session fixation by clearing old session data 

867 request.session.clear() 

868 

869 # Auto-login after registration 

870 session_id = session_manager.create_session(username, False) 

871 request.session["session_id"] = session_id 

872 request.session["username"] = username 

873 # Registration auto-login is NOT "remember me": issue a 

874 # browser-session cookie (discarded on close), matching main's 

875 # non-permanent session. RememberMeMiddleware only strips the 

876 # 30-day Max-Age when _remember_me is explicitly False — leaving 

877 # it unset would persist the cookie for 30 days. 

878 request.session["_remember_me"] = False 

879 

880 # Store password temporarily for post-registration database access 

881 from ...database.temp_auth import temp_auth_store 

882 

883 auth_token = temp_auth_store.store_auth(username, password) 

884 request.session["temp_auth_token"] = auth_token 

885 

886 # Also store in session password store for metrics access 

887 from ...database.session_passwords import session_password_store 

888 

889 session_password_store.store_session_password( 

890 username, session_id, password 

891 ) 

892 

893 # Notify the news scheduler about the new user 

894 try: 

895 from ...scheduler.background import ( 

896 get_background_job_scheduler, 

897 ) 

898 

899 scheduler = get_background_job_scheduler() 

900 if scheduler.is_running: 

901 scheduler.update_user_info(username, password) 

902 logger.info( 

903 f"Updated scheduler with new user info for {log_username}" 

904 ) 

905 except Exception: 

906 logger.exception("Could not update scheduler on registration") 

907 

908 logger.info(f"New user registered: {log_username}") 

909 

910 # Initialize library system (source types and default collection) 

911 from ...database.library_init import initialize_library_for_user 

912 

913 try: 

914 init_results = initialize_library_for_user(username, password) 

915 if init_results.get("success"): 915 ↛ 920line 915 didn't jump to line 920 because the condition on line 915 was always true

916 logger.info( 

917 f"Library system initialized for new user {log_username}" 

918 ) 

919 else: 

920 logger.warning( 

921 f"Library initialization issue for {log_username}: {init_results.get('error', 'Unknown error')}" 

922 ) 

923 except Exception: 

924 logger.exception( 

925 f"Error initializing library for new user {log_username}" 

926 ) 

927 # Don't block registration on library init failure 

928 

929 return RedirectResponse(url="/", status_code=302) 

930 

931 except Exception: 

932 # The encrypted DB already exists at this point, so this failure is in 

933 # post-creation setup (auto-login / session). The account is created 

934 # and loginable; do NOT clean up the auth row here (narrow scope). 

935 # But DO roll back any half-created session so this 500 doesn't leave 

936 # the user silently logged in (SessionMiddleware would otherwise write 

937 # the partially-set cookie on this response — see #5006). 

938 _rollback_partial_session(request, username) 

939 logger.exception( 

940 f"Registration failed for {log_username} after database creation " 

941 f"(post-creation session/login setup)" 

942 ) 

943 flash(request, "Registration failed. Please try again.", "error") 

944 return render_template( 

945 request, 

946 "auth/register.html", 

947 { 

948 "has_encryption": db_manager.has_encryption, 

949 "password_requirements": PasswordValidator.get_requirements(), 

950 }, 

951 status_code=500, 

952 ) 

953 

954 

955@router.post("/logout") 

956def logout(request: Request): 

957 """ 

958 Logout handler. 

959 Clears session and closes database connections. 

960 POST-only to prevent CSRF-triggered logout via GET (e.g. <img src="/auth/logout">). 

961 """ 

962 username = request.session.get("username") 

963 session_id = request.session.get("session_id") 

964 

965 if username: 

966 # LOGOUT CLEANUP ORDER (order matters): 

967 # 1. Unregister from news scheduler — removes the password from the 

968 # scheduler's user_sessions dict and cancels scheduled jobs. 

969 # 2. Invalidate the server-side session AND clear every credential 

970 # store for this user — BEFORE closing the DB, so the 

971 # always-running queue processor / scheduler can't read a stale 

972 # password and reopen the DB in the close→clear window. This also 

973 # makes the socket connect gate (validate_session at handshake) 

974 # reject any NEW socket from this moment on. 

975 # 3. Disconnect this session's live WebSocket connections — sockets are 

976 # authorised once at handshake and never re-checked, so without this 

977 # a socket connected before logout keeps receiving the user's events. 

978 # Scoped to THIS session so the user's other tabs / devices stay 

979 # connected. MUST run AFTER step 2 so the socket connect gate 

980 # above already rejects new sockets before we tear down the 

981 # existing ones. 

982 # 4. Close the database connection — unless the user still has research 

983 # running (closing then would make the log-queue drain silently drop 

984 # that job's logs; the idle sweeper closes it once the job ends). 

985 # 5. Drop per-user lock-dict entries. 

986 # 6. Clear the Flask session dict. 

987 try: 

988 from ...scheduler.background import ( 

989 get_background_job_scheduler, 

990 ) 

991 

992 sched = get_background_job_scheduler() 

993 if sched.is_running: 

994 sched.unregister_user(username) 

995 except Exception: 

996 logger.warning("Could not unregister user from scheduler") 

997 

998 # Step 2: invalidate the server session and clear EVERY credential 

999 # store for this user, BEFORE closing the DB. The always-running 

1000 # queue processor / scheduler read the session password store to 

1001 # reopen a user's DB, so clearing only after the close leaves a 

1002 # window where a concurrent tick resurrects the connection — and 

1003 # even resumes a queued research — after logout. 

1004 try: 

1005 if session_id: 1005 ↛ 1016line 1005 didn't jump to line 1016 because the condition on line 1005 was always true

1006 session_manager.destroy_session(session_id) 

1007 

1008 # Clear EVERY stored password for this user, not just this 

1009 # session's entry. Re-logging in without an explicit logout 

1010 # replaces the cookie's session_id and orphans the previous 

1011 # entry; because password lookup falls back to a 

1012 # username-wide scan (get_any_session_password), a single 

1013 # orphan lets a captured pre-logout cookie re-open the 

1014 # decrypted DB after logout. Matches change-password, and is 

1015 # broader than main's per-session clear_session for that reason. 

1016 from ...database.session_passwords import ( 

1017 session_password_store, 

1018 ) 

1019 

1020 session_password_store.clear_all_for_user(username) 

1021 

1022 # Same reasoning, second store. Ordinary synchronous requests 

1023 # clear their worker-local credential through 

1024 # WorkerCleanupAPIRoute, but logout must also remove entries held 

1025 # by other workers currently acting for this user. One thread 

1026 # cannot clear another thread's ``threading.local`` directly. 

1027 from ...database.thread_local_session import ( 

1028 clear_user_credentials, 

1029 ) 

1030 

1031 clear_user_credentials(username) 

1032 except Exception: 

1033 logger.exception( 

1034 "session cleanup failed during logout — continuing" 

1035 ) 

1036 

1037 # Step 3: disconnect THIS session's live sockets (#5535). A socket 

1038 # is authorised once at handshake and never re-checked, so without 

1039 # this one connected before logout keeps receiving the user's 

1040 # events. Scoped to this session so other tabs / devices survive. 

1041 # MUST run after step 2: the connect gate already rejects new 

1042 # sockets by now, which closes the reconnect-in-teardown-window 

1043 # race — were sockets torn down first, one connecting in the gap 

1044 # would still pass the gate and survive the logout. 

1045 if session_id: 1045 ↛ 1077line 1045 didn't jump to line 1077 because the condition on line 1045 was always true

1046 _disconnect_session_sockets(session_id) 

1047 

1048 # Step 4: close the DB connection — but not while the user still has 

1049 # research running. A running job keeps producing logs, and the 

1050 # log-queue drain no longer reopens a closed DB, so closing here 

1051 # would silently drop that job's log rows. The idle-connection 

1052 # sweeper closes it once the job finishes. 

1053 # 

1054 # The active-research check and the close run under this user's 

1055 # ``user_research_start_gate`` — the SAME per-user gate 

1056 # ``check_and_start_research`` acquires to register a new research 

1057 # thread, and that ``change_password`` holds across its rekey. 

1058 # Holding it here closes two check-then-act races: 

1059 # (a) a concurrent same-user ``change_password`` rekey (another 

1060 # tab) could have its in-flight engine disposed by this close 

1061 # mid-rekey; sharing the gate serialises the two. 

1062 # (b) a research STARTING between the check and the close could 

1063 # have its freshly-opened DB closed underneath it and silently 

1064 # lose logs; holding the gate means a concurrent 

1065 # ``check_and_start_research`` either registers before our 

1066 # check (so we see it active and skip the close) or blocks 

1067 # until after it. 

1068 # Lock ordering is preserved: gate BEFORE ``_lock`` (via 

1069 # ``get_usernames_with_active_research``) and before the DB layer's 

1070 # ``_connections_lock`` (via ``close_user_database``) — the same 

1071 # order used everywhere else, so no deadlock. 

1072 # 

1073 # Guarded so a failure here (e.g. SQLCipher cleanup error) doesn't 

1074 # skip the session-clear below, which would leave the user still 

1075 # authenticated on the client even though we logged a successful 

1076 # logout above. 

1077 try: 

1078 from ..routes.globals import ( 

1079 get_usernames_with_active_research, 

1080 user_research_start_gate, 

1081 ) 

1082 

1083 with user_research_start_gate(username): 

1084 if username not in get_usernames_with_active_research(): 

1085 db_manager.close_user_database(username) 

1086 except Exception: 

1087 logger.exception( 

1088 "close_user_database failed during logout — continuing" 

1089 ) 

1090 

1091 # Drop per-user lock-dict entries (library-init, backup, 

1092 # queue-processor critical sections). Matches the cleanup 

1093 # done by the idle-connection sweeper; without this, those 

1094 # three module-level dicts accumulate one entry per username 

1095 # across the process lifetime. 

1096 from ..auth.connection_cleanup import ( 

1097 _evict_collection_engines_cache, 

1098 _pop_per_user_locks, 

1099 ) 

1100 

1101 _pop_per_user_locks(username) 

1102 

1103 # Same per-user residue as the lock dicts above, dropped on the 

1104 # same teardown paths (this one, password change, the idle 

1105 # sweeper): the user's cached collection names/descriptions 

1106 # (#6609). Best-effort: logout does not stop a running research 

1107 # (the DB-close step above defers to it), and that research can 

1108 # refill the entry afterwards. Such an entry then expires under 

1109 # the TTL like any other. 

1110 _evict_collection_engines_cache(username) 

1111 

1112 # Always wipe the cookie session so the browser is genuinely 

1113 # logged out even if earlier cleanup raised. 

1114 request.session.clear() 

1115 

1116 logger.info(f"User {_log_value_preview(username)} logged out") 

1117 flash(request, "You have been logged out successfully", "info") 

1118 

1119 if "application/json" in request.headers.get("accept", ""): 

1120 return JSONResponse({"success": True}) 

1121 return RedirectResponse(url="/auth/login", status_code=302) 

1122 

1123 

1124@router.get("/check") 

1125def check_auth(request: Request): 

1126 """ 

1127 Check if user is authenticated (for AJAX requests). 

1128 

1129 Catch the authentication failure locally so this endpoint always answers 

1130 JSON, including for callers without an Accept header. Share the actual 

1131 authentication checks with protected routes, including credential loss 

1132 while a shared database connection is still open. 

1133 """ 

1134 try: 

1135 username = require_auth(request) 

1136 except HTTPException as exc: 

1137 if exc.status_code != 401: 

1138 raise 

1139 return JSONResponse({"authenticated": False}, status_code=401) 

1140 return {"authenticated": True, "username": username} 

1141 

1142 

1143def _revoke_user_credentials_after_rekey( 

1144 request: Request, username: str 

1145) -> None: 

1146 """Revoke everything that carries or was authorised by the old password. 

1147 

1148 Shared by a completed password change and one whose outcome is 

1149 indeterminate (the new key may be active). Mirrors the logout 

1150 handler's cleanup, but for every session of the user, not just this 

1151 one. Each step runs on its own: one that fails is logged (without the 

1152 exception text, which could carry request data) and the others still 

1153 run, so a scheduler fault cannot leave other devices signed in. 

1154 """ 

1155 

1156 def unregister_scheduler() -> None: 

1157 # Removes the scheduler's stored credential. 

1158 from ...scheduler.background import get_background_job_scheduler 

1159 

1160 sched = get_background_job_scheduler() 

1161 if sched.is_running: 1161 ↛ 1162line 1161 didn't jump to line 1162 because the condition on line 1161 was never true

1162 sched.unregister_user(username) 

1163 

1164 def pop_per_user_locks() -> None: 

1165 # Drop per-user lock-dict entries (matches logout path). 

1166 from ..auth.connection_cleanup import _pop_per_user_locks 

1167 

1168 _pop_per_user_locks(username) 

1169 

1170 def evict_collection_engines() -> None: 

1171 # Drop the cached collection search engines (matches logout path; 

1172 # every session ends below). 

1173 from ..auth.connection_cleanup import _evict_collection_engines_cache 

1174 

1175 _evict_collection_engines_cache(username) 

1176 

1177 def destroy_sessions() -> None: 

1178 session_manager.destroy_all_user_sessions(username) 

1179 

1180 def clear_session_passwords() -> None: 

1181 from ...database.session_passwords import session_password_store 

1182 

1183 session_password_store.clear_all_for_user(username) 

1184 

1185 def clear_thread_credentials() -> None: 

1186 # Per-thread cached credentials hold the OLD plaintext password, 

1187 # which is (or may be) invalid now: a pooled worker that kept it 

1188 # would both retain a dead secret and fail to reopen the database. 

1189 # See clear_user_credentials() for why per-request cleanup cannot 

1190 # reach a worker thread under FastAPI. 

1191 from ...database.thread_local_session import clear_user_credentials 

1192 

1193 clear_user_credentials(username) 

1194 

1195 def disconnect_sockets() -> None: 

1196 # A socket authorised under an old session must stop receiving this 

1197 # user's events (#5535). Every session is destroyed, so this is the 

1198 # all-sessions scope, unlike logout's per-session one. 

1199 _disconnect_user_sockets(username) 

1200 

1201 def clear_request_session() -> None: 

1202 request.session.clear() 

1203 

1204 for step in ( 

1205 unregister_scheduler, 

1206 pop_per_user_locks, 

1207 evict_collection_engines, 

1208 destroy_sessions, 

1209 clear_session_passwords, 

1210 clear_thread_credentials, 

1211 disconnect_sockets, 

1212 clear_request_session, 

1213 ): 

1214 try: 

1215 step() 

1216 except Exception as error: 

1217 logger.warning( 

1218 f"Credential revocation step {step.__name__} failed for " 

1219 f"{_log_value_preview(username)}: {type(error).__name__}" 

1220 ) 

1221 

1222 

1223@router.get("/change-password") 

1224def change_password_page( 

1225 request: Request, username: Annotated[str, Depends(require_auth)] 

1226): 

1227 """ 

1228 Change password page (GET only). 

1229 Not rate limited - viewing the page should always work. 

1230 

1231 Auth via Depends(require_auth) — same gate as the POST handler below 

1232 (change_password), including the is_user_connected() staleness check. 

1233 The manual `session.get("username")` check this replaces only verified 

1234 a cookie existed, not that the session still had a live DB connection. 

1235 """ 

1236 return render_template( 

1237 request, 

1238 "auth/change_password.html", 

1239 { 

1240 "password_requirements": PasswordValidator.get_requirements(), 

1241 }, 

1242 ) 

1243 

1244 

1245@router.post("/change-password") 

1246@limiter.limit(PASSWORD_CHANGE_RATE_LIMIT) 

1247def change_password( 

1248 request: Request, 

1249 username: Annotated[str, Depends(require_auth)], 

1250 current_password: Annotated[str, Form()] = "", # noqa: S107 

1251 new_password: Annotated[str, Form()] = "", # noqa: S107 

1252 confirm_password: Annotated[str, Form()] = "", # noqa: S107 

1253): 

1254 """Change password handler (POST only). 

1255 

1256 Auth via Depends(require_auth) — same gate as the rest of the app, 

1257 including any future SessionManager.validate_session() hook. Reading 

1258 request.session["username"] directly bypassed that gate and split 

1259 auth-policy enforcement across two code paths. 

1260 """ 

1261 

1262 # Validation 

1263 errors = [] 

1264 

1265 # ``strip()``: a whitespace-only current password is not empty, so it 

1266 # passed this check and reached ``change_password``'s own key validation, 

1267 # which raises ValueError -- a 500 where the form should simply say the 

1268 # field is required (#6375). The new password needs no equivalent: every 

1269 # whitespace-only value fails validate_strength below. 

1270 if not current_password.strip(): 

1271 errors.append("Current password is required") 

1272 

1273 if not new_password: 1273 ↛ 1274line 1273 didn't jump to line 1274 because the condition on line 1273 was never true

1274 errors.append("New password is required") 

1275 else: 

1276 errors.extend(PasswordValidator.validate_strength(new_password)) 

1277 

1278 if new_password != confirm_password: 

1279 errors.append("New passwords do not match") 

1280 

1281 if current_password == new_password: 

1282 errors.append("New password must be different from current password") 

1283 

1284 if errors: 

1285 for error in errors: 

1286 flash(request, error, "error") 

1287 return render_template( 

1288 request, 

1289 "auth/change_password.html", 

1290 { 

1291 "password_requirements": PasswordValidator.get_requirements(), 

1292 }, 

1293 status_code=400, 

1294 ) 

1295 

1296 # Do NOT rekey the encrypted DB while a research job is running. The rekey 

1297 # (close -> PRAGMA rekey -> close) needs exclusive access, and a running 

1298 # job holds a session bound to the pre-rekey engine with the now-invalid 

1299 # old password — it can't recover, and a concurrent rekey risks corrupting 

1300 # the whole encrypted DB. Unlike logout's DB close, the destructive rekey 

1301 # can't be deferred, so reject until research finishes. 

1302 # 

1303 # The check and the rekey run under this user's ``user_research_start_gate`` 

1304 # — the SAME per-user gate ``check_and_start_research`` (routes/globals.py) 

1305 # acquires to register and start a new research thread for this user. 

1306 # Without holding it, a job could start in the gap between the check below 

1307 # and the rekey actually happening, undetected by the check but still 

1308 # racing the rekey. Holding the gate across both closes that TOCTOU window: 

1309 # no research can start FOR THIS USER while we hold it. 

1310 # 

1311 # This is a PER-USER gate, NOT the process-global ``_lock`` (routes/globals.py): 

1312 # the SQLCipher ``PRAGMA rekey`` can take seconds, and that global lock is 

1313 # taken on every research progress/log update, so holding it across the 

1314 # rekey would freeze EVERY user's active research for the duration. The 

1315 # per-user gate blocks only this user's new research. 

1316 # 

1317 # RESIDUAL (concurrent same-user writers): the gate serialises the rekey 

1318 # against this user's NEW research starts, and the active-research check 

1319 # below rejects the rekey outright while this user has a research WORKER 

1320 # running (so that worker's metrics/log writers are excluded too). It does 

1321 # NOT serialise same-user background writers that open/write the user's 

1322 # engine WITHOUT going through check_and_start_research — the news-scheduler 

1323 # job (get_user_db_session) and the async log-drain 

1324 # (_write_log_to_database). Those can still INSERT concurrently with the 

1325 # PRAGMA rekey. Fully serialising them would require a per-user read/write 

1326 # lock taken on the metrics/log hot path inside the database layer 

1327 # (encrypted_db.py) — a cross-layer lock the web-layer gate can't provide 

1328 # without a fragile import cycle, and a change with real deadlock/perf risk. 

1329 # Given the narrow window (a manual, rare, single-user action) this is left 

1330 # as a documented residual rather than hardened here. 

1331 from ..routes.globals import ( 

1332 get_usernames_with_active_research, 

1333 user_research_start_gate, 

1334 ) 

1335 

1336 with user_research_start_gate(username): 

1337 if username in get_usernames_with_active_research(): 

1338 # Flask signatures here (flash(msg, cat), render_template(name, 

1339 # **ctx), and a `, 409` tuple return) came in with main's #5538 

1340 # hunk. All three raise under FastAPI, so this rejection branch 

1341 # 500'd instead of returning 409 -- the gate still serialised 

1342 # correctly around the crash, but the user got no usable answer. 

1343 flash( 

1344 request, 

1345 "Cannot change your password while research is in progress. " 

1346 "Wait for it to finish or cancel it, then try again.", 

1347 "error", 

1348 ) 

1349 return render_template( 

1350 request, 

1351 "auth/change_password.html", 

1352 { 

1353 "password_requirements": PasswordValidator.get_requirements(), 

1354 }, 

1355 status_code=409, 

1356 ) 

1357 

1358 # Attempt password change (the rekey) — holds only this user's gate. 

1359 outcome_indeterminate = False 

1360 try: 

1361 success = db_manager.change_password( 

1362 username, current_password, new_password 

1363 ) 

1364 except ValueError: 

1365 # change_password validates both keys before it rekeys and 

1366 # raises on an invalid one. The form checks above should have 

1367 # caught anything a browser can send, so this is the backstop 

1368 # that keeps a missed shape a 400 rather than a 500 with a 

1369 # traceback. Nothing has been rekeyed on this path. 

1370 # 

1371 # The exception is not logged: its message is built from the 

1372 # password fields this handler holds in plaintext, and the 

1373 # repository's logging guard rejects an exception variable here 

1374 # for exactly that reason. The fact and the user are what an 

1375 # operator needs; the reason is one of the two guards in 

1376 # change_password. 

1377 logger.warning( 

1378 f"Password change rejected for {_log_value_preview(username)}: the rekey path " 

1379 "refused one of the submitted password fields" 

1380 ) 

1381 flash( 

1382 request, 

1383 "Password change rejected: check the current and new password " 

1384 "fields and try again.", 

1385 "error", 

1386 ) 

1387 return render_template( 

1388 request, 

1389 "auth/change_password.html", 

1390 { 

1391 "password_requirements": PasswordValidator.get_requirements(), 

1392 }, 

1393 status_code=400, 

1394 ) 

1395 except RekeyLockContentionError: 

1396 # Another password change (or a gate holder) owns this user's 

1397 # rekey lifecycle right now. Nothing was rekeyed here. 

1398 logger.warning( 

1399 f"Password change contended for {_log_value_preview(username)}: " 

1400 "another rekey lifecycle owns the lock" 

1401 ) 

1402 flash( 

1403 request, 

1404 "A password change is already in progress for this account. " 

1405 "Please wait for it to finish and try again.", 

1406 "error", 

1407 ) 

1408 return render_template( 

1409 request, 

1410 "auth/change_password.html", 

1411 { 

1412 "password_requirements": PasswordValidator.get_requirements(), 

1413 }, 

1414 status_code=409, 

1415 ) 

1416 except RekeyIndeterminateError: 

1417 # The rekey may have taken effect: the database layer could not 

1418 # prove which key is active. Treat every old-password credential 

1419 # as revoked exactly as on success (all sessions on every device, 

1420 # both password stores, the scheduler's copy, live sockets and 

1421 # this request's session), because the old password may no 

1422 # longer be the key. The next login resolves the pending state 

1423 # with whichever password actually opens the database, and that 

1424 # recovery purges backups left under the superseded key 

1425 # (DatabaseManager._purge_superseded_key_backups). No backup is 

1426 # purged or refreshed here: neither key is proven. 

1427 # Handled below, after this user's research-start gate is 

1428 # released, like the success path. 

1429 outcome_indeterminate = True 

1430 success = False 

1431 except RekeyOperationalError: 

1432 # Unavailable (pre-ambiguity): the old key provably stayed 

1433 # active and nothing was rekeyed, or another sidecar fault 

1434 # stopped the change before it started. The user retries. 

1435 logger.warning( 

1436 f"Password change failed for {_log_value_preview(username)}: rekey outcome " 

1437 "unavailable" 

1438 ) 

1439 flash( 

1440 request, 

1441 "Password change could not be completed safely. The database " 

1442 "was left fenced; please try again shortly or contact the " 

1443 "administrator if this repeats.", 

1444 "error", 

1445 ) 

1446 return render_template( 

1447 request, 

1448 "auth/change_password.html", 

1449 { 

1450 "password_requirements": PasswordValidator.get_requirements(), 

1451 }, 

1452 status_code=503, 

1453 ) 

1454 

1455 if outcome_indeterminate: 

1456 logger.warning( 

1457 f"Password change outcome indeterminate for " 

1458 f"{_log_value_preview(username)}: revoking all sessions; the " 

1459 "next login resolves the pending rekey" 

1460 ) 

1461 _revoke_user_credentials_after_rekey(request, username) 

1462 flash( 

1463 request, 

1464 "Your password change may have completed. Try your NEW " 

1465 "password first; if it does not work, use your old one.", 

1466 "warning", 

1467 ) 

1468 return RedirectResponse(url="/auth/login", status_code=302) 

1469 

1470 if success: 

1471 # The rekey is the ONLY step needed. The auth database stores no 

1472 # password hash — login works by attempting to decrypt the user's 

1473 # SQLCipher database. Do NOT add an auth-DB password-hash update 

1474 # here; it would fail (User model has no set_password method) and 

1475 # is architecturally unnecessary. 

1476 

1477 # Clean up stale credentials before clearing session 

1478 # (mirrors logout handler cleanup steps 1–5). 

1479 

1480 # 1. No database close here. change_password() already detached and 

1481 # disposed the old-password engine BEFORE the rekey, under the 

1482 # user's rekey sidecar lock that every cold open also takes, so no 

1483 # old-key engine can be cached once it returns True. The only 

1484 # engine that can be cached by now is one a concurrent 

1485 # open_user_database(new_password) published after the rekey 

1486 # committed. That engine is valid, and closing it here would only 

1487 # force the next request to cold-open again. 

1488 

1489 # 2. Purge old backups (encrypted with old key) and create 

1490 # a fresh backup with the new key. Old-key backups are a 

1491 # security risk per NIST SP 800-57 / OWASP A02 — they remain 

1492 # decryptable with the compromised password. 

1493 try: 

1494 from ...database.backup.backup_service import BackupService 

1495 

1496 svc = BackupService(username=username, password=new_password) 

1497 result = svc.purge_and_refresh() 

1498 if result.success: 

1499 logger.info( 

1500 f"Backups refreshed after password change for {_log_value_preview(username)}" 

1501 ) 

1502 else: 

1503 logger.error( 

1504 f"Post-password-change backup failed for {_log_value_preview(username)}: " 

1505 f"{result.error}. Old backups were purged." 

1506 ) 

1507 except Exception: 

1508 logger.exception( 

1509 f"Could not refresh backups after password change " 

1510 f"for {_log_value_preview(username)}" 

1511 ) 

1512 

1513 # 3. Revoke every old-password credential and session. 

1514 _revoke_user_credentials_after_rekey(request, username) 

1515 

1516 logger.info(f"Password changed for user {_log_value_preview(username)}") 

1517 flash( 

1518 request, 

1519 "Password changed successfully. Please login with your new password.", 

1520 "success", 

1521 ) 

1522 return RedirectResponse(url="/auth/login", status_code=302) 

1523 flash(request, "Current password is incorrect", "error") 

1524 return render_template( 

1525 request, 

1526 "auth/change_password.html", 

1527 { 

1528 "password_requirements": PasswordValidator.get_requirements(), 

1529 }, 

1530 status_code=401, 

1531 ) 

1532 

1533 

1534@router.get("/integrity-check") 

1535@limiter.limit(INTEGRITY_CHECK_RATE_LIMIT) 

1536def integrity_check( 

1537 request: Request, username: Annotated[str, Depends(require_auth)] 

1538): 

1539 """ 

1540 Check database integrity for current user. 

1541 

1542 Uses Depends(require_auth) for the auth gate — single source of 

1543 truth, consistent with every other authed endpoint. Rate-limited 

1544 (unlike a plain lookup, this runs SQLite's full `integrity_check` 

1545 scan, not the cheap `quick_check`) so a signed-in user can't 

1546 re-trigger the expensive path back-to-back. 

1547 """ 

1548 is_valid = db_manager.check_database_integrity(username) 

1549 

1550 return { 

1551 "username": username, 

1552 "integrity": "valid" if is_valid else "corrupted", 

1553 "timestamp": datetime.now(timezone.utc).isoformat(), 

1554 }