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
« 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"""
6from fastapi import APIRouter, Depends, Form, Query, Request
7from fastapi.responses import JSONResponse, RedirectResponse
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
20import contextvars
21import threading
22import time
23from datetime import datetime, timezone, UTC
25from loguru import logger
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
37from urllib.parse import urlparse
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
45router = APIRouter(prefix="/auth", tags=["auth"])
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
53 token = generate_csrf_token(request)
54 return {"csrf_token": token}
57def _rollback_partial_session(request: Request, username: str) -> None:
58 """Tear down a half-created login/registration session.
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.
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
79 session_password_store.clear_session(username, session_id)
80 except Exception:
81 logger.exception("Failed to roll back a partial user session")
84def _disconnect_user_sockets(username: str) -> None:
85 """Disconnect ALL of ``username``'s live sockets and drop subscriptions.
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.
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
101 disconnect_user(username)
102 except Exception:
103 logger.exception(f"Failed to disconnect sockets for {username}")
106def _disconnect_session_sockets(session_id: str) -> None:
107 """Disconnect only the sockets belonging to ``session_id``.
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
116 disconnect_session(session_id)
117 except Exception:
118 logger.exception("Failed to disconnect sockets for session")
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)
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 )
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"
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 )
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 )
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 )
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 )
233 # Success
234 lockout_mgr.record_success(username)
236 # Prevent session fixation
237 request.session.clear()
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
252 # Store password temporarily for post-login database access
253 from ...database.temp_auth import temp_auth_store
255 auth_token = temp_auth_store.store_auth(username, password)
256 request.session["temp_auth_token"] = auth_token
258 # Also store in session password store for metrics access
259 from ...database.session_passwords import session_password_store
261 session_password_store.store_session_password(
262 username, session_id, password
263 )
264 except Exception:
265 _rollback_partial_session(request, username)
266 raise
268 logger.info(f"User {username} logged in successfully")
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()
277 def _ctx_post_login():
278 _login_ctx.run(
279 _perform_post_login_tasks, username, password, session_id
280 )
282 thread = threading.Thread(target=_ctx_post_login, daemon=True)
283 thread.start()
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)
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.
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.
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 )
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()
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
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)
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
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)
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)
391 try:
392 from ...scheduler.background import (
393 get_background_job_scheduler,
394 )
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")
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 )
412 # Model cache refresh is handled by /api/settings/available-models
413 # via its 24h TTL and explicit force_refresh=true flag.
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
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)
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)
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)
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
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 )
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
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)
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 )
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 )
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).
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}
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)
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 )
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)
557 username = username.strip()
558 acknowledge_bool = acknowledge == "true"
560 # Validation
561 errors = []
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 )
572 if not password:
573 errors.append("Password is required")
574 else:
575 errors.extend(PasswordValidator.validate_strength(password))
577 if password != confirm_password:
578 errors.append("Passwords do not match")
580 if not acknowledge_bool:
581 errors.append(
582 "You must acknowledge that password recovery is not possible"
583 )
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.")
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 )
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 )
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 )
707 try:
708 # Prevent session fixation by clearing old session data
709 request.session.clear()
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
722 # Store password temporarily for post-registration database access
723 from ...database.temp_auth import temp_auth_store
725 auth_token = temp_auth_store.store_auth(username, password)
726 request.session["temp_auth_token"] = auth_token
728 # Also store in session password store for metrics access
729 from ...database.session_passwords import session_password_store
731 session_password_store.store_session_password(
732 username, session_id, password
733 )
735 # Notify the news scheduler about the new user
736 try:
737 from ...scheduler.background import (
738 get_background_job_scheduler,
739 )
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")
750 logger.info(f"New user registered: {username}")
752 # Initialize library system (source types and default collection)
753 from ...database.library_init import initialize_library_for_user
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
771 return RedirectResponse(url="/", status_code=302)
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 )
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")
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 )
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")
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)
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 )
862 session_password_store.clear_all_for_user(username)
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 )
873 clear_user_credentials(username)
874 except Exception:
875 logger.exception(
876 "session cleanup failed during logout — continuing"
877 )
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)
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 )
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 )
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
940 _pop_per_user_locks(username)
942 # Always wipe the cookie session so the browser is genuinely
943 # logged out even if earlier cleanup raised.
944 request.session.clear()
946 logger.info(f"User {username} logged out")
947 flash(request, "You have been logged out successfully", "info")
949 return RedirectResponse(url="/auth/login", status_code=302)
952@router.get("/check")
953def check_auth(request: Request):
954 """
955 Check if user is authenticated (for AJAX requests).
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)
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.
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 )
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).
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 """
1018 # Validation
1019 errors = []
1021 if not current_password:
1022 errors.append("Current password is required")
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))
1029 if new_password != confirm_password:
1030 errors.append("New passwords do not match")
1032 if current_password == new_password:
1033 errors.append("New password must be different from current password")
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 )
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 )
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 )
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 )
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.
1121 # Clean up stale credentials before clearing session
1122 # (mirrors logout handler cleanup steps 1–5).
1124 # 1. Unregister from scheduler (removes stale credential)
1125 try:
1126 from ...scheduler.background import (
1127 get_background_job_scheduler,
1128 )
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 )
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)
1143 # 2a. Drop per-user lock-dict entries (matches logout path).
1144 from ..auth.connection_cleanup import _pop_per_user_locks
1146 _pop_per_user_locks(username)
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
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 )
1172 # 3. Destroy ALL sessions for this user + clear password store
1173 session_manager.destroy_all_user_sessions(username)
1175 from ...database.session_passwords import (
1176 session_password_store,
1177 )
1179 session_password_store.clear_all_for_user(username)
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
1188 clear_user_credentials(username)
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)
1196 # 4. Clear Flask session dict
1197 request.session.clear()
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 )
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.
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)
1229 return {
1230 "username": username,
1231 "integrity": "valid" if is_valid else "corrupted",
1232 "timestamp": datetime.now(timezone.utc).isoformat(),
1233 }