Coverage for src/local_deep_research/web/auth/routes.py: 89%
368 statements
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
« prev ^ index » next coverage.py v7.15.1, created at 2026-07-20 01:24 +0000
1"""
2Authentication routes for login, register, and logout.
3Uses SQLCipher encrypted databases with browser password manager support.
4"""
6import threading
7import time
8from datetime import datetime, timezone, UTC
10from flask import (
11 Blueprint,
12 flash,
13 jsonify,
14 redirect,
15 render_template,
16 request,
17 session,
18 url_for,
19)
20from loguru import logger
22from ...database.auth_db import auth_db_session
23from ...database.encrypted_db import DatabaseInitializationError, db_manager
24from ...database.models.auth import User
25from ...database.thread_local_session import thread_cleanup
26from sqlalchemy.exc import IntegrityError
27from ...utilities.threading_utils import thread_context, thread_with_app_context
28from .session_manager import (
29 session_manager,
30) # singleton from session_manager module
31from ..server_config import load_server_config
32from ...security.rate_limiter import (
33 login_limit,
34 password_change_limit,
35 registration_limit,
36)
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
44auth_bp = Blueprint("auth", __name__, url_prefix="/auth")
47def _create_user_session(session, username: str, password: str, remember: bool):
48 """Create a new Flask session for ``username``.
50 Runs the common post-authentication sequence shared by login and
51 register: clear any prior session data (session-fixation defence),
52 create a server-side session, stash the username + remember flag,
53 store the password in the temp-auth and session-password stores for
54 post-login database access, and return the new session id.
56 The caller owns logging, flashing, and redirects — this helper does
57 only the data-layer work.
58 """
59 # Prevent session fixation by clearing old session data before creating new
60 session.clear()
62 session_id = None
63 try:
64 # Create session
65 session_id = session_manager.create_session(username, remember)
66 session["session_id"] = session_id
67 session["username"] = username
68 session.permanent = remember
70 # Store password temporarily for post-login database access
71 from ...database.temp_auth import temp_auth_store
73 auth_token = temp_auth_store.store_auth(username, password)
74 session["temp_auth_token"] = auth_token
76 # Also store in session password store for metrics access
77 from ...database.session_passwords import session_password_store
79 session_password_store.store_session_password(
80 username, session_id, password
81 )
82 except Exception:
83 # Roll back a partially-created session so the caller's error response
84 # doesn't leave the user half-logged-in: a partially-set Flask cookie
85 # (session_id/username already assigned) would otherwise persist on the
86 # caller's 500 and effectively log them in despite the reported
87 # failure. Clear the cookie and best-effort tear down the server-side
88 # session + password-store entry, then re-raise. Cleanup must not mask
89 # the original error.
90 session.clear()
91 if session_id is not None: 91 ↛ 96line 91 didn't jump to line 96 because the condition on line 91 was always true
92 try:
93 _cleanup_user_session(username, session_id=session_id)
94 except Exception:
95 logger.exception("Failed to roll back a partial user session")
96 raise
98 return session_id
101def _cleanup_user_session(
102 username: str, session_id=None, new_password=None
103) -> None:
104 """Tear down server-side session state for ``username``.
106 Parameterises the two cleanup scopes used by the auth routes:
108 * Single-session (logout) — pass ``session_id``: destroys just that
109 session and clears its password-store entry. The caller is
110 responsible for guarding on ``session_id`` being truthy.
111 * All-sessions (change_password) — omit ``session_id``: destroys
112 every session for the user and clears the whole password store.
114 When ``new_password`` is supplied (change_password path only), an
115 additional backup purge-and-refresh step runs so old-key encrypted
116 backups are replaced with ones encrypted under the new key.
118 The caller owns logging, flashing, redirects, scheduler unregister,
119 database close, per-user lock cleanup, and the final
120 ``session.clear()`` — this helper does only the session/password-store
121 layer plus the optional backup refresh.
122 """
123 if session_id is not None:
124 # Single-session scope (logout)
125 session_manager.destroy_session(session_id)
127 from ...database.session_passwords import session_password_store
129 session_password_store.clear_session(username, session_id)
130 else:
131 # All-sessions scope (change_password)
132 if new_password is not None: 132 ↛ 157line 132 didn't jump to line 157 because the condition on line 132 was always true
133 # Purge old backups (encrypted with old key) and create
134 # a fresh backup with the new key. Old-key backups are a
135 # security risk per NIST SP 800-57 / OWASP A02 — they remain
136 # decryptable with the compromised password.
137 try:
138 from ...database.backup.backup_service import BackupService
140 svc = BackupService(username=username, password=new_password)
141 result = svc.purge_and_refresh()
142 if result.success: 142 ↛ 143line 142 didn't jump to line 143 because the condition on line 142 was never true
143 logger.info(
144 f"Backups refreshed after password change for {username}"
145 )
146 else:
147 logger.error(
148 f"Post-password-change backup failed for {username}: "
149 f"{result.error}. Old backups were purged."
150 )
151 except Exception:
152 logger.exception(
153 f"Could not refresh backups after password change "
154 f"for {username}"
155 )
157 session_manager.destroy_all_user_sessions(username)
159 from ...database.session_passwords import session_password_store
161 session_password_store.clear_all_for_user(username)
164@auth_bp.route("/csrf-token", methods=["GET"])
165def get_csrf_token():
166 """
167 Get CSRF token for API requests.
168 Returns the current CSRF token for the session.
169 This endpoint makes it easy for API clients to get the CSRF token
170 programmatically without parsing HTML.
171 """
172 from flask_wtf.csrf import generate_csrf
174 # Generate or get existing CSRF token for this session
175 token = generate_csrf()
177 return jsonify({"csrf_token": token}), 200
180@auth_bp.route("/login", methods=["GET"])
181def login_page():
182 """
183 Login page (GET only).
184 Not rate limited - viewing the page should always work.
185 """
186 config = load_server_config()
187 # Check if already logged in
188 if session.get("username"):
189 return redirect(url_for("index"))
191 # Preserve the next parameter for post-login redirect
192 next_page = request.args.get("next", "")
194 return render_template(
195 "auth/login.html",
196 has_encryption=db_manager.has_encryption,
197 allow_registrations=config.get("allow_registrations", True),
198 next_page=next_page,
199 )
202@auth_bp.route("/login", methods=["POST"])
203@login_limit
204def login():
205 """
206 Login handler (POST only).
207 Rate limited to 5 attempts per 15 minutes per IP to prevent brute force attacks.
208 """
209 config = load_server_config()
210 # POST - Handle login
211 username = request.form.get("username", "").strip()
212 password = request.form.get("password", "")
213 remember = request.form.get("remember", "false") == "true"
215 if not username or not password:
216 flash("Username and password are required", "error")
217 return render_template(
218 "auth/login.html",
219 has_encryption=db_manager.has_encryption,
220 allow_registrations=config.get("allow_registrations", True),
221 ), 400
223 # Check account lockout before attempting credential verification
224 lockout_mgr = get_account_lockout_manager()
225 if lockout_mgr.is_locked(username): 225 ↛ 226line 225 didn't jump to line 226 because the condition on line 225 was never true
226 logger.warning(
227 f"Login attempt for locked account: {sanitize_for_log(username)}"
228 )
229 flash("Account is temporarily locked. Please try again later.", "error")
230 return render_template(
231 "auth/login.html",
232 has_encryption=db_manager.has_encryption,
233 allow_registrations=config.get("allow_registrations", True),
234 ), 429
236 # Try to open user's encrypted database. Two distinct failure modes:
237 # - return None → credentials invalid OR DB missing → 401, count toward lockout
238 # - raise DatabaseInitializationError → credentials valid but schema
239 # can't be brought up (e.g. world-writable migrations dir tripping
240 # the alembic_runner permission check) → 503, do NOT count toward
241 # lockout. The user's password is correct; punishing them with a
242 # lockout for a server-side configuration problem would be wrong.
243 try:
244 engine = db_manager.open_user_database(username, password)
245 except DatabaseInitializationError:
246 logger.warning(
247 f"Login refused for {sanitize_for_log(username)}: "
248 "database initialisation failed (see traceback above). "
249 "Lockout counter NOT incremented — credentials are valid."
250 )
251 flash(
252 "Database initialisation failed. The server is misconfigured — "
253 "please check the server logs or contact the administrator.",
254 "error",
255 )
256 return render_template(
257 "auth/login.html",
258 has_encryption=db_manager.has_encryption,
259 allow_registrations=config.get("allow_registrations", True),
260 ), 503
262 if engine is None:
263 # Invalid credentials or database doesn't exist
264 lockout_mgr.record_failure(username)
265 logger.warning(
266 f"Failed login attempt for username: {sanitize_for_log(username)}"
267 )
268 flash("Invalid username or password", "error")
269 return render_template(
270 "auth/login.html",
271 has_encryption=db_manager.has_encryption,
272 allow_registrations=config.get("allow_registrations", True),
273 ), 401
275 # Success — clear any prior failure count
276 lockout_mgr.record_success(username)
278 # Create session (clears old data, creates server-side session,
279 # stashes credentials for post-login DB access).
280 _create_user_session(session, username, password, remember)
282 logger.info(f"User {username} logged in successfully")
284 # Defer non-critical post-login work to a background thread so the
285 # redirect returns immediately (settings migration, library init,
286 # news scheduler notify, and backup scheduling are all idempotent
287 # and can safely run after the response).
288 app_ctx = thread_context()
289 thread = threading.Thread(
290 target=thread_with_app_context(_perform_post_login_tasks),
291 args=(app_ctx, username, password),
292 daemon=True,
293 )
294 thread.start()
296 next_page = request.args.get("next", "")
297 safe_path = URLValidator.get_safe_redirect_path(next_page, request.host_url)
298 if safe_path:
299 safe_path = safe_path.replace("\\", "/")
300 parsed = urlparse(safe_path)
301 if not parsed.scheme and not parsed.netloc: 301 ↛ 303line 301 didn't jump to line 303 because the condition on line 301 was always true
302 return redirect(safe_path)
303 return redirect(url_for("index"))
306@thread_cleanup
307def _perform_post_login_tasks(username: str, password: str) -> None:
308 """Run non-critical post-login operations in a background thread.
310 Each operation is wrapped in its own try/except so that one failure
311 does not prevent the others from running. All operations here are
312 idempotent and safe to retry on the next login.
314 An outer try/except wraps the whole body so any exception that
315 escapes the per-step handlers (for example a failure inside a
316 ``with`` context manager's __enter__ / __exit__) is logged loudly
317 with a traceback instead of dying silently in the daemon thread.
318 """
319 try:
320 _perform_post_login_tasks_body(username, password)
321 except Exception:
322 logger.exception(
323 f"Post-login background thread crashed for user {username}"
324 )
327def _perform_post_login_tasks_body(username: str, password: str) -> None:
328 """Body of _perform_post_login_tasks — split out so the outer
329 try/except in the wrapper catches anything the per-step handlers
330 miss. See _perform_post_login_tasks for rationale."""
331 total_start = time.perf_counter()
333 # 1. Settings version check + migration
334 #
335 # ATOMICITY INVARIANT: the defaults import and the `app.version`
336 # marker MUST be written in one `get_user_db_session(...)` scope
337 # with a single terminal `db_session.commit()`. SQLite WAL rollback
338 # then guarantees either both land or neither does — the only
339 # acceptable states for `db_version_matches_package()` to behave
340 # correctly on the next login. Splitting into two commits regresses
341 # to the "sticky loop": `app.version` stays unwritten, every
342 # subsequent login re-runs the ~498-row bulk insert (app.version is
343 # not in default_settings.json, only `update_db_version()` writes
344 # it). Do not factor these calls into separate sessions or allow
345 # `load_from_defaults_file`/`update_db_version` to commit internally
346 # here — both must be called with `commit=False`.
347 step_start = time.perf_counter()
348 try:
349 from ...settings.manager import SettingsManager
350 from ...database.session_context import get_user_db_session
352 with get_user_db_session(username, password) as db_session:
353 settings_manager = SettingsManager(db_session)
354 if not settings_manager.db_version_matches_package(): 354 ↛ 355line 354 didn't jump to line 355 because the condition on line 354 was never true
355 logger.info(
356 f"Database version mismatch for {username} "
357 "- loading missing default settings"
358 )
359 settings_manager.load_from_defaults_file(
360 commit=False, overwrite=False
361 )
362 settings_manager.update_db_version(commit=False)
363 db_session.commit()
364 logger.info(
365 f"Missing default settings loaded and version "
366 f"updated for user {username}"
367 )
368 except Exception:
369 logger.exception(f"Post-login settings migration failed for {username}")
370 _log_step_duration("step 1 (settings version check)", step_start, username)
372 # 2. Initialize library system (source types and default collection)
373 step_start = time.perf_counter()
374 try:
375 from ...database.library_init import initialize_library_for_user
377 init_results = initialize_library_for_user(username, password)
378 if init_results.get("success"):
379 logger.info(f"Library system initialized for user {username}")
380 else:
381 logger.warning(
382 f"Library initialization issue for {username}: "
383 f"{init_results.get('error', 'Unknown error')}"
384 )
385 except Exception:
386 logger.exception(f"Post-login library init failed for {username}")
387 _log_step_duration("step 2 (library init)", step_start, username)
389 # 3. Update last_login in auth DB + notify news scheduler
390 step_start = time.perf_counter()
391 try:
392 with auth_db_session() as auth_db:
393 user = auth_db.query(User).filter_by(username=username).first()
394 if user:
395 user.last_login = datetime.now(UTC)
397 try:
398 from ...scheduler.background import (
399 get_background_job_scheduler,
400 )
402 scheduler = get_background_job_scheduler()
403 if scheduler.is_running:
404 scheduler.update_user_info(username, password)
405 logger.info(
406 f"Updated scheduler with user info for {username}"
407 )
408 except Exception:
409 logger.exception("Could not update scheduler on login")
411 auth_db.commit()
412 except Exception:
413 logger.exception(f"Post-login auth DB update failed for {username}")
414 _log_step_duration(
415 "step 3 (auth DB + scheduler notify)", step_start, username
416 )
418 # Model cache refresh is handled by /api/settings/available-models
419 # via its 24h TTL and explicit force_refresh=true flag.
421 # 4. Schedule background database backup if enabled
422 step_start = time.perf_counter()
423 try:
424 from ...database.backup import get_backup_executor
425 from ...settings.manager import SettingsManager
426 from ...database.session_context import get_user_db_session
428 with get_user_db_session(username, password) as db_session:
429 sm = SettingsManager(db_session)
430 backup_enabled = sm.get_setting("backup.enabled", True)
432 if backup_enabled: 432 ↛ 442line 432 didn't jump to line 442
433 max_backups = sm.get_setting("backup.max_count", 1)
434 max_age_days = sm.get_setting("backup.max_age_days", 7)
436 get_backup_executor().submit_backup(
437 username, password, max_backups, max_age_days
438 )
439 logger.info(f"Background backup scheduled for user {username}")
440 except Exception:
441 logger.exception(f"Post-login backup scheduling failed for {username}")
442 _log_step_duration("step 4 (schedule backup)", step_start, username)
444 total_ms = (time.perf_counter() - total_start) * 1000
445 if total_ms > 1000:
446 logger.info(
447 f"Post-login tasks completed for user {username} "
448 f"(total: {total_ms:.0f}ms)"
449 )
450 else:
451 logger.info(
452 f"Post-login tasks completed for user {username} ({total_ms:.0f}ms)"
453 )
456def _log_step_duration(step_label: str, start: float, username: str) -> None:
457 """Log post-login step duration at INFO if > 100ms, else DEBUG."""
458 elapsed_ms = (time.perf_counter() - start) * 1000
459 if elapsed_ms > 100:
460 logger.info(
461 f"Post-login {step_label} for {username} took {elapsed_ms:.0f}ms"
462 )
463 else:
464 logger.debug(
465 f"Post-login {step_label} for {username} took {elapsed_ms:.0f}ms"
466 )
469@auth_bp.route("/validate-password", methods=["POST"])
470def validate_password():
471 """Validate password strength via API (used by client-side forms)."""
472 password = request.form.get("password", "")
473 errors = PasswordValidator.validate_strength(password)
474 return jsonify({"valid": len(errors) == 0, "errors": errors})
477@auth_bp.route("/register", methods=["GET"])
478def register_page():
479 """
480 Registration page (GET only).
481 Not rate limited - viewing the page should always work.
482 """
483 config = load_server_config()
484 if not config.get("allow_registrations", True):
485 flash("New user registrations are currently disabled.", "error")
486 return redirect(url_for("auth.login_page"))
488 return render_template(
489 "auth/register.html",
490 has_encryption=db_manager.has_encryption,
491 password_requirements=PasswordValidator.get_requirements(),
492 )
495@auth_bp.route("/register", methods=["POST"])
496@registration_limit
497def register():
498 """
499 Registration handler (POST only).
500 Creates new encrypted database for user with clear warnings about password recovery.
501 Rate limited to 3 attempts per hour per IP to prevent registration spam.
502 """
503 config = load_server_config()
504 if not config.get("allow_registrations", True):
505 flash("New user registrations are currently disabled.", "error")
506 return redirect(url_for("auth.login_page"))
508 # POST - Handle registration
509 username = request.form.get("username", "").strip()
510 password = request.form.get("password", "")
511 confirm_password = request.form.get("confirm_password", "")
512 acknowledge = request.form.get("acknowledge", "false") == "true"
514 # Validation
515 errors = []
517 if not username:
518 errors.append("Username is required")
519 elif len(username) < 3:
520 errors.append("Username must be at least 3 characters")
521 elif not username.replace("_", "").replace("-", "").isalnum():
522 errors.append(
523 "Username can only contain letters, numbers, underscores, and hyphens"
524 )
526 if not password:
527 errors.append("Password is required")
528 else:
529 errors.extend(PasswordValidator.validate_strength(password))
531 if password != confirm_password:
532 errors.append("Passwords do not match")
534 if not acknowledge:
535 errors.append(
536 "You must acknowledge that password recovery is not possible"
537 )
539 # Check if user already exists
540 # Use generic error message to prevent account enumeration
541 # Note: While this creates a minor timing difference, it's acceptable because:
542 # 1. Rate limiting prevents automated timing analysis
543 # 2. Generic error message prevents content-based enumeration
544 # 3. Local database query timing is minimal (no network calls)
545 # 4. Better UX with immediate feedback outweighs minor timing risk
546 # See: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
547 if not errors and username and db_manager.user_exists(username):
548 errors.append("Registration failed. Please try a different username.")
550 if errors:
551 for error in errors:
552 flash(error, "error")
553 return render_template(
554 "auth/register.html",
555 has_encryption=db_manager.has_encryption,
556 password_requirements=PasswordValidator.get_requirements(),
557 ), 400
559 # Create user in auth database
560 with auth_db_session() as auth_db:
561 try:
562 new_user = User(username=username)
563 auth_db.add(new_user)
564 auth_db.flush()
565 # Capture the PK after flush (the INSERT has run and assigned the
566 # id) but before commit: reading it here needs no post-commit
567 # refresh SELECT (expire_on_commit), and if the commit below fails
568 # nothing is persisted, so there is no orphan to clean up. Cleanup
569 # deletes by this id rather than re-querying by username, which
570 # could race a concurrent registration reusing the same username.
571 new_user_id = new_user.id
572 auth_db.commit()
573 except IntegrityError:
574 # Catch duplicate username specifically (race condition case)
575 # This handles the edge case where two requests for the same username
576 # pass the user_exists() check simultaneously
577 logger.warning(f"Duplicate username attempted: {username}")
578 auth_db.rollback()
579 flash(
580 "Registration failed. Please try a different username.", "error"
581 )
582 return render_template(
583 "auth/register.html",
584 has_encryption=db_manager.has_encryption,
585 password_requirements=PasswordValidator.get_requirements(),
586 ), 400
587 except Exception:
588 logger.exception(
589 f"Registration failed for {username} while creating the "
590 f"auth record"
591 )
592 auth_db.rollback()
593 flash("Registration failed. Please try again.", "error")
594 return render_template(
595 "auth/register.html",
596 has_encryption=db_manager.has_encryption,
597 password_requirements=PasswordValidator.get_requirements(),
598 ), 500
600 # Create encrypted database for user.
601 #
602 # This is the only step that can leave an orphaned auth row: the User
603 # row is already committed, but no encrypted DB exists yet, so the
604 # username is taken (blocking re-registration via user_exists()) while
605 # login also fails (no DB to open) — a permanently bricked account.
606 # The cleanup scope is intentionally narrow: later failures (session
607 # setup, library init) must NOT delete the auth row, because by then
608 # the user DB exists and the account is legitimate.
609 try:
610 db_manager.create_user_database(username, password)
611 except Exception:
612 logger.exception(
613 f"Registration failed for {username} while creating the "
614 f"encrypted database"
615 )
616 # Delete the orphaned auth row by primary key in a fresh session
617 # (the original auth_db session has already closed). Deleting by ID
618 # avoids racing a concurrent registration that re-uses the username.
619 try:
620 with auth_db_session() as cleanup_db:
621 orphaned_user = (
622 cleanup_db.query(User).filter_by(id=new_user_id).first()
623 )
624 if orphaned_user is not None: 624 ↛ 636line 624 didn't jump to line 636
625 cleanup_db.delete(orphaned_user)
626 cleanup_db.commit()
627 logger.info(
628 f"Cleaned up orphaned auth entry for {username}"
629 )
630 except Exception:
631 # Surface a stuck orphan so it's diagnosable, but don't mask the
632 # original registration failure returned to the user.
633 logger.exception(
634 f"Failed to clean up orphaned auth entry for {username}"
635 )
636 flash("Registration failed. Please try again.", "error")
637 return render_template(
638 "auth/register.html",
639 has_encryption=db_manager.has_encryption,
640 password_requirements=PasswordValidator.get_requirements(),
641 ), 500
643 try:
644 # Auto-login after registration (remember=False: fresh
645 # registrations should not persist as "remember me" sessions).
646 _create_user_session(session, username, password, remember=False)
648 # Notify the news scheduler about the new user
649 try:
650 from ...scheduler.background import (
651 get_background_job_scheduler,
652 )
654 scheduler = get_background_job_scheduler()
655 if scheduler.is_running: 655 ↛ 663line 655 didn't jump to line 663 because the condition on line 655 was always true
656 scheduler.update_user_info(username, password)
657 logger.info(
658 f"Updated scheduler with new user info for {username}"
659 )
660 except Exception:
661 logger.exception("Could not update scheduler on registration")
663 logger.info(f"New user registered: {username}")
665 # Initialize library system (source types and default collection)
666 from ...database.library_init import initialize_library_for_user
668 try:
669 init_results = initialize_library_for_user(username, password)
670 if init_results.get("success"): 670 ↛ 675line 670 didn't jump to line 675 because the condition on line 670 was always true
671 logger.info(
672 f"Library system initialized for new user {username}"
673 )
674 else:
675 logger.warning(
676 f"Library initialization issue for {username}: {init_results.get('error', 'Unknown error')}"
677 )
678 except Exception:
679 logger.exception(
680 f"Error initializing library for new user {username}"
681 )
682 # Don't block registration on library init failure
684 return redirect(url_for("index"))
686 except Exception:
687 # The encrypted DB already exists at this point, so this failure is in
688 # post-creation setup (auto-login / session). The account is created
689 # and loginable; do NOT clean up the auth row here (narrow scope).
690 logger.exception(
691 f"Registration failed for {username} after database creation "
692 f"(post-creation session/login setup)"
693 )
694 flash("Registration failed. Please try again.", "error")
695 return render_template(
696 "auth/register.html",
697 has_encryption=db_manager.has_encryption,
698 password_requirements=PasswordValidator.get_requirements(),
699 ), 500
702@auth_bp.route("/logout", methods=["POST"])
703def logout():
704 """
705 Logout handler.
706 Clears session and closes database connections.
707 POST-only to prevent CSRF-triggered logout via GET (e.g. <img src="/auth/logout">).
708 """
709 username = session.get("username")
710 session_id = session.get("session_id")
712 if username:
713 # LOGOUT CLEANUP ORDER (order matters):
714 # 1. Unregister from news scheduler — removes password from scheduler's
715 # user_sessions dict and cancels scheduled jobs. Must happen BEFORE
716 # close_user_database() because: scheduler jobs fetch the password
717 # from user_sessions at runtime to call open_user_database(). If we
718 # close the DB first, a running job that already has the password
719 # can re-create the engine. Removing the password first ensures
720 # future job invocations can't authenticate.
721 # Note: a narrow race remains — a job that already fetched the
722 # password (but hasn't called open_user_database yet) can still
723 # recreate an engine. This is benign: the dead-thread sweep will
724 # clean it up within 60 seconds.
725 # 2. Close database connection — disposes QueuePool engine and cleans
726 # up thread engines for this user.
727 # 3. Destroy Flask session — invalidates session token.
728 # 4. Clear session password store — removes password from secondary store.
729 # 5. Clear Flask session dict — removes all session data.
730 try:
731 from ...scheduler.background import (
732 get_background_job_scheduler,
733 )
735 sched = get_background_job_scheduler()
736 if sched.is_running:
737 sched.unregister_user(username)
738 except Exception:
739 logger.warning("Could not unregister user from scheduler")
741 # Close database connection
742 db_manager.close_user_database(username)
744 # Drop per-user lock-dict entries (library-init, backup,
745 # queue-processor critical sections). Matches the cleanup
746 # done by the idle-connection sweeper; without this, those
747 # three module-level dicts accumulate one entry per username
748 # across the process lifetime.
749 from .connection_cleanup import _pop_per_user_locks
751 _pop_per_user_locks(username)
753 # Clear session
754 if session_id: 754 ↛ 757line 754 didn't jump to line 757 because the condition on line 754 was always true
755 _cleanup_user_session(username, session_id=session_id)
757 session.clear()
759 logger.info(f"User {username} logged out")
760 flash("You have been logged out successfully", "info")
762 return redirect(url_for("auth.login"))
765@auth_bp.route("/check", methods=["GET"])
766def check_auth():
767 """
768 Check if user is authenticated (for AJAX requests).
769 """
770 if session.get("username"):
771 return jsonify({"authenticated": True, "username": session["username"]})
772 return jsonify({"authenticated": False}), 401
775@auth_bp.route("/change-password", methods=["GET"])
776def change_password_page():
777 """
778 Change password page (GET only).
779 Not rate limited - viewing the page should always work.
780 """
781 username = session.get("username")
782 if not username:
783 return redirect(url_for("auth.login"))
785 return render_template(
786 "auth/change_password.html",
787 password_requirements=PasswordValidator.get_requirements(),
788 )
791@auth_bp.route("/change-password", methods=["POST"])
792@password_change_limit
793def change_password():
794 """
795 Change password handler (POST only).
796 Requires current password and re-encrypts database.
797 Rate limited to prevent brute-force of current password.
798 """
799 username = session.get("username")
800 if not username: 800 ↛ 801line 800 didn't jump to line 801 because the condition on line 800 was never true
801 return redirect(url_for("auth.login"))
803 # POST - Handle password change
804 current_password = request.form.get("current_password", "")
805 new_password = request.form.get("new_password", "")
806 confirm_password = request.form.get("confirm_password", "")
808 # Validation
809 errors = []
811 if not current_password:
812 errors.append("Current password is required")
814 if not new_password: 814 ↛ 815line 814 didn't jump to line 815 because the condition on line 814 was never true
815 errors.append("New password is required")
816 else:
817 errors.extend(PasswordValidator.validate_strength(new_password))
819 if new_password != confirm_password:
820 errors.append("New passwords do not match")
822 if current_password == new_password:
823 errors.append("New password must be different from current password")
825 if errors:
826 for error in errors:
827 flash(error, "error")
828 return render_template(
829 "auth/change_password.html",
830 password_requirements=PasswordValidator.get_requirements(),
831 ), 400
833 # Attempt password change
834 success = db_manager.change_password(
835 username, current_password, new_password
836 )
838 if success:
839 # The rekey is the ONLY step needed. The auth database stores no
840 # password hash — login works by attempting to decrypt the user's
841 # SQLCipher database. Do NOT add an auth-DB password-hash update
842 # here; it would fail (User model has no set_password method) and
843 # is architecturally unnecessary.
845 # Clean up stale credentials before clearing session
846 # (mirrors logout handler cleanup steps 1–5).
848 # 1. Unregister from scheduler (removes stale credential)
849 try:
850 from ...scheduler.background import (
851 get_background_job_scheduler,
852 )
854 sched = get_background_job_scheduler()
855 if sched.is_running: 855 ↛ 856line 855 didn't jump to line 856 because the condition on line 855 was never true
856 sched.unregister_user(username)
857 except Exception:
858 logger.warning(
859 "Could not unregister user from scheduler",
860 )
862 # 2. Close database connection (disposes old-password engine)
863 # change_password() already closes in its finally block, but
864 # an explicit close here is defensive — harmless if redundant.
865 db_manager.close_user_database(username)
867 # 2a. Drop per-user lock-dict entries (matches logout path).
868 from .connection_cleanup import _pop_per_user_locks
870 _pop_per_user_locks(username)
872 # 3. Destroy ALL sessions for this user + clear password store,
873 # and refresh backups encrypted under the new key (old-key
874 # backups are a security risk — see _cleanup_user_session).
875 _cleanup_user_session(
876 username, session_id=None, new_password=new_password
877 )
879 # 4. Clear Flask session dict
880 session.clear()
882 logger.info(f"Password changed for user {username}")
883 flash(
884 "Password changed successfully. Please login with your new password.",
885 "success",
886 )
887 return redirect(url_for("auth.login"))
888 flash("Current password is incorrect", "error")
889 return render_template(
890 "auth/change_password.html",
891 password_requirements=PasswordValidator.get_requirements(),
892 ), 401
895@auth_bp.route("/integrity-check", methods=["GET"])
896def integrity_check():
897 """
898 Check database integrity for current user.
899 """
900 username = session.get("username")
901 if not username:
902 return jsonify({"error": "Not authenticated"}), 401
904 is_valid = db_manager.check_database_integrity(username)
906 return jsonify(
907 {
908 "username": username,
909 "integrity": "valid" if is_valid else "corrupted",
910 "timestamp": datetime.now(timezone.utc).isoformat(),
911 }
912 )