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

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 

47def _create_user_session(session, username: str, password: str, remember: bool): 

48 """Create a new Flask session for ``username``. 

49 

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. 

55 

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

61 

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 

69 

70 # Store password temporarily for post-login database access 

71 from ...database.temp_auth import temp_auth_store 

72 

73 auth_token = temp_auth_store.store_auth(username, password) 

74 session["temp_auth_token"] = auth_token 

75 

76 # Also store in session password store for metrics access 

77 from ...database.session_passwords import session_password_store 

78 

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 

97 

98 return session_id 

99 

100 

101def _cleanup_user_session( 

102 username: str, session_id=None, new_password=None 

103) -> None: 

104 """Tear down server-side session state for ``username``. 

105 

106 Parameterises the two cleanup scopes used by the auth routes: 

107 

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. 

113 

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. 

117 

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) 

126 

127 from ...database.session_passwords import session_password_store 

128 

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 

139 

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 ) 

156 

157 session_manager.destroy_all_user_sessions(username) 

158 

159 from ...database.session_passwords import session_password_store 

160 

161 session_password_store.clear_all_for_user(username) 

162 

163 

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 

173 

174 # Generate or get existing CSRF token for this session 

175 token = generate_csrf() 

176 

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

178 

179 

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

190 

191 # Preserve the next parameter for post-login redirect 

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

193 

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 ) 

200 

201 

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" 

214 

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 

222 

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 

235 

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 

261 

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 

274 

275 # Success — clear any prior failure count 

276 lockout_mgr.record_success(username) 

277 

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) 

281 

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

283 

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

295 

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

304 

305 

306@thread_cleanup 

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

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

309 

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. 

313 

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 ) 

325 

326 

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

332 

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 

351 

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) 

371 

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 

376 

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) 

388 

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) 

396 

397 try: 

398 from ...scheduler.background import ( 

399 get_background_job_scheduler, 

400 ) 

401 

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

410 

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 ) 

417 

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

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

420 

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 

427 

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) 

431 

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) 

435 

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) 

443 

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 ) 

454 

455 

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 ) 

467 

468 

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

475 

476 

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

487 

488 return render_template( 

489 "auth/register.html", 

490 has_encryption=db_manager.has_encryption, 

491 password_requirements=PasswordValidator.get_requirements(), 

492 ) 

493 

494 

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

507 

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" 

513 

514 # Validation 

515 errors = [] 

516 

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 ) 

525 

526 if not password: 

527 errors.append("Password is required") 

528 else: 

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

530 

531 if password != confirm_password: 

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

533 

534 if not acknowledge: 

535 errors.append( 

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

537 ) 

538 

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

549 

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 

558 

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 

599 

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 

642 

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) 

647 

648 # Notify the news scheduler about the new user 

649 try: 

650 from ...scheduler.background import ( 

651 get_background_job_scheduler, 

652 ) 

653 

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

662 

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

664 

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

666 from ...database.library_init import initialize_library_for_user 

667 

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 

683 

684 return redirect(url_for("index")) 

685 

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 

700 

701 

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

711 

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 ) 

734 

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

740 

741 # Close database connection 

742 db_manager.close_user_database(username) 

743 

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 

750 

751 _pop_per_user_locks(username) 

752 

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) 

756 

757 session.clear() 

758 

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

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

761 

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

763 

764 

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 

773 

774 

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

784 

785 return render_template( 

786 "auth/change_password.html", 

787 password_requirements=PasswordValidator.get_requirements(), 

788 ) 

789 

790 

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

802 

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

807 

808 # Validation 

809 errors = [] 

810 

811 if not current_password: 

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

813 

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

818 

819 if new_password != confirm_password: 

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

821 

822 if current_password == new_password: 

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

824 

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 

832 

833 # Attempt password change 

834 success = db_manager.change_password( 

835 username, current_password, new_password 

836 ) 

837 

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. 

844 

845 # Clean up stale credentials before clearing session 

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

847 

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

849 try: 

850 from ...scheduler.background import ( 

851 get_background_job_scheduler, 

852 ) 

853 

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 ) 

861 

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) 

866 

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

868 from .connection_cleanup import _pop_per_user_locks 

869 

870 _pop_per_user_locks(username) 

871 

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 ) 

878 

879 # 4. Clear Flask session dict 

880 session.clear() 

881 

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 

893 

894 

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 

903 

904 is_valid = db_manager.check_database_integrity(username) 

905 

906 return jsonify( 

907 { 

908 "username": username, 

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

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

911 } 

912 )