Coverage for src/local_deep_research/web/auth/routes.py: 90%
342 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-03 23:15 +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")
47@auth_bp.route("/csrf-token", methods=["GET"])
48def get_csrf_token():
49 """
50 Get CSRF token for API requests.
51 Returns the current CSRF token for the session.
52 This endpoint makes it easy for API clients to get the CSRF token
53 programmatically without parsing HTML.
54 """
55 from flask_wtf.csrf import generate_csrf
57 # Generate or get existing CSRF token for this session
58 token = generate_csrf()
60 return jsonify({"csrf_token": token}), 200
63@auth_bp.route("/login", methods=["GET"])
64def login_page():
65 """
66 Login page (GET only).
67 Not rate limited - viewing the page should always work.
68 """
69 config = load_server_config()
70 # Check if already logged in
71 if session.get("username"):
72 return redirect(url_for("index"))
74 # Preserve the next parameter for post-login redirect
75 next_page = request.args.get("next", "")
77 return render_template(
78 "auth/login.html",
79 has_encryption=db_manager.has_encryption,
80 allow_registrations=config.get("allow_registrations", True),
81 next_page=next_page,
82 )
85@auth_bp.route("/login", methods=["POST"])
86@login_limit
87def login():
88 """
89 Login handler (POST only).
90 Rate limited to 5 attempts per 15 minutes per IP to prevent brute force attacks.
91 """
92 config = load_server_config()
93 # POST - Handle login
94 username = request.form.get("username", "").strip()
95 password = request.form.get("password", "")
96 remember = request.form.get("remember", "false") == "true"
98 if not username or not password:
99 flash("Username and password are required", "error")
100 return render_template(
101 "auth/login.html",
102 has_encryption=db_manager.has_encryption,
103 allow_registrations=config.get("allow_registrations", True),
104 ), 400
106 # Check account lockout before attempting credential verification
107 lockout_mgr = get_account_lockout_manager()
108 if lockout_mgr.is_locked(username): 108 ↛ 109line 108 didn't jump to line 109 because the condition on line 108 was never true
109 logger.warning(
110 f"Login attempt for locked account: {sanitize_for_log(username)}"
111 )
112 flash("Account is temporarily locked. Please try again later.", "error")
113 return render_template(
114 "auth/login.html",
115 has_encryption=db_manager.has_encryption,
116 allow_registrations=config.get("allow_registrations", True),
117 ), 429
119 # Try to open user's encrypted database. Two distinct failure modes:
120 # - return None → credentials invalid OR DB missing → 401, count toward lockout
121 # - raise DatabaseInitializationError → credentials valid but schema
122 # can't be brought up (e.g. world-writable migrations dir tripping
123 # the alembic_runner permission check) → 503, do NOT count toward
124 # lockout. The user's password is correct; punishing them with a
125 # lockout for a server-side configuration problem would be wrong.
126 try:
127 engine = db_manager.open_user_database(username, password)
128 except DatabaseInitializationError:
129 logger.warning(
130 f"Login refused for {sanitize_for_log(username)}: "
131 "database initialisation failed (see traceback above). "
132 "Lockout counter NOT incremented — credentials are valid."
133 )
134 flash(
135 "Database initialisation failed. The server is misconfigured — "
136 "please check the server logs or contact the administrator.",
137 "error",
138 )
139 return render_template(
140 "auth/login.html",
141 has_encryption=db_manager.has_encryption,
142 allow_registrations=config.get("allow_registrations", True),
143 ), 503
145 if engine is None:
146 # Invalid credentials or database doesn't exist
147 lockout_mgr.record_failure(username)
148 logger.warning(
149 f"Failed login attempt for username: {sanitize_for_log(username)}"
150 )
151 flash("Invalid username or password", "error")
152 return render_template(
153 "auth/login.html",
154 has_encryption=db_manager.has_encryption,
155 allow_registrations=config.get("allow_registrations", True),
156 ), 401
158 # Success — clear any prior failure count
159 lockout_mgr.record_success(username)
161 # Prevent session fixation by clearing old session data before creating new
162 session.clear()
164 # Create session
165 session_id = session_manager.create_session(username, remember)
166 session["session_id"] = session_id
167 session["username"] = username
168 session.permanent = remember
170 # Store password temporarily for post-login database access
171 from ...database.temp_auth import temp_auth_store
173 auth_token = temp_auth_store.store_auth(username, password)
174 session["temp_auth_token"] = auth_token
176 # Also store in session password store for metrics access
177 from ...database.session_passwords import session_password_store
179 session_password_store.store_session_password(
180 username, session_id, password
181 )
183 logger.info(f"User {username} logged in successfully")
185 # Defer non-critical post-login work to a background thread so the
186 # redirect returns immediately (settings migration, library init,
187 # news scheduler notify, and backup scheduling are all idempotent
188 # and can safely run after the response).
189 app_ctx = thread_context()
190 thread = threading.Thread(
191 target=thread_with_app_context(_perform_post_login_tasks),
192 args=(app_ctx, username, password),
193 daemon=True,
194 )
195 thread.start()
197 next_page = request.args.get("next", "")
198 safe_path = URLValidator.get_safe_redirect_path(next_page, request.host_url)
199 if safe_path:
200 safe_path = safe_path.replace("\\", "/")
201 parsed = urlparse(safe_path)
202 if not parsed.scheme and not parsed.netloc: 202 ↛ 204line 202 didn't jump to line 204 because the condition on line 202 was always true
203 return redirect(safe_path)
204 return redirect(url_for("index"))
207@thread_cleanup
208def _perform_post_login_tasks(username: str, password: str) -> None:
209 """Run non-critical post-login operations in a background thread.
211 Each operation is wrapped in its own try/except so that one failure
212 does not prevent the others from running. All operations here are
213 idempotent and safe to retry on the next login.
215 An outer try/except wraps the whole body so any exception that
216 escapes the per-step handlers (for example a failure inside a
217 ``with`` context manager's __enter__ / __exit__) is logged loudly
218 with a traceback instead of dying silently in the daemon thread.
219 """
220 try:
221 _perform_post_login_tasks_body(username, password)
222 except Exception:
223 logger.exception(
224 f"Post-login background thread crashed for user {username}"
225 )
228def _perform_post_login_tasks_body(username: str, password: str) -> None:
229 """Body of _perform_post_login_tasks — split out so the outer
230 try/except in the wrapper catches anything the per-step handlers
231 miss. See _perform_post_login_tasks for rationale."""
232 total_start = time.perf_counter()
234 # 1. Settings version check + migration
235 #
236 # ATOMICITY INVARIANT: the defaults import and the `app.version`
237 # marker MUST be written in one `get_user_db_session(...)` scope
238 # with a single terminal `db_session.commit()`. SQLite WAL rollback
239 # then guarantees either both land or neither does — the only
240 # acceptable states for `db_version_matches_package()` to behave
241 # correctly on the next login. Splitting into two commits regresses
242 # to the "sticky loop": `app.version` stays unwritten, every
243 # subsequent login re-runs the ~498-row bulk insert (app.version is
244 # not in default_settings.json, only `update_db_version()` writes
245 # it). Do not factor these calls into separate sessions or allow
246 # `load_from_defaults_file`/`update_db_version` to commit internally
247 # here — both must be called with `commit=False`.
248 step_start = time.perf_counter()
249 try:
250 from ...settings.manager import SettingsManager
251 from ...database.session_context import get_user_db_session
253 with get_user_db_session(username, password) as db_session:
254 settings_manager = SettingsManager(db_session)
255 if not settings_manager.db_version_matches_package():
256 logger.info(
257 f"Database version mismatch for {username} "
258 "- loading missing default settings"
259 )
260 settings_manager.load_from_defaults_file(
261 commit=False, overwrite=False
262 )
263 settings_manager.update_db_version(commit=False)
264 db_session.commit()
265 logger.info(
266 f"Missing default settings loaded and version "
267 f"updated for user {username}"
268 )
269 except Exception:
270 logger.exception(f"Post-login settings migration failed for {username}")
271 _log_step_duration("step 1 (settings version check)", step_start, username)
273 # 2. Initialize library system (source types and default collection)
274 step_start = time.perf_counter()
275 try:
276 from ...database.library_init import initialize_library_for_user
278 init_results = initialize_library_for_user(username, password)
279 if init_results.get("success"):
280 logger.info(f"Library system initialized for user {username}")
281 else:
282 logger.warning(
283 f"Library initialization issue for {username}: "
284 f"{init_results.get('error', 'Unknown error')}"
285 )
286 except Exception:
287 logger.exception(f"Post-login library init failed for {username}")
288 _log_step_duration("step 2 (library init)", step_start, username)
290 # 3. Update last_login in auth DB + notify news scheduler
291 step_start = time.perf_counter()
292 try:
293 with auth_db_session() as auth_db:
294 user = auth_db.query(User).filter_by(username=username).first()
295 if user:
296 user.last_login = datetime.now(UTC)
298 try:
299 from ...scheduler.background import (
300 get_background_job_scheduler,
301 )
303 scheduler = get_background_job_scheduler()
304 if scheduler.is_running:
305 scheduler.update_user_info(username, password)
306 logger.info(
307 f"Updated scheduler with user info for {username}"
308 )
309 except Exception:
310 logger.exception("Could not update scheduler on login")
312 auth_db.commit()
313 except Exception:
314 logger.exception(f"Post-login auth DB update failed for {username}")
315 _log_step_duration(
316 "step 3 (auth DB + scheduler notify)", step_start, username
317 )
319 # Model cache refresh is handled by /api/settings/available-models
320 # via its 24h TTL and explicit force_refresh=true flag.
322 # 4. Schedule background database backup if enabled
323 step_start = time.perf_counter()
324 try:
325 from ...database.backup import get_backup_executor
326 from ...settings.manager import SettingsManager
327 from ...database.session_context import get_user_db_session
329 with get_user_db_session(username, password) as db_session:
330 sm = SettingsManager(db_session)
331 backup_enabled = sm.get_setting("backup.enabled", True)
333 if backup_enabled: 333 ↛ 343line 333 didn't jump to line 343
334 max_backups = sm.get_setting("backup.max_count", 1)
335 max_age_days = sm.get_setting("backup.max_age_days", 7)
337 get_backup_executor().submit_backup(
338 username, password, max_backups, max_age_days
339 )
340 logger.info(f"Background backup scheduled for user {username}")
341 except Exception:
342 logger.exception(f"Post-login backup scheduling failed for {username}")
343 _log_step_duration("step 4 (schedule backup)", step_start, username)
345 total_ms = (time.perf_counter() - total_start) * 1000
346 if total_ms > 1000:
347 logger.info(
348 f"Post-login tasks completed for user {username} "
349 f"(total: {total_ms:.0f}ms)"
350 )
351 else:
352 logger.info(
353 f"Post-login tasks completed for user {username} ({total_ms:.0f}ms)"
354 )
357def _log_step_duration(step_label: str, start: float, username: str) -> None:
358 """Log post-login step duration at INFO if > 100ms, else DEBUG."""
359 elapsed_ms = (time.perf_counter() - start) * 1000
360 if elapsed_ms > 100:
361 logger.info(
362 f"Post-login {step_label} for {username} took {elapsed_ms:.0f}ms"
363 )
364 else:
365 logger.debug(
366 f"Post-login {step_label} for {username} took {elapsed_ms:.0f}ms"
367 )
370@auth_bp.route("/validate-password", methods=["POST"])
371def validate_password():
372 """Validate password strength via API (used by client-side forms)."""
373 password = request.form.get("password", "")
374 errors = PasswordValidator.validate_strength(password)
375 return jsonify({"valid": len(errors) == 0, "errors": errors})
378@auth_bp.route("/register", methods=["GET"])
379def register_page():
380 """
381 Registration page (GET only).
382 Not rate limited - viewing the page should always work.
383 """
384 config = load_server_config()
385 if not config.get("allow_registrations", True):
386 flash("New user registrations are currently disabled.", "error")
387 return redirect(url_for("auth.login_page"))
389 return render_template(
390 "auth/register.html",
391 has_encryption=db_manager.has_encryption,
392 password_requirements=PasswordValidator.get_requirements(),
393 )
396@auth_bp.route("/register", methods=["POST"])
397@registration_limit
398def register():
399 """
400 Registration handler (POST only).
401 Creates new encrypted database for user with clear warnings about password recovery.
402 Rate limited to 3 attempts per hour per IP to prevent registration spam.
403 """
404 config = load_server_config()
405 if not config.get("allow_registrations", True):
406 flash("New user registrations are currently disabled.", "error")
407 return redirect(url_for("auth.login_page"))
409 # POST - Handle registration
410 username = request.form.get("username", "").strip()
411 password = request.form.get("password", "")
412 confirm_password = request.form.get("confirm_password", "")
413 acknowledge = request.form.get("acknowledge", "false") == "true"
415 # Validation
416 errors = []
418 if not username:
419 errors.append("Username is required")
420 elif len(username) < 3:
421 errors.append("Username must be at least 3 characters")
422 elif not username.replace("_", "").replace("-", "").isalnum():
423 errors.append(
424 "Username can only contain letters, numbers, underscores, and hyphens"
425 )
427 if not password:
428 errors.append("Password is required")
429 else:
430 errors.extend(PasswordValidator.validate_strength(password))
432 if password != confirm_password:
433 errors.append("Passwords do not match")
435 if not acknowledge:
436 errors.append(
437 "You must acknowledge that password recovery is not possible"
438 )
440 # Check if user already exists
441 # Use generic error message to prevent account enumeration
442 # Note: While this creates a minor timing difference, it's acceptable because:
443 # 1. Rate limiting prevents automated timing analysis
444 # 2. Generic error message prevents content-based enumeration
445 # 3. Local database query timing is minimal (no network calls)
446 # 4. Better UX with immediate feedback outweighs minor timing risk
447 # See: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
448 if not errors and username and db_manager.user_exists(username):
449 errors.append("Registration failed. Please try a different username.")
451 if errors:
452 for error in errors:
453 flash(error, "error")
454 return render_template(
455 "auth/register.html",
456 has_encryption=db_manager.has_encryption,
457 password_requirements=PasswordValidator.get_requirements(),
458 ), 400
460 # Create user in auth database
461 with auth_db_session() as auth_db:
462 try:
463 new_user = User(username=username)
464 auth_db.add(new_user)
465 auth_db.commit()
466 except IntegrityError:
467 # Catch duplicate username specifically (race condition case)
468 # This handles the edge case where two requests for the same username
469 # pass the user_exists() check simultaneously
470 logger.warning(f"Duplicate username attempted: {username}")
471 auth_db.rollback()
472 flash(
473 "Registration failed. Please try a different username.", "error"
474 )
475 return render_template(
476 "auth/register.html",
477 has_encryption=db_manager.has_encryption,
478 password_requirements=PasswordValidator.get_requirements(),
479 ), 400
480 except Exception:
481 logger.exception(f"Registration failed for {username}")
482 auth_db.rollback()
483 flash("Registration failed. Please try again.", "error")
484 return render_template(
485 "auth/register.html",
486 has_encryption=db_manager.has_encryption,
487 password_requirements=PasswordValidator.get_requirements(),
488 ), 500
490 try:
491 # Create encrypted database for user
492 db_manager.create_user_database(username, password)
494 # Prevent session fixation by clearing old session data
495 session.clear()
497 # Auto-login after registration
498 session_id = session_manager.create_session(username, False)
499 session["session_id"] = session_id
500 session["username"] = username
502 # Store password temporarily for post-registration database access
503 from ...database.temp_auth import temp_auth_store
505 auth_token = temp_auth_store.store_auth(username, password)
506 session["temp_auth_token"] = auth_token
508 # Also store in session password store for metrics access
509 from ...database.session_passwords import session_password_store
511 session_password_store.store_session_password(
512 username, session_id, password
513 )
515 # Notify the news scheduler about the new user
516 try:
517 from ...scheduler.background import (
518 get_background_job_scheduler,
519 )
521 scheduler = get_background_job_scheduler()
522 if scheduler.is_running: 522 ↛ 530line 522 didn't jump to line 530 because the condition on line 522 was always true
523 scheduler.update_user_info(username, password)
524 logger.info(
525 f"Updated scheduler with new user info for {username}"
526 )
527 except Exception:
528 logger.exception("Could not update scheduler on registration")
530 logger.info(f"New user registered: {username}")
532 # Initialize library system (source types and default collection)
533 from ...database.library_init import initialize_library_for_user
535 try:
536 init_results = initialize_library_for_user(username, password)
537 if init_results.get("success"): 537 ↛ 542line 537 didn't jump to line 542 because the condition on line 537 was always true
538 logger.info(
539 f"Library system initialized for new user {username}"
540 )
541 else:
542 logger.warning(
543 f"Library initialization issue for {username}: {init_results.get('error', 'Unknown error')}"
544 )
545 except Exception:
546 logger.exception(
547 f"Error initializing library for new user {username}"
548 )
549 # Don't block registration on library init failure
551 return redirect(url_for("index"))
553 except Exception:
554 logger.exception(f"Registration failed for {username}")
555 flash("Registration failed. Please try again.", "error")
556 return render_template(
557 "auth/register.html",
558 has_encryption=db_manager.has_encryption,
559 password_requirements=PasswordValidator.get_requirements(),
560 ), 500
563@auth_bp.route("/logout", methods=["POST"])
564def logout():
565 """
566 Logout handler.
567 Clears session and closes database connections.
568 POST-only to prevent CSRF-triggered logout via GET (e.g. <img src="/auth/logout">).
569 """
570 username = session.get("username")
571 session_id = session.get("session_id")
573 if username:
574 # LOGOUT CLEANUP ORDER (order matters):
575 # 1. Unregister from news scheduler — removes password from scheduler's
576 # user_sessions dict and cancels scheduled jobs. Must happen BEFORE
577 # close_user_database() because: scheduler jobs fetch the password
578 # from user_sessions at runtime to call open_user_database(). If we
579 # close the DB first, a running job that already has the password
580 # can re-create the engine. Removing the password first ensures
581 # future job invocations can't authenticate.
582 # Note: a narrow race remains — a job that already fetched the
583 # password (but hasn't called open_user_database yet) can still
584 # recreate an engine. This is benign: the dead-thread sweep will
585 # clean it up within 60 seconds.
586 # 2. Close database connection — disposes QueuePool engine and cleans
587 # up thread engines for this user.
588 # 3. Destroy Flask session — invalidates session token.
589 # 4. Clear session password store — removes password from secondary store.
590 # 5. Clear Flask session dict — removes all session data.
591 try:
592 from ...scheduler.background import (
593 get_background_job_scheduler,
594 )
596 sched = get_background_job_scheduler()
597 if sched.is_running:
598 sched.unregister_user(username)
599 except Exception:
600 logger.warning("Could not unregister user from scheduler")
602 # Close database connection
603 db_manager.close_user_database(username)
605 # Drop per-user lock-dict entries (library-init, backup,
606 # queue-processor critical sections). Matches the cleanup
607 # done by the idle-connection sweeper; without this, those
608 # three module-level dicts accumulate one entry per username
609 # across the process lifetime.
610 from .connection_cleanup import _pop_per_user_locks
612 _pop_per_user_locks(username)
614 # Clear session
615 if session_id: 615 ↛ 623line 615 didn't jump to line 623 because the condition on line 615 was always true
616 session_manager.destroy_session(session_id)
618 # Clear session password
619 from ...database.session_passwords import session_password_store
621 session_password_store.clear_session(username, session_id)
623 session.clear()
625 logger.info(f"User {username} logged out")
626 flash("You have been logged out successfully", "info")
628 return redirect(url_for("auth.login"))
631@auth_bp.route("/check", methods=["GET"])
632def check_auth():
633 """
634 Check if user is authenticated (for AJAX requests).
635 """
636 if session.get("username"):
637 return jsonify({"authenticated": True, "username": session["username"]})
638 return jsonify({"authenticated": False}), 401
641@auth_bp.route("/change-password", methods=["GET"])
642def change_password_page():
643 """
644 Change password page (GET only).
645 Not rate limited - viewing the page should always work.
646 """
647 username = session.get("username")
648 if not username:
649 return redirect(url_for("auth.login"))
651 return render_template(
652 "auth/change_password.html",
653 password_requirements=PasswordValidator.get_requirements(),
654 )
657@auth_bp.route("/change-password", methods=["POST"])
658@password_change_limit
659def change_password():
660 """
661 Change password handler (POST only).
662 Requires current password and re-encrypts database.
663 Rate limited to prevent brute-force of current password.
664 """
665 username = session.get("username")
666 if not username: 666 ↛ 667line 666 didn't jump to line 667 because the condition on line 666 was never true
667 return redirect(url_for("auth.login"))
669 # POST - Handle password change
670 current_password = request.form.get("current_password", "")
671 new_password = request.form.get("new_password", "")
672 confirm_password = request.form.get("confirm_password", "")
674 # Validation
675 errors = []
677 if not current_password:
678 errors.append("Current password is required")
680 if not new_password: 680 ↛ 681line 680 didn't jump to line 681 because the condition on line 680 was never true
681 errors.append("New password is required")
682 else:
683 errors.extend(PasswordValidator.validate_strength(new_password))
685 if new_password != confirm_password:
686 errors.append("New passwords do not match")
688 if current_password == new_password:
689 errors.append("New password must be different from current password")
691 if errors:
692 for error in errors:
693 flash(error, "error")
694 return render_template(
695 "auth/change_password.html",
696 password_requirements=PasswordValidator.get_requirements(),
697 ), 400
699 # Attempt password change
700 success = db_manager.change_password(
701 username, current_password, new_password
702 )
704 if success:
705 # The rekey is the ONLY step needed. The auth database stores no
706 # password hash — login works by attempting to decrypt the user's
707 # SQLCipher database. Do NOT add an auth-DB password-hash update
708 # here; it would fail (User model has no set_password method) and
709 # is architecturally unnecessary.
711 # Clean up stale credentials before clearing session
712 # (mirrors logout handler cleanup steps 1–5).
714 # 1. Unregister from scheduler (removes stale credential)
715 try:
716 from ...scheduler.background import (
717 get_background_job_scheduler,
718 )
720 sched = get_background_job_scheduler()
721 if sched.is_running: 721 ↛ 722line 721 didn't jump to line 722 because the condition on line 721 was never true
722 sched.unregister_user(username)
723 except Exception:
724 logger.warning(
725 "Could not unregister user from scheduler",
726 )
728 # 2. Close database connection (disposes old-password engine)
729 # change_password() already closes in its finally block, but
730 # an explicit close here is defensive — harmless if redundant.
731 db_manager.close_user_database(username)
733 # 2a. Drop per-user lock-dict entries (matches logout path).
734 from .connection_cleanup import _pop_per_user_locks
736 _pop_per_user_locks(username)
738 # 2b. Purge old backups (encrypted with old key) and create
739 # a fresh backup with the new key. Old-key backups are a
740 # security risk per NIST SP 800-57 / OWASP A02 — they remain
741 # decryptable with the compromised password.
742 try:
743 from ...database.backup.backup_service import BackupService
745 svc = BackupService(username=username, password=new_password)
746 result = svc.purge_and_refresh()
747 if result.success: 747 ↛ 748line 747 didn't jump to line 748 because the condition on line 747 was never true
748 logger.info(
749 f"Backups refreshed after password change for {username}"
750 )
751 else:
752 logger.error(
753 f"Post-password-change backup failed for {username}: "
754 f"{result.error}. Old backups were purged."
755 )
756 except Exception:
757 logger.exception(
758 f"Could not refresh backups after password change "
759 f"for {username}"
760 )
762 # 3. Destroy ALL sessions for this user + clear password store
763 session_manager.destroy_all_user_sessions(username)
765 from ...database.session_passwords import (
766 session_password_store,
767 )
769 session_password_store.clear_all_for_user(username)
771 # 4. Clear Flask session dict
772 session.clear()
774 logger.info(f"Password changed for user {username}")
775 flash(
776 "Password changed successfully. Please login with your new password.",
777 "success",
778 )
779 return redirect(url_for("auth.login"))
780 flash("Current password is incorrect", "error")
781 return render_template(
782 "auth/change_password.html",
783 password_requirements=PasswordValidator.get_requirements(),
784 ), 401
787@auth_bp.route("/integrity-check", methods=["GET"])
788def integrity_check():
789 """
790 Check database integrity for current user.
791 """
792 username = session.get("username")
793 if not username:
794 return jsonify({"error": "Not authenticated"}), 401
796 is_valid = db_manager.check_database_integrity(username)
798 return jsonify(
799 {
800 "username": username,
801 "integrity": "valid" if is_valid else "corrupted",
802 "timestamp": datetime.now(timezone.utc).isoformat(),
803 }
804 )