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

417 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-06 15:42 +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, Query, Request 

7from fastapi.responses import JSONResponse, RedirectResponse 

8 

9from ..dependencies.auth import clear_session_if_unrecoverable, require_auth 

10from ..dependencies.flash import flash 

11from ..dependencies.rate_limit import ( 

12 LOGIN_RATE_LIMIT, 

13 PASSWORD_CHANGE_RATE_LIMIT, 

14 REGISTRATION_RATE_LIMIT, 

15 VALIDATE_PASSWORD_RATE_LIMIT, 

16 limiter, 

17) 

18from ..dependencies.template_helpers import render_template 

19 

20import contextvars 

21import threading 

22import time 

23from datetime import datetime, timezone, UTC 

24 

25from loguru import logger 

26 

27from ...database.auth_db import auth_db_session 

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

29from ...database.models.auth import User 

30from ...database.thread_local_session import thread_cleanup 

31from sqlalchemy.exc import IntegrityError 

32from ..auth.session_manager import ( 

33 session_manager, 

34) # singleton from session_manager module 

35from ..server_config import load_server_config 

36 

37from urllib.parse import urlparse 

38 

39from ...security.url_validator import URLValidator 

40from ...security.account_lockout import get_account_lockout_manager 

41from ...security.password_validator import PasswordValidator 

42from ...security.log_sanitizer import sanitize_for_log 

43from typing import Annotated 

44 

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

46 

47 

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

49def get_csrf_token(request: Request): 

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

51 from ..dependencies.csrf import generate_csrf_token 

52 

53 token = generate_csrf_token(request) 

54 return {"csrf_token": token} 

55 

56 

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

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

59 

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

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

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

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

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

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

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

67 

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

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

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

71 """ 

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

73 request.session.clear() 

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

75 try: 

76 session_manager.destroy_session(session_id) 

77 from ...database.session_passwords import session_password_store 

78 

79 session_password_store.clear_session(username, session_id) 

80 except Exception: 

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

82 

83 

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

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

86 

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

88 delivering that user's events (including ``settings_changed``, which 

89 carries plaintext secrets) for its whole lifetime. Used by the 

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

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

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

93 

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

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

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

97 """ 

98 try: 

99 from ..services.socketio_asgi import disconnect_user 

100 

101 disconnect_user(username) 

102 except Exception: 

103 logger.exception(f"Failed to disconnect sockets for {username}") 

104 

105 

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

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

108 

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

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

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

112 """ 

113 try: 

114 from ..services.socketio_asgi import disconnect_session 

115 

116 disconnect_session(session_id) 

117 except Exception: 

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

119 

120 

121@router.get("/login") 

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

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

124 config = load_server_config() 

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

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

127 

128 return render_template( 

129 request, 

130 "auth/login.html", 

131 { 

132 "has_encryption": db_manager.has_encryption, 

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

134 "next_page": next, 

135 }, 

136 ) 

137 

138 

139@router.post("/login") 

140@limiter.limit(LOGIN_RATE_LIMIT) 

141def login( 

142 request: Request, 

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

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

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

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

147): 

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

149 config = load_server_config() 

150 username = username.strip() 

151 remember_bool = remember == "true" 

152 

153 if not username or not password: 

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

155 return render_template( 

156 request, 

157 "auth/login.html", 

158 { 

159 "has_encryption": db_manager.has_encryption, 

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

161 }, 

162 status_code=400, 

163 ) 

164 

165 # Check account lockout 

166 lockout_mgr = get_account_lockout_manager() 

167 if lockout_mgr.is_locked(username): 

168 logger.warning( 

169 f"Login attempt for locked account: {sanitize_for_log(username)}" 

170 ) 

171 flash( 

172 request, 

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

174 "error", 

175 ) 

176 return render_template( 

177 request, 

178 "auth/login.html", 

179 { 

180 "has_encryption": db_manager.has_encryption, 

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

182 }, 

183 status_code=429, 

184 ) 

185 

186 # Try to open user's encrypted database. Two distinct failure modes: 

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

188 # - raise DatabaseInitializationError → credentials valid but schema 

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

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

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

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

193 try: 

194 engine = db_manager.open_user_database(username, password) 

195 except DatabaseInitializationError: 

196 logger.warning( 

197 f"Login refused for {sanitize_for_log(username)}: " 

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

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

200 ) 

201 flash( 

202 request, 

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

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

205 "error", 

206 ) 

207 return render_template( 

208 request, 

209 "auth/login.html", 

210 { 

211 "has_encryption": db_manager.has_encryption, 

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

213 }, 

214 status_code=503, 

215 ) 

216 

217 if engine is None: 

218 lockout_mgr.record_failure(username) 

219 logger.warning( 

220 f"Failed login attempt for username: {sanitize_for_log(username)}" 

221 ) 

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

223 return render_template( 

224 request, 

225 "auth/login.html", 

226 { 

227 "has_encryption": db_manager.has_encryption, 

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

229 }, 

230 status_code=401, 

231 ) 

232 

233 # Success 

234 lockout_mgr.record_success(username) 

235 

236 # Prevent session fixation 

237 request.session.clear() 

238 

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

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

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

242 try: 

243 # Create session 

244 session_id = session_manager.create_session(username, remember_bool) 

245 request.session["session_id"] = session_id 

246 request.session["username"] = username 

247 # RememberMeMiddleware reads this to decide whether to strip 

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

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

250 request.session["_remember_me"] = remember_bool 

251 

252 # Store password temporarily for post-login database access 

253 from ...database.temp_auth import temp_auth_store 

254 

255 auth_token = temp_auth_store.store_auth(username, password) 

256 request.session["temp_auth_token"] = auth_token 

257 

258 # Also store in session password store for metrics access 

259 from ...database.session_passwords import session_password_store 

260 

261 session_password_store.store_session_password( 

262 username, session_id, password 

263 ) 

264 except Exception: 

265 _rollback_partial_session(request, username) 

266 raise 

267 

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

269 

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

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

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

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

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

275 _login_ctx = contextvars.copy_context() 

276 

277 def _ctx_post_login(): 

278 _login_ctx.run( 

279 _perform_post_login_tasks, username, password, session_id 

280 ) 

281 

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

283 thread.start() 

284 

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

286 if safe_path: 

287 safe_path = safe_path.replace("\\", "/") 

288 parsed = urlparse(safe_path) 

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

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

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

292 

293 

294@thread_cleanup 

295def _perform_post_login_tasks( 

296 username: str, password: str, session_id: str 

297) -> None: 

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

299 

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

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

302 idempotent and safe to retry on the next login. 

303 

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

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

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

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

308 """ 

309 try: 

310 _perform_post_login_tasks_body(username, password, session_id) 

311 except Exception: 

312 logger.exception( 

313 f"Post-login background thread crashed for user {username}" 

314 ) 

315 

316 

317def _perform_post_login_tasks_body( 

318 username: str, password: str, session_id: str 

319) -> None: 

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

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

322 miss. See _perform_post_login_tasks for rationale.""" 

323 total_start = time.perf_counter() 

324 

325 # 1. Settings version check + migration 

326 # 

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

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

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

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

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

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

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

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

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

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

337 # `load_from_defaults_file`/`update_db_version` to commit internally 

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

339 step_start = time.perf_counter() 

340 try: 

341 from ...settings.manager import SettingsManager 

342 from ...database.session_context import get_user_db_session 

343 

344 with get_user_db_session(username, password) as db_session: 

345 settings_manager = SettingsManager(db_session) 

346 if not settings_manager.db_version_matches_package(): 

347 logger.info( 

348 f"Database version mismatch for {username} " 

349 "- loading missing default settings" 

350 ) 

351 # override_locked: this only adds keys the upgrade 

352 # introduced, so a locked account still needs it. 

353 settings_manager.load_from_defaults_file( 

354 commit=False, overwrite=False, override_locked=True 

355 ) 

356 settings_manager.update_db_version(commit=False) 

357 db_session.commit() 

358 logger.info( 

359 f"Missing default settings loaded and version " 

360 f"updated for user {username}" 

361 ) 

362 except Exception: 

363 logger.exception(f"Post-login settings migration failed for {username}") 

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

365 

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

367 step_start = time.perf_counter() 

368 try: 

369 from ...database.library_init import initialize_library_for_user 

370 

371 init_results = initialize_library_for_user(username, password) 

372 if init_results.get("success"): 

373 logger.info(f"Library system initialized for user {username}") 

374 else: 

375 logger.warning( 

376 f"Library initialization issue for {username}: " 

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

378 ) 

379 except Exception: 

380 logger.exception(f"Post-login library init failed for {username}") 

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

382 

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

384 step_start = time.perf_counter() 

385 try: 

386 with auth_db_session() as auth_db: 

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

388 if user: 

389 user.last_login = datetime.now(UTC) 

390 

391 try: 

392 from ...scheduler.background import ( 

393 get_background_job_scheduler, 

394 ) 

395 

396 scheduler = get_background_job_scheduler() 

397 if scheduler.is_running: 

398 scheduler.update_user_info(username, password) 

399 logger.info( 

400 f"Updated scheduler with user info for {username}" 

401 ) 

402 except Exception: 

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

404 

405 auth_db.commit() 

406 except Exception: 

407 logger.exception(f"Post-login auth DB update failed for {username}") 

408 _log_step_duration( 

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

410 ) 

411 

412 # Model cache refresh is handled by /api/settings/available-models 

413 # via its 24h TTL and explicit force_refresh=true flag. 

414 

415 # 4. Schedule background database backup if enabled 

416 step_start = time.perf_counter() 

417 try: 

418 from ...database.backup import get_backup_executor 

419 from ...settings.manager import SettingsManager 

420 from ...database.session_context import get_user_db_session 

421 

422 with get_user_db_session(username, password) as db_session: 

423 sm = SettingsManager(db_session) 

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

425 

426 if backup_enabled: 426 ↛ 436line 426 didn't jump to line 436

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

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

429 

430 get_backup_executor().submit_backup( 

431 username, password, max_backups, max_age_days 

432 ) 

433 logger.info(f"Background backup scheduled for user {username}") 

434 except Exception: 

435 logger.exception(f"Post-login backup scheduling failed for {username}") 

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

437 

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

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

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

441 step_start = time.perf_counter() 

442 try: 

443 from ...database.session_context import get_user_db_session 

444 from ..queue.processor_v2 import queue_processor 

445 

446 with get_user_db_session(username, password) as db_session: 

447 queue_processor.reconcile_orphan_active_research( 

448 username, db_session 

449 ) 

450 except Exception: 

451 logger.exception( 

452 f"Post-login research reconciliation failed for {username}" 

453 ) 

454 _log_step_duration( 

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

456 ) 

457 

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

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

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

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

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

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

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

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

466 step_start = time.perf_counter() 

467 try: 

468 from ..queue.processor_v2 import queue_processor 

469 

470 queue_processor.notify_user_activity(username, session_id) 

471 except Exception: 

472 logger.exception( 

473 f"Post-login queue-resume registration failed for {username}" 

474 ) 

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

476 

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

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

479 logger.info( 

480 f"Post-login tasks completed for user {username} " 

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

482 ) 

483 else: 

484 logger.info( 

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

486 ) 

487 

488 

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

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

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

492 if elapsed_ms > 100: 

493 logger.info( 

494 f"Post-login {step_label} for {username} took {elapsed_ms:.0f}ms" 

495 ) 

496 else: 

497 logger.debug( 

498 f"Post-login {step_label} for {username} took {elapsed_ms:.0f}ms" 

499 ) 

500 

501 

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

503@limiter.limit(VALIDATE_PASSWORD_RATE_LIMIT) 

504def validate_password( 

505 request: Request, 

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

507): 

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

509 

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

511 against unknown candidate passwords. 

512 """ 

513 errors = PasswordValidator.validate_strength(password) 

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

515 

516 

517@router.get("/register") 

518def register_page(request: Request): 

519 """ 

520 Registration page (GET only). 

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

522 """ 

523 config = load_server_config() 

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

525 flash( 

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

527 ) 

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

529 

530 return render_template( 

531 request, 

532 "auth/register.html", 

533 { 

534 "has_encryption": db_manager.has_encryption, 

535 "password_requirements": PasswordValidator.get_requirements(), 

536 }, 

537 ) 

538 

539 

540@router.post("/register") 

541@limiter.limit(REGISTRATION_RATE_LIMIT) 

542def register( 

543 request: Request, 

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

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

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

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

548): 

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

550 config = load_server_config() 

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

552 flash( 

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

554 ) 

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

556 

557 username = username.strip() 

558 acknowledge_bool = acknowledge == "true" 

559 

560 # Validation 

561 errors = [] 

562 

563 if not username: 

564 errors.append("Username is required") 

565 elif len(username) < 3: 

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

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

568 errors.append( 

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

570 ) 

571 

572 if not password: 

573 errors.append("Password is required") 

574 else: 

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

576 

577 if password != confirm_password: 

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

579 

580 if not acknowledge_bool: 

581 errors.append( 

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

583 ) 

584 

585 # Check if user already exists 

586 # Use generic error message to prevent account enumeration 

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

588 # 1. Rate limiting prevents automated timing analysis 

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

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

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

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

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

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

595 

596 if errors: 

597 for error in errors: 

598 flash(request, error, "error") 

599 return render_template( 

600 request, 

601 "auth/register.html", 

602 { 

603 "has_encryption": db_manager.has_encryption, 

604 "password_requirements": PasswordValidator.get_requirements(), 

605 }, 

606 status_code=400, 

607 ) 

608 

609 # Create user in auth database 

610 with auth_db_session() as auth_db: 

611 try: 

612 new_user = User(username=username) 

613 auth_db.add(new_user) 

614 auth_db.flush() 

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

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

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

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

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

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

621 new_user_id = new_user.id 

622 auth_db.commit() 

623 except IntegrityError: 

624 # Catch duplicate username specifically (race condition case) 

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

626 # pass the user_exists() check simultaneously 

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

628 auth_db.rollback() 

629 flash( 

630 request, 

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

632 "error", 

633 ) 

634 return render_template( 

635 request, 

636 "auth/register.html", 

637 { 

638 "has_encryption": db_manager.has_encryption, 

639 "password_requirements": PasswordValidator.get_requirements(), 

640 }, 

641 status_code=400, 

642 ) 

643 except Exception: 

644 logger.exception( 

645 f"Registration failed for {username} while creating the " 

646 f"auth record" 

647 ) 

648 auth_db.rollback() 

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

650 return render_template( 

651 request, 

652 "auth/register.html", 

653 { 

654 "has_encryption": db_manager.has_encryption, 

655 "password_requirements": PasswordValidator.get_requirements(), 

656 }, 

657 status_code=500, 

658 ) 

659 

660 # Create encrypted database for user. 

661 # 

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

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

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

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

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

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

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

669 try: 

670 db_manager.create_user_database(username, password) 

671 except Exception: 

672 logger.exception( 

673 f"Registration failed for {username} while creating the " 

674 f"encrypted database" 

675 ) 

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

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

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

679 try: 

680 with auth_db_session() as cleanup_db: 

681 orphaned_user = ( 

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

683 ) 

684 if orphaned_user is not None: 684 ↛ 696line 684 didn't jump to line 696

685 cleanup_db.delete(orphaned_user) 

686 cleanup_db.commit() 

687 logger.info( 

688 f"Cleaned up orphaned auth entry for {username}" 

689 ) 

690 except Exception: 

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

692 # original registration failure returned to the user. 

693 logger.exception( 

694 f"Failed to clean up orphaned auth entry for {username}" 

695 ) 

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

697 return render_template( 

698 request, 

699 "auth/register.html", 

700 { 

701 "has_encryption": db_manager.has_encryption, 

702 "password_requirements": PasswordValidator.get_requirements(), 

703 }, 

704 status_code=500, 

705 ) 

706 

707 try: 

708 # Prevent session fixation by clearing old session data 

709 request.session.clear() 

710 

711 # Auto-login after registration 

712 session_id = session_manager.create_session(username, False) 

713 request.session["session_id"] = session_id 

714 request.session["username"] = username 

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

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

717 # non-permanent session. RememberMeMiddleware only strips the 

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

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

720 request.session["_remember_me"] = False 

721 

722 # Store password temporarily for post-registration database access 

723 from ...database.temp_auth import temp_auth_store 

724 

725 auth_token = temp_auth_store.store_auth(username, password) 

726 request.session["temp_auth_token"] = auth_token 

727 

728 # Also store in session password store for metrics access 

729 from ...database.session_passwords import session_password_store 

730 

731 session_password_store.store_session_password( 

732 username, session_id, password 

733 ) 

734 

735 # Notify the news scheduler about the new user 

736 try: 

737 from ...scheduler.background import ( 

738 get_background_job_scheduler, 

739 ) 

740 

741 scheduler = get_background_job_scheduler() 

742 if scheduler.is_running: 

743 scheduler.update_user_info(username, password) 

744 logger.info( 

745 f"Updated scheduler with new user info for {username}" 

746 ) 

747 except Exception: 

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

749 

750 logger.info(f"New user registered: {username}") 

751 

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

753 from ...database.library_init import initialize_library_for_user 

754 

755 try: 

756 init_results = initialize_library_for_user(username, password) 

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

758 logger.info( 

759 f"Library system initialized for new user {username}" 

760 ) 

761 else: 

762 logger.warning( 

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

764 ) 

765 except Exception: 

766 logger.exception( 

767 f"Error initializing library for new user {username}" 

768 ) 

769 # Don't block registration on library init failure 

770 

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

772 

773 except Exception: 

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

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

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

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

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

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

780 _rollback_partial_session(request, username) 

781 logger.exception( 

782 f"Registration failed for {username} after database creation " 

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

784 ) 

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

786 return render_template( 

787 request, 

788 "auth/register.html", 

789 { 

790 "has_encryption": db_manager.has_encryption, 

791 "password_requirements": PasswordValidator.get_requirements(), 

792 }, 

793 status_code=500, 

794 ) 

795 

796 

797@router.post("/logout") 

798def logout(request: Request): 

799 """ 

800 Logout handler. 

801 Clears session and closes database connections. 

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

803 """ 

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

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

806 

807 if username: 

808 # LOGOUT CLEANUP ORDER (order matters): 

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

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

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

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

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

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

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

816 # reject any NEW socket from this moment on. 

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

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

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

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

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

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

823 # existing ones. 

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

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

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

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

828 # 6. Clear the Flask session dict. 

829 try: 

830 from ...scheduler.background import ( 

831 get_background_job_scheduler, 

832 ) 

833 

834 sched = get_background_job_scheduler() 

835 if sched.is_running: 

836 sched.unregister_user(username) 

837 except Exception: 

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

839 

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

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

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

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

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

845 # even resumes a queued research — after logout. 

846 try: 

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

848 session_manager.destroy_session(session_id) 

849 

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

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

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

853 # entry; because password lookup falls back to a 

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

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

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

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

858 from ...database.session_passwords import ( 

859 session_password_store, 

860 ) 

861 

862 session_password_store.clear_all_for_user(username) 

863 

864 # Same reasoning, second store. Ordinary synchronous requests 

865 # clear their worker-local credential through 

866 # WorkerCleanupAPIRoute, but logout must also remove entries held 

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

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

869 from ...database.thread_local_session import ( 

870 clear_user_credentials, 

871 ) 

872 

873 clear_user_credentials(username) 

874 except Exception: 

875 logger.exception( 

876 "session cleanup failed during logout — continuing" 

877 ) 

878 

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

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

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

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

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

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

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

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

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

888 _disconnect_session_sockets(session_id) 

889 

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

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

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

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

894 # sweeper closes it once the job finishes. 

895 # 

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

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

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

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

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

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

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

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

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

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

906 # lose logs; holding the gate means a concurrent 

907 # ``check_and_start_research`` either registers before our 

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

909 # until after it. 

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

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

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

913 # order used everywhere else, so no deadlock. 

914 # 

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

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

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

918 # logout above. 

919 try: 

920 from ..routes.globals import ( 

921 get_usernames_with_active_research, 

922 user_research_start_gate, 

923 ) 

924 

925 with user_research_start_gate(username): 

926 if username not in get_usernames_with_active_research(): 

927 db_manager.close_user_database(username) 

928 except Exception: 

929 logger.exception( 

930 "close_user_database failed during logout — continuing" 

931 ) 

932 

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

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

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

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

937 # across the process lifetime. 

938 from ..auth.connection_cleanup import _pop_per_user_locks 

939 

940 _pop_per_user_locks(username) 

941 

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

943 # logged out even if earlier cleanup raised. 

944 request.session.clear() 

945 

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

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

948 

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

950 

951 

952@router.get("/check") 

953def check_auth(request: Request): 

954 """ 

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

956 

957 Deliberately NOT ``Depends(require_auth)``: this endpoint is polled by 

958 AJAX/XHR clients that need a machine-readable answer unconditionally. 

959 ``require_auth``'s 401 is converted to an HTML redirect for non-``/api/`` 

960 requests without an ``Accept: application/json`` header by the global 

961 exception handler (``_is_api_request`` / ``handle_http_exception`` in 

962 ``fastapi_app.py``), and ``/auth/check`` doesn't live under ``/api/`` and 

963 can't rely on every caller sending that header. So the connectivity 

964 check that ``require_auth`` does is inlined here instead, to keep the 

965 raw-JSON response contract for every caller. 

966 """ 

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

968 if username and not db_manager.is_user_connected(username): 

969 # Stale session: same "no recoverable credential" cleanup 

970 # require_auth performs, but report it as unauthenticated rather 

971 # than raising (see docstring above for why this can't delegate). 

972 clear_session_if_unrecoverable(request, username) 

973 username = None 

974 if username: 

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

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

977 

978 

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

980def change_password_page( 

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

982): 

983 """ 

984 Change password page (GET only). 

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

986 

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

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

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

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

991 """ 

992 return render_template( 

993 request, 

994 "auth/change_password.html", 

995 { 

996 "password_requirements": PasswordValidator.get_requirements(), 

997 }, 

998 ) 

999 

1000 

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

1002@limiter.limit(PASSWORD_CHANGE_RATE_LIMIT) 

1003def change_password( 

1004 request: Request, 

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

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

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

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

1009): 

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

1011 

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

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

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

1015 auth-policy enforcement across two code paths. 

1016 """ 

1017 

1018 # Validation 

1019 errors = [] 

1020 

1021 if not current_password: 

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

1023 

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

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

1026 else: 

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

1028 

1029 if new_password != confirm_password: 

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

1031 

1032 if current_password == new_password: 

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

1034 

1035 if errors: 

1036 for error in errors: 

1037 flash(request, error, "error") 

1038 return render_template( 

1039 request, 

1040 "auth/change_password.html", 

1041 { 

1042 "password_requirements": PasswordValidator.get_requirements(), 

1043 }, 

1044 status_code=400, 

1045 ) 

1046 

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

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

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

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

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

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

1053 # 

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

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

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

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

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

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

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

1061 # 

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

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

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

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

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

1067 # 

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

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

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

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

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

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

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

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

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

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

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

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

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

1081 # as a documented residual rather than hardened here. 

1082 from ..routes.globals import ( 

1083 get_usernames_with_active_research, 

1084 user_research_start_gate, 

1085 ) 

1086 

1087 with user_research_start_gate(username): 

1088 if username in get_usernames_with_active_research(): 

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

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

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

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

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

1094 flash( 

1095 request, 

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

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

1098 "error", 

1099 ) 

1100 return render_template( 

1101 request, 

1102 "auth/change_password.html", 

1103 { 

1104 "password_requirements": PasswordValidator.get_requirements(), 

1105 }, 

1106 status_code=409, 

1107 ) 

1108 

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

1110 success = db_manager.change_password( 

1111 username, current_password, new_password 

1112 ) 

1113 

1114 if success: 

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

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

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

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

1119 # is architecturally unnecessary. 

1120 

1121 # Clean up stale credentials before clearing session 

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

1123 

1124 # 1. Unregister from scheduler (removes stale credential) 

1125 try: 

1126 from ...scheduler.background import ( 

1127 get_background_job_scheduler, 

1128 ) 

1129 

1130 sched = get_background_job_scheduler() 

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

1132 sched.unregister_user(username) 

1133 except Exception: 

1134 logger.warning( 

1135 "Could not unregister user from scheduler", 

1136 ) 

1137 

1138 # 2. Close database connection (disposes old-password engine) 

1139 # change_password() already closes in its finally block, but 

1140 # an explicit close here is defensive — harmless if redundant. 

1141 db_manager.close_user_database(username) 

1142 

1143 # 2a. Drop per-user lock-dict entries (matches logout path). 

1144 from ..auth.connection_cleanup import _pop_per_user_locks 

1145 

1146 _pop_per_user_locks(username) 

1147 

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

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

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

1151 # decryptable with the compromised password. 

1152 try: 

1153 from ...database.backup.backup_service import BackupService 

1154 

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

1156 result = svc.purge_and_refresh() 

1157 if result.success: 

1158 logger.info( 

1159 f"Backups refreshed after password change for {username}" 

1160 ) 

1161 else: 

1162 logger.error( 

1163 f"Post-password-change backup failed for {username}: " 

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

1165 ) 

1166 except Exception: 

1167 logger.exception( 

1168 f"Could not refresh backups after password change " 

1169 f"for {username}" 

1170 ) 

1171 

1172 # 3. Destroy ALL sessions for this user + clear password store 

1173 session_manager.destroy_all_user_sessions(username) 

1174 

1175 from ...database.session_passwords import ( 

1176 session_password_store, 

1177 ) 

1178 

1179 session_password_store.clear_all_for_user(username) 

1180 

1181 # Drop per-thread cached credentials too. These hold the OLD 

1182 # plaintext password, which is now invalid — a pooled worker that 

1183 # kept it would both retain a dead secret and fail to reopen the 

1184 # database. See clear_user_credentials() for why per-request 

1185 # cleanup cannot reach a worker thread under FastAPI. 

1186 from ...database.thread_local_session import clear_user_credentials 

1187 

1188 clear_user_credentials(username) 

1189 

1190 # 3a. Disconnect live WebSocket connections so a socket authorised 

1191 # under the old session stops receiving this user's events 

1192 # (#5535). Every session is destroyed by a password change, so 

1193 # this is the all-sessions scope, unlike logout's per-session one. 

1194 _disconnect_user_sockets(username) 

1195 

1196 # 4. Clear Flask session dict 

1197 request.session.clear() 

1198 

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

1200 flash( 

1201 request, 

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

1203 "success", 

1204 ) 

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

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

1207 return render_template( 

1208 request, 

1209 "auth/change_password.html", 

1210 { 

1211 "password_requirements": PasswordValidator.get_requirements(), 

1212 }, 

1213 status_code=401, 

1214 ) 

1215 

1216 

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

1218def integrity_check( 

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

1220): 

1221 """ 

1222 Check database integrity for current user. 

1223 

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

1225 truth, consistent with every other authed endpoint. 

1226 """ 

1227 is_valid = db_manager.check_database_integrity(username) 

1228 

1229 return { 

1230 "username": username, 

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

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

1233 }