Coverage for src/local_deep_research/web/routers/auth.py: 91%
489 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-22 21:20 +0000
1"""
2Authentication routes for login, register, and logout.
3Uses SQLCipher encrypted databases with browser password manager support.
4"""
6from fastapi import APIRouter, Depends, Form, HTTPException, Query, Request
7from fastapi.responses import JSONResponse, RedirectResponse
9from ..dependencies.auth import require_auth
10from ..dependencies.flash import flash
11from ..dependencies.rate_limit import (
12 INTEGRITY_CHECK_RATE_LIMIT,
13 LOGIN_RATE_LIMIT,
14 PASSWORD_CHANGE_RATE_LIMIT,
15 REGISTRATION_RATE_LIMIT,
16 VALIDATE_PASSWORD_RATE_LIMIT,
17 limiter,
18)
19from ..dependencies.template_helpers import render_template
21import contextvars
22import threading
23import time
24from datetime import datetime, timezone, UTC
26from loguru import logger
28from ...database.auth_db import auth_db_session
29from ...database.encrypted_db import DatabaseInitializationError, db_manager
30from ...database.rekey_lock import (
31 RekeyIndeterminateError,
32 RekeyLockContentionError,
33 RekeyOperationalError,
34 RekeySidecarIOError,
35)
36from ...database.models.auth import User
37from ...database.thread_local_session import thread_cleanup
38from sqlalchemy.exc import IntegrityError
39from ..auth.session_manager import (
40 session_manager,
41) # singleton from session_manager module
42from ..server_config import load_server_config
44import posixpath
45from urllib.parse import (
46 unquote,
47 unquote_plus,
48 urljoin,
49 urlparse,
50 urlsplit,
51 urlunsplit,
52)
54from ...security.url_validator import URLValidator
55from ...security.account_lockout import get_account_lockout_manager
56from ...security.password_validator import PasswordValidator
57from ...security.log_sanitizer import redact_and_bound_for_log
58from .notes import _log_value_preview
59from typing import Annotated
61router = APIRouter(prefix="/auth", tags=["auth"])
64@router.get("/csrf-token")
65def get_csrf_token(request: Request):
66 """Get CSRF token for API requests."""
67 from ..dependencies.csrf import generate_csrf_token, get_auth_context
69 context = get_auth_context(request.session)
70 expected_context = request.headers.get("X-LDR-Auth-Context")
71 if expected_context is not None and expected_context != context:
72 # A recovery fetch is tied to the page's original login. Ordinary
73 # anonymous token fetches (login/register forms) remain supported.
74 return JSONResponse(
75 {
76 "error": "Your sign-in changed. Reload this page before trying again."
77 if context
78 else "Authentication required"
79 },
80 status_code=409 if context else 401,
81 headers={"Cache-Control": "no-store"},
82 )
83 token = generate_csrf_token(request)
84 return JSONResponse(
85 {"csrf_token": token, "auth_context": context},
86 headers={"Cache-Control": "no-store"},
87 )
90def _rollback_partial_session(request: Request, username: str) -> None:
91 """Tear down a half-created login/registration session.
93 Session creation sets the cookie keys (session_id/username) and creates a
94 server-side session BEFORE the temp-auth / password-store steps. If a later
95 step fails, the caller returns a 500 — but SessionMiddleware would still
96 write the partially-set cookie on that response, effectively logging the
97 user in despite the reported failure (and leaking a server-side session +
98 password entry). Clear the cookie and best-effort tear down the server-side
99 session + password-store entry so the reported outcome matches reality.
101 Best-effort: cleanup must never mask the caller's original error. Ports the
102 Flask fix from #5006 (the FastAPI branch creates sessions inline rather than
103 via a shared ``_create_user_session`` helper).
104 """
105 session_id = request.session.get("session_id")
106 request.session.clear()
107 if session_id: 107 ↛ exitline 107 didn't return from function '_rollback_partial_session' because the condition on line 107 was always true
108 try:
109 session_manager.destroy_session(session_id)
110 from ...database.session_passwords import session_password_store
112 session_password_store.clear_session(username, session_id)
113 except Exception:
114 logger.exception("Failed to roll back a partial user session")
117def _disconnect_user_sockets(username: str) -> None:
118 """Disconnect ALL of ``username``'s live sockets and drop subscriptions.
120 A Socket.IO connection is authorised once at handshake and then keeps
121 delivering that user's events (including ``settings_changed``) for its
122 whole lifetime. Used by the
123 password-change flow, which destroys every session for the user, so all
124 of their sockets must go. For single-session logout use
125 ``_disconnect_session_sockets`` instead so other tabs / devices survive.
127 Retargeted from main's deleted Flask ``SocketIOService`` (#5535) onto the
128 ASGI socket layer. Best-effort: socket teardown must never break the auth
129 flow, and a non-web context (tests, CLI) simply has no loop running.
130 """
131 try:
132 from ..services.socketio_asgi import disconnect_user
134 disconnect_user(username)
135 except Exception:
136 logger.exception(
137 f"Failed to disconnect sockets for {_log_value_preview(username)}"
138 )
141def _disconnect_session_sockets(session_id: str) -> None:
142 """Disconnect only the sockets belonging to ``session_id``.
144 Used by single-session logout: it tears down the sockets of the session
145 being logged out without disconnecting the user's other still-valid
146 sessions. Best-effort — socket teardown must never break logout.
147 """
148 try:
149 from ..services.socketio_asgi import disconnect_session
151 disconnect_session(session_id)
152 except Exception:
153 logger.exception("Failed to disconnect sockets for session")
156# chat.js pre-fills the chat box from this query parameter of a chat URL.
157_CHAT_QUERY_PARAM = "q"
160def _opens_chat_page(path: str) -> bool:
161 """Whether a redirect to ``path`` would load the chat page.
163 The browser removes dot segments (``%2e`` included) from a redirect
164 target and the server percent-decodes the path before routing, so the
165 check uses the decoded path without dot segments. ``/chat`` counts too:
166 the router redirects it to ``/chat/`` and keeps the query. Ignoring
167 letter case can only make the check match more paths.
168 """
169 decoded = posixpath.normpath("/" + unquote(path).lstrip("/")).lower()
170 return decoded == "/chat" or decoded.startswith("/chat/")
173def _drop_chat_query(target: str, base_url: str) -> str:
174 """Remove ``q`` from a login ``next`` that leads to the chat page.
176 A link from another site can open
177 ``/auth/login?next=/chat/?q=<text>``. After the login POST the browser
178 follows this app's own redirect, so the chat page sees a same-origin
179 navigation, ``_drop_foreign_chat_query`` (chat.py) keeps ``q``, and
180 chat.js would pre-fill the linked text. So the login page drops ``q``
181 from such a target, both in the form it renders and in the redirect
182 after login. The rest of ``next`` is kept as written, and any other
183 target is returned unchanged. This runs alongside, not instead of,
184 ``URLValidator.get_safe_redirect_path``.
185 """
186 try:
187 parts = urlsplit(target)
188 resolved_path = urlsplit(urljoin(base_url, target)).path
189 except ValueError:
190 # Unparseable (for example "http://["). get_safe_redirect_path
191 # raises on the same input, so it never redirects anywhere.
192 return target
193 if not parts.query or not _opens_chat_page(resolved_path):
194 return target
195 kept = [
196 item
197 for item in parts.query.split("&")
198 # Decode the name the way chat.js's URLSearchParams does.
199 if unquote_plus(item.split("=", 1)[0]) != _CHAT_QUERY_PARAM
200 ]
201 return urlunsplit(parts._replace(query="&".join(kept)))
204@router.get("/login")
205def login_page(request: Request, next: Annotated[str, Query()] = ""):
206 """Login page (GET only)."""
207 config = load_server_config()
208 if request.session.get("username"):
209 return RedirectResponse(url="/", status_code=302)
211 return render_template(
212 request,
213 "auth/login.html",
214 {
215 "has_encryption": db_manager.has_encryption,
216 "allow_registrations": config.get("allow_registrations", True),
217 "next_page": _drop_chat_query(next, str(request.base_url)),
218 },
219 )
222@router.post("/login")
223@limiter.limit(LOGIN_RATE_LIMIT)
224def login(
225 request: Request,
226 username: Annotated[str, Form()] = "",
227 password: Annotated[str, Form()] = "", # noqa: S107
228 remember: Annotated[str, Form()] = "false",
229 next: Annotated[str, Query()] = "",
230):
231 """Login handler (POST only). Rate limited to prevent brute force."""
232 config = load_server_config()
233 username = username.strip()
234 remember_bool = remember == "true"
236 if not username or not password:
237 flash(request, "Username and password are required", "error")
238 return render_template(
239 request,
240 "auth/login.html",
241 {
242 "has_encryption": db_manager.has_encryption,
243 "allow_registrations": config.get("allow_registrations", True),
244 },
245 status_code=400,
246 )
248 # Check account lockout
249 lockout_mgr = get_account_lockout_manager()
250 if lockout_mgr.is_locked(username):
251 logger.warning(
252 f"Login attempt for locked account: {redact_and_bound_for_log(username, 50)}"
253 )
254 flash(
255 request,
256 "Account is temporarily locked. Please try again later.",
257 "error",
258 )
259 return render_template(
260 request,
261 "auth/login.html",
262 {
263 "has_encryption": db_manager.has_encryption,
264 "allow_registrations": config.get("allow_registrations", True),
265 },
266 status_code=429,
267 )
269 # Opening the database is this route's password check only with SQLCipher.
270 # The plaintext development fallback has no independent credential check:
271 # a successful open there does not prove the caller knows the account's
272 # password. A supported shared plaintext mode needs separate authentication.
273 # With SQLCipher, distinguish these two failure modes:
274 # - return None → credentials invalid OR DB missing → 401, count toward lockout
275 # - raise DatabaseInitializationError → credentials valid but schema
276 # can't be brought up (e.g. world-writable migrations dir tripping
277 # the alembic_runner permission check) → 503, do NOT count toward
278 # lockout. The user's password is correct; punishing them with a
279 # lockout for a server-side configuration problem would be wrong.
280 try:
281 engine = db_manager.open_user_database(username, password)
282 except DatabaseInitializationError:
283 logger.warning(
284 f"Login refused for {redact_and_bound_for_log(username, 50)}: "
285 "database initialisation failed (see traceback above). "
286 "Lockout counter NOT incremented — credentials are valid."
287 )
288 flash(
289 request,
290 "Database initialisation failed. The server is misconfigured — "
291 "please check the server logs or contact the administrator.",
292 "error",
293 )
294 return render_template(
295 request,
296 "auth/login.html",
297 {
298 "has_encryption": db_manager.has_encryption,
299 "allow_registrations": config.get("allow_registrations", True),
300 },
301 status_code=503,
302 )
303 except RekeyOperationalError as error:
304 # Durable rekey state is indeterminate, pending recovery, or the
305 # sidecar metadata cannot be read, or another holder (a password
306 # change of a large database, a stopped process) kept the sidecar
307 # past the open's wait bound (RekeyLockContentionError). Credentials
308 # cannot be validated in any of these cases, so fail closed WITHOUT
309 # counting toward lockout — the password may well be correct.
310 sidecar_detail = ""
311 if isinstance(error, RekeySidecarIOError): 311 ↛ 312line 311 didn't jump to line 312 because the condition on line 311 was never true
312 sidecar_detail = (
313 f" (sidecar {error.operation.value} failed, "
314 f"errno={error.errno})"
315 )
316 logger.warning(
317 f"Login refused for {redact_and_bound_for_log(username, 50)}: "
318 f"rekey state requires operator attention "
319 f"({type(error).__name__}){sidecar_detail}"
320 )
321 flash(
322 request,
323 "Your account database is pending maintenance. "
324 "Please try again shortly or contact the administrator.",
325 "error",
326 )
327 return render_template(
328 request,
329 "auth/login.html",
330 {
331 "has_encryption": db_manager.has_encryption,
332 "allow_registrations": config.get("allow_registrations", True),
333 },
334 status_code=503,
335 )
337 if engine is None:
338 lockout_mgr.record_failure(username)
339 logger.warning(
340 f"Failed login attempt for username: {redact_and_bound_for_log(username, 50)}"
341 )
342 flash(request, "Invalid username or password", "error")
343 return render_template(
344 request,
345 "auth/login.html",
346 {
347 "has_encryption": db_manager.has_encryption,
348 "allow_registrations": config.get("allow_registrations", True),
349 },
350 status_code=401,
351 )
353 # Success
354 lockout_mgr.record_success(username)
356 # Prevent session fixation
357 request.session.clear()
359 # Create the session atomically: on any post-creation failure below, roll
360 # back the half-set cookie + server-side session so the error isn't a
361 # silent login (see _rollback_partial_session / #5006).
362 try:
363 # Create session
364 session_id = session_manager.create_session(username, remember_bool)
365 request.session["session_id"] = session_id
366 request.session["username"] = username
367 # RememberMeMiddleware reads this to decide whether to strip
368 # Max-Age/Expires from the session cookie (making it a browser
369 # session cookie that's discarded on close).
370 request.session["_remember_me"] = remember_bool
372 # Store password temporarily for post-login database access
373 from ...database.temp_auth import temp_auth_store
375 auth_token = temp_auth_store.store_auth(username, password)
376 request.session["temp_auth_token"] = auth_token
378 # Also store in session password store for metrics access
379 from ...database.session_passwords import session_password_store
381 session_password_store.store_session_password(
382 username, session_id, password
383 )
384 except Exception:
385 _rollback_partial_session(request, username)
386 raise
388 logger.info(f"User {_log_value_preview(username)} logged in successfully")
390 # Defer non-critical post-login work to a background thread so the
391 # redirect returns immediately (settings migration, library init,
392 # news scheduler notify, and backup scheduling are all idempotent
393 # and can safely run after the response). Copy the request context
394 # so `get_current_username()` inside the worker resolves the user.
395 _login_ctx = contextvars.copy_context()
397 def _ctx_post_login():
398 _login_ctx.run(
399 _perform_post_login_tasks, username, password, session_id
400 )
402 thread = threading.Thread(target=_ctx_post_login, daemon=True)
403 thread.start()
405 safe_path = URLValidator.get_safe_redirect_path(next, str(request.base_url))
406 if safe_path:
407 safe_path = _drop_chat_query(
408 safe_path.replace("\\", "/"), str(request.base_url)
409 )
410 parsed = urlparse(safe_path)
411 if not parsed.scheme and not parsed.netloc: 411 ↛ 413line 411 didn't jump to line 413 because the condition on line 411 was always true
412 return RedirectResponse(url=safe_path, status_code=302)
413 return RedirectResponse(url="/", status_code=302)
416@thread_cleanup
417def _perform_post_login_tasks(
418 username: str, password: str, session_id: str
419) -> None:
420 """Run non-critical post-login operations in a background thread.
422 Each operation is wrapped in its own try/except so that one failure
423 does not prevent the others from running. All operations here are
424 idempotent and safe to retry on the next login.
426 An outer try/except wraps the whole body so any exception that
427 escapes the per-step handlers (for example a failure inside a
428 ``with`` context manager's __enter__ / __exit__) is logged loudly
429 with a traceback instead of dying silently in the daemon thread.
430 """
431 try:
432 _perform_post_login_tasks_body(username, password, session_id)
433 except Exception:
434 logger.exception(
435 f"Post-login background thread crashed for user {_log_value_preview(username)}"
436 )
439def _perform_post_login_tasks_body(
440 username: str, password: str, session_id: str
441) -> None:
442 """Body of _perform_post_login_tasks — split out so the outer
443 try/except in the wrapper catches anything the per-step handlers
444 miss. See _perform_post_login_tasks for rationale."""
445 total_start = time.perf_counter()
447 # 1. Settings version check + migration
448 #
449 # ATOMICITY INVARIANT: the defaults import and the `app.version`
450 # marker MUST be written in one `get_user_db_session(...)` scope
451 # with a single terminal `db_session.commit()`. SQLite WAL rollback
452 # then guarantees either both land or neither does — the only
453 # acceptable states for `db_version_matches_package()` to behave
454 # correctly on the next login. Splitting into two commits regresses
455 # to the "sticky loop": `app.version` stays unwritten, every
456 # subsequent login re-runs the ~498-row bulk insert (app.version is
457 # not in default_settings.json, only `update_db_version()` writes
458 # it). Do not factor these calls into separate sessions or allow
459 # the `import_settings`/`update_db_version` calls to commit
460 # internally here — both must be called with `commit=False`.
461 step_start = time.perf_counter()
462 try:
463 from ...settings.manager import SettingsManager
464 from ...database.session_context import get_user_db_session
466 with get_user_db_session(username, password) as db_session:
467 settings_manager = SettingsManager(db_session)
468 if not settings_manager.db_version_matches_package():
469 logger.info(
470 f"Database version mismatch for {_log_value_preview(username)} "
471 "- loading missing default settings"
472 )
473 # override_locked: this only adds keys the upgrade
474 # introduced, so a locked account still needs it. Spent via
475 # a direct import_settings call — the
476 # load_from_defaults_file wrapper no longer accepts
477 # override_locked (#5841).
478 settings_manager.import_settings(
479 settings_manager.default_settings,
480 commit=False,
481 overwrite=False,
482 override_locked=True,
483 )
484 settings_manager.update_db_version(commit=False)
485 db_session.commit()
486 logger.info(
487 f"Missing default settings loaded and version "
488 f"updated for user {_log_value_preview(username)}"
489 )
490 except Exception:
491 logger.exception(
492 f"Post-login settings migration failed for {_log_value_preview(username)}"
493 )
494 _log_step_duration("step 1 (settings version check)", step_start, username)
496 # 2. Initialize library system (source types and default collection)
497 step_start = time.perf_counter()
498 try:
499 from ...database.library_init import initialize_library_for_user
501 init_results = initialize_library_for_user(username, password)
502 if init_results.get("success"):
503 logger.info(
504 f"Library system initialized for user {_log_value_preview(username)}"
505 )
506 else:
507 logger.warning(
508 f"Library initialization issue for {_log_value_preview(username)}: "
509 f"{init_results.get('error', 'Unknown error')}"
510 )
511 except Exception:
512 logger.exception(
513 f"Post-login library init failed for {_log_value_preview(username)}"
514 )
515 _log_step_duration("step 2 (library init)", step_start, username)
517 # 3. Update last_login in auth DB + notify news scheduler
518 step_start = time.perf_counter()
519 try:
520 with auth_db_session() as auth_db:
521 user = auth_db.query(User).filter_by(username=username).first()
522 if user:
523 user.last_login = datetime.now(UTC)
525 try:
526 from ...scheduler.background import (
527 get_background_job_scheduler,
528 )
530 scheduler = get_background_job_scheduler()
531 if scheduler.is_running:
532 scheduler.update_user_info(username, password)
533 logger.info(
534 f"Updated scheduler with user info for {_log_value_preview(username)}"
535 )
536 except Exception:
537 logger.exception("Could not update scheduler on login")
539 auth_db.commit()
540 except Exception:
541 logger.exception(
542 f"Post-login auth DB update failed for {_log_value_preview(username)}"
543 )
544 _log_step_duration(
545 "step 3 (auth DB + scheduler notify)", step_start, username
546 )
548 # Model cache refresh is handled by POST /settings/api/available-models;
549 # ordinary GET reads use its 24h TTL.
551 # 4. Schedule background database backup if enabled
552 step_start = time.perf_counter()
553 try:
554 from ...database.backup import get_backup_executor
555 from ...settings.manager import SettingsManager
556 from ...database.session_context import get_user_db_session
558 with get_user_db_session(username, password) as db_session:
559 sm = SettingsManager(db_session)
560 backup_enabled = sm.get_setting("backup.enabled", True)
562 if backup_enabled:
563 max_backups = sm.get_setting("backup.max_count", 1)
564 max_age_days = sm.get_setting("backup.max_age_days", 7)
566 get_backup_executor().submit_backup(
567 username, password, max_backups, max_age_days
568 )
569 logger.info(
570 f"Background backup scheduled for user {_log_value_preview(username)}"
571 )
572 except Exception:
573 logger.exception(
574 f"Post-login backup scheduling failed for {_log_value_preview(username)}"
575 )
576 _log_step_duration("step 4 (schedule backup)", step_start, username)
578 # 6. Reconcile orphan IN_PROGRESS research records. If the server was
579 # restarted mid-research, those rows point to dead threads and would
580 # otherwise appear as "running forever" in the UI with no way to cancel.
581 step_start = time.perf_counter()
582 try:
583 from ...database.session_context import get_user_db_session
584 from ..queue.processor_v2 import queue_processor
585 from ..research_state import user_research_start_gate
587 # Earlier post-login steps reuse this thread-local session. End any
588 # read transaction before taking the process-wide gate -> DB order.
589 with get_user_db_session(username, password) as ordering_session:
590 ordering_session.rollback()
592 # A concurrent start holds this same gate until worker registration,
593 # so login reconciliation cannot mistake its fresh durable claim for
594 # a server-restart orphan.
595 with user_research_start_gate(username):
596 with get_user_db_session(username, password) as db_session:
597 queue_processor.reconcile_orphan_active_research(
598 username, db_session
599 )
600 except Exception:
601 logger.exception(
602 f"Post-login research reconciliation failed for {_log_value_preview(username)}"
603 )
604 _log_step_duration(
605 "step 6 (reconcile orphan research)", step_start, username
606 )
608 # 7. Resume queued researches. The queue processor's wake-list
609 # (_users_to_check) is in-memory, so after a server restart any QUEUED
610 # rows would never dispatch — registration normally happens at queue
611 # time (notify_research_queued), and here at login for recovery. Under
612 # Flask this was fed by a before_request hook on every authenticated
613 # request; login is the FastAPI-native anchor where username,
614 # session_id, and the session password are all freshly available. If
615 # the user has nothing queued, the first loop tick deregisters them.
616 step_start = time.perf_counter()
617 try:
618 from ..queue.processor_v2 import queue_processor
620 queue_processor.notify_user_activity(username, session_id)
621 except Exception:
622 logger.exception(
623 f"Post-login queue-resume registration failed for {_log_value_preview(username)}"
624 )
625 _log_step_duration("step 7 (resume queued research)", step_start, username)
627 total_ms = (time.perf_counter() - total_start) * 1000
628 if total_ms > 1000: 628 ↛ 629line 628 didn't jump to line 629 because the condition on line 628 was never true
629 logger.info(
630 f"Post-login tasks completed for user {_log_value_preview(username)} "
631 f"(total: {total_ms:.0f}ms)"
632 )
633 else:
634 logger.info(
635 f"Post-login tasks completed for user {_log_value_preview(username)} ({total_ms:.0f}ms)"
636 )
639def _log_step_duration(step_label: str, start: float, username: str) -> None:
640 """Log post-login step duration at INFO if > 100ms, else DEBUG."""
641 elapsed_ms = (time.perf_counter() - start) * 1000
642 if elapsed_ms > 100:
643 logger.info(
644 f"Post-login {step_label} for {_log_value_preview(username)} took {elapsed_ms:.0f}ms"
645 )
646 else:
647 logger.opt(lazy=True).debug(
648 "Post-login {} for {} took {:.0f}ms",
649 lambda: step_label,
650 lambda: _log_value_preview(username),
651 lambda: elapsed_ms,
652 )
655@router.post("/validate-password")
656@limiter.limit(VALIDATE_PASSWORD_RATE_LIMIT)
657def validate_password(
658 request: Request,
659 password: Annotated[str, Form()] = "", # noqa: S107
660):
661 """Validate password strength via API (used by client-side forms).
663 Rate-limited to prevent using this endpoint as a complexity oracle
664 against unknown candidate passwords.
665 """
666 errors = PasswordValidator.validate_strength(password)
667 return {"valid": len(errors) == 0, "errors": errors}
670@router.get("/register")
671def register_page(request: Request):
672 """
673 Registration page (GET only).
674 Not rate limited - viewing the page should always work.
675 """
676 config = load_server_config()
677 if not config.get("allow_registrations", True):
678 flash(
679 request, "New user registrations are currently disabled.", "error"
680 )
681 return RedirectResponse(url="/auth/login", status_code=302)
683 return render_template(
684 request,
685 "auth/register.html",
686 {
687 "has_encryption": db_manager.has_encryption,
688 "password_requirements": PasswordValidator.get_requirements(),
689 },
690 )
693@router.post("/register")
694@limiter.limit(REGISTRATION_RATE_LIMIT)
695def register(
696 request: Request,
697 username: Annotated[str, Form()] = "",
698 password: Annotated[str, Form()] = "", # noqa: S107
699 confirm_password: Annotated[str, Form()] = "", # noqa: S107
700 acknowledge: Annotated[str, Form()] = "false",
701):
702 """Registration handler (POST only)."""
703 config = load_server_config()
704 if not config.get("allow_registrations", True):
705 flash(
706 request, "New user registrations are currently disabled.", "error"
707 )
708 return RedirectResponse(url="/auth/login", status_code=302)
710 username = username.strip()
711 acknowledge_bool = acknowledge == "true"
713 # Validation
714 errors = []
716 if not username:
717 errors.append("Username is required")
718 elif len(username) < 3:
719 errors.append("Username must be at least 3 characters")
720 elif not username.replace("_", "").replace("-", "").isalnum():
721 errors.append(
722 "Username can only contain letters, numbers, underscores, and hyphens"
723 )
725 if not password:
726 errors.append("Password is required")
727 else:
728 errors.extend(PasswordValidator.validate_strength(password))
730 if password != confirm_password:
731 errors.append("Passwords do not match")
733 if not acknowledge_bool:
734 errors.append(
735 "You must acknowledge that password recovery is not possible"
736 )
738 # Check if user already exists
739 # Use generic error message to prevent account enumeration
740 # Note: While this creates a minor timing difference, it's acceptable because:
741 # 1. Rate limiting prevents automated timing analysis
742 # 2. Generic error message prevents content-based enumeration
743 # 3. Local database query timing is minimal (no network calls)
744 # 4. Better UX with immediate feedback outweighs minor timing risk
745 # See: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
746 if not errors and username and db_manager.user_exists(username):
747 errors.append("Registration failed. Please try a different username.")
749 if errors:
750 for error in errors:
751 flash(request, error, "error")
752 return render_template(
753 request,
754 "auth/register.html",
755 {
756 "has_encryption": db_manager.has_encryption,
757 "password_requirements": PasswordValidator.get_requirements(),
758 },
759 status_code=400,
760 )
762 # (#6938: the form username has a minimum length but no maximum, so
763 # every registration log line below interpolates this bounded preview,
764 # never the raw value.)
765 log_username = _log_value_preview(username)
767 # Create user in auth database
768 with auth_db_session() as auth_db:
769 try:
770 new_user = User(username=username)
771 auth_db.add(new_user)
772 auth_db.flush()
773 # Capture the PK after flush (the INSERT has run and assigned the
774 # id) but before commit: reading it here needs no post-commit
775 # refresh SELECT (expire_on_commit), and if the commit below fails
776 # nothing is persisted, so there is no orphan to clean up. Cleanup
777 # deletes by this id rather than re-querying by username, which
778 # could race a concurrent registration reusing the same username.
779 new_user_id = new_user.id
780 auth_db.commit()
781 except IntegrityError:
782 # Catch duplicate username specifically (race condition case)
783 # This handles the edge case where two requests for the same username
784 # pass the user_exists() check simultaneously
785 logger.warning(f"Duplicate username attempted: {log_username}")
786 auth_db.rollback()
787 flash(
788 request,
789 "Registration failed. Please try a different username.",
790 "error",
791 )
792 return render_template(
793 request,
794 "auth/register.html",
795 {
796 "has_encryption": db_manager.has_encryption,
797 "password_requirements": PasswordValidator.get_requirements(),
798 },
799 status_code=400,
800 )
801 except Exception:
802 logger.exception(
803 f"Registration failed for {log_username} while creating the "
804 f"auth record"
805 )
806 auth_db.rollback()
807 flash(request, "Registration failed. Please try again.", "error")
808 return render_template(
809 request,
810 "auth/register.html",
811 {
812 "has_encryption": db_manager.has_encryption,
813 "password_requirements": PasswordValidator.get_requirements(),
814 },
815 status_code=500,
816 )
818 # Create encrypted database for user.
819 #
820 # This is the only step that can leave an orphaned auth row: the User
821 # row is already committed, but no encrypted DB exists yet, so the
822 # username is taken (blocking re-registration via user_exists()) while
823 # login also fails (no DB to open) — a permanently bricked account.
824 # The cleanup scope is intentionally narrow: later failures (session
825 # setup, library init) must NOT delete the auth row, because by then
826 # the user DB exists and the account is legitimate.
827 try:
828 db_manager.create_user_database(username, password)
829 except Exception:
830 logger.exception(
831 f"Registration failed for {log_username} while creating the "
832 f"encrypted database"
833 )
834 # Delete the orphaned auth row by primary key in a fresh session
835 # (the original auth_db session has already closed). Deleting by ID
836 # avoids racing a concurrent registration that re-uses the username.
837 try:
838 with auth_db_session() as cleanup_db:
839 orphaned_user = (
840 cleanup_db.query(User).filter_by(id=new_user_id).first()
841 )
842 if orphaned_user is not None: 842 ↛ 854line 842 didn't jump to line 854
843 cleanup_db.delete(orphaned_user)
844 cleanup_db.commit()
845 logger.info(
846 f"Cleaned up orphaned auth entry for {log_username}"
847 )
848 except Exception:
849 # Surface a stuck orphan so it's diagnosable, but don't mask the
850 # original registration failure returned to the user.
851 logger.exception(
852 f"Failed to clean up orphaned auth entry for {log_username}"
853 )
854 flash(request, "Registration failed. Please try again.", "error")
855 return render_template(
856 request,
857 "auth/register.html",
858 {
859 "has_encryption": db_manager.has_encryption,
860 "password_requirements": PasswordValidator.get_requirements(),
861 },
862 status_code=500,
863 )
865 try:
866 # Prevent session fixation by clearing old session data
867 request.session.clear()
869 # Auto-login after registration
870 session_id = session_manager.create_session(username, False)
871 request.session["session_id"] = session_id
872 request.session["username"] = username
873 # Registration auto-login is NOT "remember me": issue a
874 # browser-session cookie (discarded on close), matching main's
875 # non-permanent session. RememberMeMiddleware only strips the
876 # 30-day Max-Age when _remember_me is explicitly False — leaving
877 # it unset would persist the cookie for 30 days.
878 request.session["_remember_me"] = False
880 # Store password temporarily for post-registration database access
881 from ...database.temp_auth import temp_auth_store
883 auth_token = temp_auth_store.store_auth(username, password)
884 request.session["temp_auth_token"] = auth_token
886 # Also store in session password store for metrics access
887 from ...database.session_passwords import session_password_store
889 session_password_store.store_session_password(
890 username, session_id, password
891 )
893 # Notify the news scheduler about the new user
894 try:
895 from ...scheduler.background import (
896 get_background_job_scheduler,
897 )
899 scheduler = get_background_job_scheduler()
900 if scheduler.is_running:
901 scheduler.update_user_info(username, password)
902 logger.info(
903 f"Updated scheduler with new user info for {log_username}"
904 )
905 except Exception:
906 logger.exception("Could not update scheduler on registration")
908 logger.info(f"New user registered: {log_username}")
910 # Initialize library system (source types and default collection)
911 from ...database.library_init import initialize_library_for_user
913 try:
914 init_results = initialize_library_for_user(username, password)
915 if init_results.get("success"): 915 ↛ 920line 915 didn't jump to line 920 because the condition on line 915 was always true
916 logger.info(
917 f"Library system initialized for new user {log_username}"
918 )
919 else:
920 logger.warning(
921 f"Library initialization issue for {log_username}: {init_results.get('error', 'Unknown error')}"
922 )
923 except Exception:
924 logger.exception(
925 f"Error initializing library for new user {log_username}"
926 )
927 # Don't block registration on library init failure
929 return RedirectResponse(url="/", status_code=302)
931 except Exception:
932 # The encrypted DB already exists at this point, so this failure is in
933 # post-creation setup (auto-login / session). The account is created
934 # and loginable; do NOT clean up the auth row here (narrow scope).
935 # But DO roll back any half-created session so this 500 doesn't leave
936 # the user silently logged in (SessionMiddleware would otherwise write
937 # the partially-set cookie on this response — see #5006).
938 _rollback_partial_session(request, username)
939 logger.exception(
940 f"Registration failed for {log_username} after database creation "
941 f"(post-creation session/login setup)"
942 )
943 flash(request, "Registration failed. Please try again.", "error")
944 return render_template(
945 request,
946 "auth/register.html",
947 {
948 "has_encryption": db_manager.has_encryption,
949 "password_requirements": PasswordValidator.get_requirements(),
950 },
951 status_code=500,
952 )
955@router.post("/logout")
956def logout(request: Request):
957 """
958 Logout handler.
959 Clears session and closes database connections.
960 POST-only to prevent CSRF-triggered logout via GET (e.g. <img src="/auth/logout">).
961 """
962 username = request.session.get("username")
963 session_id = request.session.get("session_id")
965 if username:
966 # LOGOUT CLEANUP ORDER (order matters):
967 # 1. Unregister from news scheduler — removes the password from the
968 # scheduler's user_sessions dict and cancels scheduled jobs.
969 # 2. Invalidate the server-side session AND clear every credential
970 # store for this user — BEFORE closing the DB, so the
971 # always-running queue processor / scheduler can't read a stale
972 # password and reopen the DB in the close→clear window. This also
973 # makes the socket connect gate (validate_session at handshake)
974 # reject any NEW socket from this moment on.
975 # 3. Disconnect this session's live WebSocket connections — sockets are
976 # authorised once at handshake and never re-checked, so without this
977 # a socket connected before logout keeps receiving the user's events.
978 # Scoped to THIS session so the user's other tabs / devices stay
979 # connected. MUST run AFTER step 2 so the socket connect gate
980 # above already rejects new sockets before we tear down the
981 # existing ones.
982 # 4. Close the database connection — unless the user still has research
983 # running (closing then would make the log-queue drain silently drop
984 # that job's logs; the idle sweeper closes it once the job ends).
985 # 5. Drop per-user lock-dict entries.
986 # 6. Clear the Flask session dict.
987 try:
988 from ...scheduler.background import (
989 get_background_job_scheduler,
990 )
992 sched = get_background_job_scheduler()
993 if sched.is_running:
994 sched.unregister_user(username)
995 except Exception:
996 logger.warning("Could not unregister user from scheduler")
998 # Step 2: invalidate the server session and clear EVERY credential
999 # store for this user, BEFORE closing the DB. The always-running
1000 # queue processor / scheduler read the session password store to
1001 # reopen a user's DB, so clearing only after the close leaves a
1002 # window where a concurrent tick resurrects the connection — and
1003 # even resumes a queued research — after logout.
1004 try:
1005 if session_id: 1005 ↛ 1016line 1005 didn't jump to line 1016 because the condition on line 1005 was always true
1006 session_manager.destroy_session(session_id)
1008 # Clear EVERY stored password for this user, not just this
1009 # session's entry. Re-logging in without an explicit logout
1010 # replaces the cookie's session_id and orphans the previous
1011 # entry; because password lookup falls back to a
1012 # username-wide scan (get_any_session_password), a single
1013 # orphan lets a captured pre-logout cookie re-open the
1014 # decrypted DB after logout. Matches change-password, and is
1015 # broader than main's per-session clear_session for that reason.
1016 from ...database.session_passwords import (
1017 session_password_store,
1018 )
1020 session_password_store.clear_all_for_user(username)
1022 # Same reasoning, second store. Ordinary synchronous requests
1023 # clear their worker-local credential through
1024 # WorkerCleanupAPIRoute, but logout must also remove entries held
1025 # by other workers currently acting for this user. One thread
1026 # cannot clear another thread's ``threading.local`` directly.
1027 from ...database.thread_local_session import (
1028 clear_user_credentials,
1029 )
1031 clear_user_credentials(username)
1032 except Exception:
1033 logger.exception(
1034 "session cleanup failed during logout — continuing"
1035 )
1037 # Step 3: disconnect THIS session's live sockets (#5535). A socket
1038 # is authorised once at handshake and never re-checked, so without
1039 # this one connected before logout keeps receiving the user's
1040 # events. Scoped to this session so other tabs / devices survive.
1041 # MUST run after step 2: the connect gate already rejects new
1042 # sockets by now, which closes the reconnect-in-teardown-window
1043 # race — were sockets torn down first, one connecting in the gap
1044 # would still pass the gate and survive the logout.
1045 if session_id: 1045 ↛ 1077line 1045 didn't jump to line 1077 because the condition on line 1045 was always true
1046 _disconnect_session_sockets(session_id)
1048 # Step 4: close the DB connection — but not while the user still has
1049 # research running. A running job keeps producing logs, and the
1050 # log-queue drain no longer reopens a closed DB, so closing here
1051 # would silently drop that job's log rows. The idle-connection
1052 # sweeper closes it once the job finishes.
1053 #
1054 # The active-research check and the close run under this user's
1055 # ``user_research_start_gate`` — the SAME per-user gate
1056 # ``check_and_start_research`` acquires to register a new research
1057 # thread, and that ``change_password`` holds across its rekey.
1058 # Holding it here closes two check-then-act races:
1059 # (a) a concurrent same-user ``change_password`` rekey (another
1060 # tab) could have its in-flight engine disposed by this close
1061 # mid-rekey; sharing the gate serialises the two.
1062 # (b) a research STARTING between the check and the close could
1063 # have its freshly-opened DB closed underneath it and silently
1064 # lose logs; holding the gate means a concurrent
1065 # ``check_and_start_research`` either registers before our
1066 # check (so we see it active and skip the close) or blocks
1067 # until after it.
1068 # Lock ordering is preserved: gate BEFORE ``_lock`` (via
1069 # ``get_usernames_with_active_research``) and before the DB layer's
1070 # ``_connections_lock`` (via ``close_user_database``) — the same
1071 # order used everywhere else, so no deadlock.
1072 #
1073 # Guarded so a failure here (e.g. SQLCipher cleanup error) doesn't
1074 # skip the session-clear below, which would leave the user still
1075 # authenticated on the client even though we logged a successful
1076 # logout above.
1077 try:
1078 from ..routes.globals import (
1079 get_usernames_with_active_research,
1080 user_research_start_gate,
1081 )
1083 with user_research_start_gate(username):
1084 if username not in get_usernames_with_active_research():
1085 db_manager.close_user_database(username)
1086 except Exception:
1087 logger.exception(
1088 "close_user_database failed during logout — continuing"
1089 )
1091 # Drop per-user lock-dict entries (library-init, backup,
1092 # queue-processor critical sections). Matches the cleanup
1093 # done by the idle-connection sweeper; without this, those
1094 # three module-level dicts accumulate one entry per username
1095 # across the process lifetime.
1096 from ..auth.connection_cleanup import (
1097 _evict_collection_engines_cache,
1098 _pop_per_user_locks,
1099 )
1101 _pop_per_user_locks(username)
1103 # Same per-user residue as the lock dicts above, dropped on the
1104 # same teardown paths (this one, password change, the idle
1105 # sweeper): the user's cached collection names/descriptions
1106 # (#6609). Best-effort: logout does not stop a running research
1107 # (the DB-close step above defers to it), and that research can
1108 # refill the entry afterwards. Such an entry then expires under
1109 # the TTL like any other.
1110 _evict_collection_engines_cache(username)
1112 # Always wipe the cookie session so the browser is genuinely
1113 # logged out even if earlier cleanup raised.
1114 request.session.clear()
1116 logger.info(f"User {_log_value_preview(username)} logged out")
1117 flash(request, "You have been logged out successfully", "info")
1119 if "application/json" in request.headers.get("accept", ""):
1120 return JSONResponse({"success": True})
1121 return RedirectResponse(url="/auth/login", status_code=302)
1124@router.get("/check")
1125def check_auth(request: Request):
1126 """
1127 Check if user is authenticated (for AJAX requests).
1129 Catch the authentication failure locally so this endpoint always answers
1130 JSON, including for callers without an Accept header. Share the actual
1131 authentication checks with protected routes, including credential loss
1132 while a shared database connection is still open.
1133 """
1134 try:
1135 username = require_auth(request)
1136 except HTTPException as exc:
1137 if exc.status_code != 401:
1138 raise
1139 return JSONResponse({"authenticated": False}, status_code=401)
1140 return {"authenticated": True, "username": username}
1143def _revoke_user_credentials_after_rekey(
1144 request: Request, username: str
1145) -> None:
1146 """Revoke everything that carries or was authorised by the old password.
1148 Shared by a completed password change and one whose outcome is
1149 indeterminate (the new key may be active). Mirrors the logout
1150 handler's cleanup, but for every session of the user, not just this
1151 one. Each step runs on its own: one that fails is logged (without the
1152 exception text, which could carry request data) and the others still
1153 run, so a scheduler fault cannot leave other devices signed in.
1154 """
1156 def unregister_scheduler() -> None:
1157 # Removes the scheduler's stored credential.
1158 from ...scheduler.background import get_background_job_scheduler
1160 sched = get_background_job_scheduler()
1161 if sched.is_running: 1161 ↛ 1162line 1161 didn't jump to line 1162 because the condition on line 1161 was never true
1162 sched.unregister_user(username)
1164 def pop_per_user_locks() -> None:
1165 # Drop per-user lock-dict entries (matches logout path).
1166 from ..auth.connection_cleanup import _pop_per_user_locks
1168 _pop_per_user_locks(username)
1170 def evict_collection_engines() -> None:
1171 # Drop the cached collection search engines (matches logout path;
1172 # every session ends below).
1173 from ..auth.connection_cleanup import _evict_collection_engines_cache
1175 _evict_collection_engines_cache(username)
1177 def destroy_sessions() -> None:
1178 session_manager.destroy_all_user_sessions(username)
1180 def clear_session_passwords() -> None:
1181 from ...database.session_passwords import session_password_store
1183 session_password_store.clear_all_for_user(username)
1185 def clear_thread_credentials() -> None:
1186 # Per-thread cached credentials hold the OLD plaintext password,
1187 # which is (or may be) invalid now: a pooled worker that kept it
1188 # would both retain a dead secret and fail to reopen the database.
1189 # See clear_user_credentials() for why per-request cleanup cannot
1190 # reach a worker thread under FastAPI.
1191 from ...database.thread_local_session import clear_user_credentials
1193 clear_user_credentials(username)
1195 def disconnect_sockets() -> None:
1196 # A socket authorised under an old session must stop receiving this
1197 # user's events (#5535). Every session is destroyed, so this is the
1198 # all-sessions scope, unlike logout's per-session one.
1199 _disconnect_user_sockets(username)
1201 def clear_request_session() -> None:
1202 request.session.clear()
1204 for step in (
1205 unregister_scheduler,
1206 pop_per_user_locks,
1207 evict_collection_engines,
1208 destroy_sessions,
1209 clear_session_passwords,
1210 clear_thread_credentials,
1211 disconnect_sockets,
1212 clear_request_session,
1213 ):
1214 try:
1215 step()
1216 except Exception as error:
1217 logger.warning(
1218 f"Credential revocation step {step.__name__} failed for "
1219 f"{_log_value_preview(username)}: {type(error).__name__}"
1220 )
1223@router.get("/change-password")
1224def change_password_page(
1225 request: Request, username: Annotated[str, Depends(require_auth)]
1226):
1227 """
1228 Change password page (GET only).
1229 Not rate limited - viewing the page should always work.
1231 Auth via Depends(require_auth) — same gate as the POST handler below
1232 (change_password), including the is_user_connected() staleness check.
1233 The manual `session.get("username")` check this replaces only verified
1234 a cookie existed, not that the session still had a live DB connection.
1235 """
1236 return render_template(
1237 request,
1238 "auth/change_password.html",
1239 {
1240 "password_requirements": PasswordValidator.get_requirements(),
1241 },
1242 )
1245@router.post("/change-password")
1246@limiter.limit(PASSWORD_CHANGE_RATE_LIMIT)
1247def change_password(
1248 request: Request,
1249 username: Annotated[str, Depends(require_auth)],
1250 current_password: Annotated[str, Form()] = "", # noqa: S107
1251 new_password: Annotated[str, Form()] = "", # noqa: S107
1252 confirm_password: Annotated[str, Form()] = "", # noqa: S107
1253):
1254 """Change password handler (POST only).
1256 Auth via Depends(require_auth) — same gate as the rest of the app,
1257 including any future SessionManager.validate_session() hook. Reading
1258 request.session["username"] directly bypassed that gate and split
1259 auth-policy enforcement across two code paths.
1260 """
1262 # Validation
1263 errors = []
1265 # ``strip()``: a whitespace-only current password is not empty, so it
1266 # passed this check and reached ``change_password``'s own key validation,
1267 # which raises ValueError -- a 500 where the form should simply say the
1268 # field is required (#6375). The new password needs no equivalent: every
1269 # whitespace-only value fails validate_strength below.
1270 if not current_password.strip():
1271 errors.append("Current password is required")
1273 if not new_password: 1273 ↛ 1274line 1273 didn't jump to line 1274 because the condition on line 1273 was never true
1274 errors.append("New password is required")
1275 else:
1276 errors.extend(PasswordValidator.validate_strength(new_password))
1278 if new_password != confirm_password:
1279 errors.append("New passwords do not match")
1281 if current_password == new_password:
1282 errors.append("New password must be different from current password")
1284 if errors:
1285 for error in errors:
1286 flash(request, error, "error")
1287 return render_template(
1288 request,
1289 "auth/change_password.html",
1290 {
1291 "password_requirements": PasswordValidator.get_requirements(),
1292 },
1293 status_code=400,
1294 )
1296 # Do NOT rekey the encrypted DB while a research job is running. The rekey
1297 # (close -> PRAGMA rekey -> close) needs exclusive access, and a running
1298 # job holds a session bound to the pre-rekey engine with the now-invalid
1299 # old password — it can't recover, and a concurrent rekey risks corrupting
1300 # the whole encrypted DB. Unlike logout's DB close, the destructive rekey
1301 # can't be deferred, so reject until research finishes.
1302 #
1303 # The check and the rekey run under this user's ``user_research_start_gate``
1304 # — the SAME per-user gate ``check_and_start_research`` (routes/globals.py)
1305 # acquires to register and start a new research thread for this user.
1306 # Without holding it, a job could start in the gap between the check below
1307 # and the rekey actually happening, undetected by the check but still
1308 # racing the rekey. Holding the gate across both closes that TOCTOU window:
1309 # no research can start FOR THIS USER while we hold it.
1310 #
1311 # This is a PER-USER gate, NOT the process-global ``_lock`` (routes/globals.py):
1312 # the SQLCipher ``PRAGMA rekey`` can take seconds, and that global lock is
1313 # taken on every research progress/log update, so holding it across the
1314 # rekey would freeze EVERY user's active research for the duration. The
1315 # per-user gate blocks only this user's new research.
1316 #
1317 # RESIDUAL (concurrent same-user writers): the gate serialises the rekey
1318 # against this user's NEW research starts, and the active-research check
1319 # below rejects the rekey outright while this user has a research WORKER
1320 # running (so that worker's metrics/log writers are excluded too). It does
1321 # NOT serialise same-user background writers that open/write the user's
1322 # engine WITHOUT going through check_and_start_research — the news-scheduler
1323 # job (get_user_db_session) and the async log-drain
1324 # (_write_log_to_database). Those can still INSERT concurrently with the
1325 # PRAGMA rekey. Fully serialising them would require a per-user read/write
1326 # lock taken on the metrics/log hot path inside the database layer
1327 # (encrypted_db.py) — a cross-layer lock the web-layer gate can't provide
1328 # without a fragile import cycle, and a change with real deadlock/perf risk.
1329 # Given the narrow window (a manual, rare, single-user action) this is left
1330 # as a documented residual rather than hardened here.
1331 from ..routes.globals import (
1332 get_usernames_with_active_research,
1333 user_research_start_gate,
1334 )
1336 with user_research_start_gate(username):
1337 if username in get_usernames_with_active_research():
1338 # Flask signatures here (flash(msg, cat), render_template(name,
1339 # **ctx), and a `, 409` tuple return) came in with main's #5538
1340 # hunk. All three raise under FastAPI, so this rejection branch
1341 # 500'd instead of returning 409 -- the gate still serialised
1342 # correctly around the crash, but the user got no usable answer.
1343 flash(
1344 request,
1345 "Cannot change your password while research is in progress. "
1346 "Wait for it to finish or cancel it, then try again.",
1347 "error",
1348 )
1349 return render_template(
1350 request,
1351 "auth/change_password.html",
1352 {
1353 "password_requirements": PasswordValidator.get_requirements(),
1354 },
1355 status_code=409,
1356 )
1358 # Attempt password change (the rekey) — holds only this user's gate.
1359 outcome_indeterminate = False
1360 try:
1361 success = db_manager.change_password(
1362 username, current_password, new_password
1363 )
1364 except ValueError:
1365 # change_password validates both keys before it rekeys and
1366 # raises on an invalid one. The form checks above should have
1367 # caught anything a browser can send, so this is the backstop
1368 # that keeps a missed shape a 400 rather than a 500 with a
1369 # traceback. Nothing has been rekeyed on this path.
1370 #
1371 # The exception is not logged: its message is built from the
1372 # password fields this handler holds in plaintext, and the
1373 # repository's logging guard rejects an exception variable here
1374 # for exactly that reason. The fact and the user are what an
1375 # operator needs; the reason is one of the two guards in
1376 # change_password.
1377 logger.warning(
1378 f"Password change rejected for {_log_value_preview(username)}: the rekey path "
1379 "refused one of the submitted password fields"
1380 )
1381 flash(
1382 request,
1383 "Password change rejected: check the current and new password "
1384 "fields and try again.",
1385 "error",
1386 )
1387 return render_template(
1388 request,
1389 "auth/change_password.html",
1390 {
1391 "password_requirements": PasswordValidator.get_requirements(),
1392 },
1393 status_code=400,
1394 )
1395 except RekeyLockContentionError:
1396 # Another password change (or a gate holder) owns this user's
1397 # rekey lifecycle right now. Nothing was rekeyed here.
1398 logger.warning(
1399 f"Password change contended for {_log_value_preview(username)}: "
1400 "another rekey lifecycle owns the lock"
1401 )
1402 flash(
1403 request,
1404 "A password change is already in progress for this account. "
1405 "Please wait for it to finish and try again.",
1406 "error",
1407 )
1408 return render_template(
1409 request,
1410 "auth/change_password.html",
1411 {
1412 "password_requirements": PasswordValidator.get_requirements(),
1413 },
1414 status_code=409,
1415 )
1416 except RekeyIndeterminateError:
1417 # The rekey may have taken effect: the database layer could not
1418 # prove which key is active. Treat every old-password credential
1419 # as revoked exactly as on success (all sessions on every device,
1420 # both password stores, the scheduler's copy, live sockets and
1421 # this request's session), because the old password may no
1422 # longer be the key. The next login resolves the pending state
1423 # with whichever password actually opens the database, and that
1424 # recovery purges backups left under the superseded key
1425 # (DatabaseManager._purge_superseded_key_backups). No backup is
1426 # purged or refreshed here: neither key is proven.
1427 # Handled below, after this user's research-start gate is
1428 # released, like the success path.
1429 outcome_indeterminate = True
1430 success = False
1431 except RekeyOperationalError:
1432 # Unavailable (pre-ambiguity): the old key provably stayed
1433 # active and nothing was rekeyed, or another sidecar fault
1434 # stopped the change before it started. The user retries.
1435 logger.warning(
1436 f"Password change failed for {_log_value_preview(username)}: rekey outcome "
1437 "unavailable"
1438 )
1439 flash(
1440 request,
1441 "Password change could not be completed safely. The database "
1442 "was left fenced; please try again shortly or contact the "
1443 "administrator if this repeats.",
1444 "error",
1445 )
1446 return render_template(
1447 request,
1448 "auth/change_password.html",
1449 {
1450 "password_requirements": PasswordValidator.get_requirements(),
1451 },
1452 status_code=503,
1453 )
1455 if outcome_indeterminate:
1456 logger.warning(
1457 f"Password change outcome indeterminate for "
1458 f"{_log_value_preview(username)}: revoking all sessions; the "
1459 "next login resolves the pending rekey"
1460 )
1461 _revoke_user_credentials_after_rekey(request, username)
1462 flash(
1463 request,
1464 "Your password change may have completed. Try your NEW "
1465 "password first; if it does not work, use your old one.",
1466 "warning",
1467 )
1468 return RedirectResponse(url="/auth/login", status_code=302)
1470 if success:
1471 # The rekey is the ONLY step needed. The auth database stores no
1472 # password hash — login works by attempting to decrypt the user's
1473 # SQLCipher database. Do NOT add an auth-DB password-hash update
1474 # here; it would fail (User model has no set_password method) and
1475 # is architecturally unnecessary.
1477 # Clean up stale credentials before clearing session
1478 # (mirrors logout handler cleanup steps 1–5).
1480 # 1. No database close here. change_password() already detached and
1481 # disposed the old-password engine BEFORE the rekey, under the
1482 # user's rekey sidecar lock that every cold open also takes, so no
1483 # old-key engine can be cached once it returns True. The only
1484 # engine that can be cached by now is one a concurrent
1485 # open_user_database(new_password) published after the rekey
1486 # committed. That engine is valid, and closing it here would only
1487 # force the next request to cold-open again.
1489 # 2. Purge old backups (encrypted with old key) and create
1490 # a fresh backup with the new key. Old-key backups are a
1491 # security risk per NIST SP 800-57 / OWASP A02 — they remain
1492 # decryptable with the compromised password.
1493 try:
1494 from ...database.backup.backup_service import BackupService
1496 svc = BackupService(username=username, password=new_password)
1497 result = svc.purge_and_refresh()
1498 if result.success:
1499 logger.info(
1500 f"Backups refreshed after password change for {_log_value_preview(username)}"
1501 )
1502 else:
1503 logger.error(
1504 f"Post-password-change backup failed for {_log_value_preview(username)}: "
1505 f"{result.error}. Old backups were purged."
1506 )
1507 except Exception:
1508 logger.exception(
1509 f"Could not refresh backups after password change "
1510 f"for {_log_value_preview(username)}"
1511 )
1513 # 3. Revoke every old-password credential and session.
1514 _revoke_user_credentials_after_rekey(request, username)
1516 logger.info(f"Password changed for user {_log_value_preview(username)}")
1517 flash(
1518 request,
1519 "Password changed successfully. Please login with your new password.",
1520 "success",
1521 )
1522 return RedirectResponse(url="/auth/login", status_code=302)
1523 flash(request, "Current password is incorrect", "error")
1524 return render_template(
1525 request,
1526 "auth/change_password.html",
1527 {
1528 "password_requirements": PasswordValidator.get_requirements(),
1529 },
1530 status_code=401,
1531 )
1534@router.get("/integrity-check")
1535@limiter.limit(INTEGRITY_CHECK_RATE_LIMIT)
1536def integrity_check(
1537 request: Request, username: Annotated[str, Depends(require_auth)]
1538):
1539 """
1540 Check database integrity for current user.
1542 Uses Depends(require_auth) for the auth gate — single source of
1543 truth, consistent with every other authed endpoint. Rate-limited
1544 (unlike a plain lookup, this runs SQLite's full `integrity_check`
1545 scan, not the cheap `quick_check`) so a signed-in user can't
1546 re-trigger the expensive path back-to-back.
1547 """
1548 is_valid = db_manager.check_database_integrity(username)
1550 return {
1551 "username": username,
1552 "integrity": "valid" if is_valid else "corrupted",
1553 "timestamp": datetime.now(timezone.utc).isoformat(),
1554 }