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

1""" 

2Authentication routes for login, register, and logout. 

3Uses SQLCipher encrypted databases with browser password manager support. 

4""" 

5 

6import threading 

7import time 

8from datetime import datetime, timezone, UTC 

9 

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 

21 

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 

38 

39from ...security.url_validator import URLValidator 

40from ...security.account_lockout import get_account_lockout_manager 

41from ...security.password_validator import PasswordValidator 

42from ...security.log_sanitizer import sanitize_for_log 

43 

44auth_bp = Blueprint("auth", __name__, url_prefix="/auth") 

45 

46 

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 

56 

57 # Generate or get existing CSRF token for this session 

58 token = generate_csrf() 

59 

60 return jsonify({"csrf_token": token}), 200 

61 

62 

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")) 

73 

74 # Preserve the next parameter for post-login redirect 

75 next_page = request.args.get("next", "") 

76 

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 ) 

83 

84 

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" 

97 

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 

105 

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 

118 

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 

144 

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 

157 

158 # Success — clear any prior failure count 

159 lockout_mgr.record_success(username) 

160 

161 # Prevent session fixation by clearing old session data before creating new 

162 session.clear() 

163 

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 

169 

170 # Store password temporarily for post-login database access 

171 from ...database.temp_auth import temp_auth_store 

172 

173 auth_token = temp_auth_store.store_auth(username, password) 

174 session["temp_auth_token"] = auth_token 

175 

176 # Also store in session password store for metrics access 

177 from ...database.session_passwords import session_password_store 

178 

179 session_password_store.store_session_password( 

180 username, session_id, password 

181 ) 

182 

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

184 

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() 

196 

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")) 

205 

206 

207@thread_cleanup 

208def _perform_post_login_tasks(username: str, password: str) -> None: 

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

210 

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. 

214 

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 ) 

226 

227 

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() 

233 

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 

252 

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) 

272 

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 

277 

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) 

289 

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) 

297 

298 try: 

299 from ...scheduler.background import ( 

300 get_background_job_scheduler, 

301 ) 

302 

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") 

311 

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 ) 

318 

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

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

321 

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 

328 

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) 

332 

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) 

336 

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) 

344 

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 ) 

355 

356 

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 ) 

368 

369 

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}) 

376 

377 

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")) 

388 

389 return render_template( 

390 "auth/register.html", 

391 has_encryption=db_manager.has_encryption, 

392 password_requirements=PasswordValidator.get_requirements(), 

393 ) 

394 

395 

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")) 

408 

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" 

414 

415 # Validation 

416 errors = [] 

417 

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 ) 

426 

427 if not password: 

428 errors.append("Password is required") 

429 else: 

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

431 

432 if password != confirm_password: 

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

434 

435 if not acknowledge: 

436 errors.append( 

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

438 ) 

439 

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.") 

450 

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 

459 

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 

489 

490 try: 

491 # Create encrypted database for user 

492 db_manager.create_user_database(username, password) 

493 

494 # Prevent session fixation by clearing old session data 

495 session.clear() 

496 

497 # Auto-login after registration 

498 session_id = session_manager.create_session(username, False) 

499 session["session_id"] = session_id 

500 session["username"] = username 

501 

502 # Store password temporarily for post-registration database access 

503 from ...database.temp_auth import temp_auth_store 

504 

505 auth_token = temp_auth_store.store_auth(username, password) 

506 session["temp_auth_token"] = auth_token 

507 

508 # Also store in session password store for metrics access 

509 from ...database.session_passwords import session_password_store 

510 

511 session_password_store.store_session_password( 

512 username, session_id, password 

513 ) 

514 

515 # Notify the news scheduler about the new user 

516 try: 

517 from ...scheduler.background import ( 

518 get_background_job_scheduler, 

519 ) 

520 

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") 

529 

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

531 

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

533 from ...database.library_init import initialize_library_for_user 

534 

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 

550 

551 return redirect(url_for("index")) 

552 

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 

561 

562 

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") 

572 

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 ) 

595 

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") 

601 

602 # Close database connection 

603 db_manager.close_user_database(username) 

604 

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 

611 

612 _pop_per_user_locks(username) 

613 

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) 

617 

618 # Clear session password 

619 from ...database.session_passwords import session_password_store 

620 

621 session_password_store.clear_session(username, session_id) 

622 

623 session.clear() 

624 

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

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

627 

628 return redirect(url_for("auth.login")) 

629 

630 

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 

639 

640 

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")) 

650 

651 return render_template( 

652 "auth/change_password.html", 

653 password_requirements=PasswordValidator.get_requirements(), 

654 ) 

655 

656 

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")) 

668 

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", "") 

673 

674 # Validation 

675 errors = [] 

676 

677 if not current_password: 

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

679 

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)) 

684 

685 if new_password != confirm_password: 

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

687 

688 if current_password == new_password: 

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

690 

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 

698 

699 # Attempt password change 

700 success = db_manager.change_password( 

701 username, current_password, new_password 

702 ) 

703 

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. 

710 

711 # Clean up stale credentials before clearing session 

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

713 

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

715 try: 

716 from ...scheduler.background import ( 

717 get_background_job_scheduler, 

718 ) 

719 

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 ) 

727 

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) 

732 

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

734 from .connection_cleanup import _pop_per_user_locks 

735 

736 _pop_per_user_locks(username) 

737 

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 

744 

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 ) 

761 

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

763 session_manager.destroy_all_user_sessions(username) 

764 

765 from ...database.session_passwords import ( 

766 session_password_store, 

767 ) 

768 

769 session_password_store.clear_all_for_user(username) 

770 

771 # 4. Clear Flask session dict 

772 session.clear() 

773 

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 

785 

786 

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 

795 

796 is_valid = db_manager.check_database_integrity(username) 

797 

798 return jsonify( 

799 { 

800 "username": username, 

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

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

803 } 

804 )