From 99741c386e555a28ac4e77d597c977826f07cb25 Mon Sep 17 00:00:00 2001 From: Antigravity Date: Mon, 5 Oct 2026 15:23:37 +0300 Subject: [PATCH] fix(auth): implement DB-first auth, persistent bearer token, secure cookies, and CLI reset_password script --- app/main.py | 17 +++++++-- app/static/index.html | 5 +-- app/static/js/app.js | 30 +++++++++++++++- docker-compose.yml | 2 +- scripts/reset_password.py | 73 +++++++++++++++++++++++++++++++++++++++ 5 files changed, 120 insertions(+), 7 deletions(-) create mode 100755 scripts/reset_password.py diff --git a/app/main.py b/app/main.py index d1ab8ce..f271a2b 100644 --- a/app/main.py +++ b/app/main.py @@ -215,7 +215,12 @@ async def index_page(request: Request): @app.post("/api/login") async def api_login(req: LoginRequest, request: Request, response: Response): - ip = request.client.host if request.client else "unknown" + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + ip = forwarded.split(",")[0].strip() + else: + ip = request.headers.get("x-real-ip") or (request.client.host if request.client else "unknown") + if not check_login_rate_limit(ip): raise HTTPException( status_code=status.HTTP_429_TOO_MANY_REQUESTS, @@ -234,15 +239,21 @@ async def api_login(req: LoginRequest, request: Request, response: Response): user_agent = request.headers.get("User-Agent", "") session_token = create_session(user["id"], user_agent=user_agent, ip=ip) + is_secure = request.headers.get("x-forwarded-proto", "").lower() == "https" or request.url.scheme == "https" response.set_cookie( key=config.SESSION_COOKIE_NAME, value=session_token, max_age=config.SESSION_MAX_AGE, httponly=True, samesite="lax", - secure=False, + secure=is_secure, ) - return {"status": "ok", "user": {"id": user["id"], "username": user["username"]}} + return { + "status": "ok", + "user": {"id": user["id"], "username": user["username"]}, + "token": session_token, + } + @app.post("/api/logout") diff --git a/app/static/index.html b/app/static/index.html index 131df46..3a929f2 100644 --- a/app/static/index.html +++ b/app/static/index.html @@ -5,7 +5,7 @@ Notes — Персональный тренажер и словарь английского языка - + @@ -928,6 +928,7 @@ - + + diff --git a/app/static/js/app.js b/app/static/js/app.js index 721f086..2a127c1 100644 --- a/app/static/js/app.js +++ b/app/static/js/app.js @@ -1,5 +1,27 @@ // Notes Application Frontend Logic - Complete SRS, Heatmap, Quiz, and Analytics -document.addEventListener('DOMContentLoaded', () => { + // Persistent session: auto-inject Authorization header if token exists in localStorage + const originalFetch = window.fetch; + window.fetch = function(url, options = {}) { + const token = localStorage.getItem('notes_token'); + if (token && typeof url === 'string' && url.startsWith('/api/')) { + options.headers = options.headers || {}; + if (options.headers instanceof Headers) { + if (!options.headers.has('Authorization')) { + options.headers.set('Authorization', `Bearer ${token}`); + } + } else if (Array.isArray(options.headers)) { + if (!options.headers.some(([k]) => k.toLowerCase() === 'authorization')) { + options.headers.push(['Authorization', `Bearer ${token}`]); + } + } else { + if (!options.headers['Authorization']) { + options.headers['Authorization'] = `Bearer ${token}`; + } + } + } + return originalFetch.call(this, url, options); + }; + // Register PWA Service Worker if ('serviceWorker' in navigator) { navigator.serviceWorker.register('/sw.js', { scope: '/' }) @@ -7,6 +29,7 @@ document.addEventListener('DOMContentLoaded', () => { .catch(err => console.log('Notes ServiceWorker failed:', err)); } + // App State let currentUser = null; let currentLookupData = null; @@ -385,6 +408,9 @@ document.addEventListener('DOMContentLoaded', () => { }); const data = await resp.json(); if (resp.ok && data.status === 'ok') { + if (data.token) { + localStorage.setItem('notes_token', data.token); + } currentUser = data.user; showApp(); loadInitialData(); @@ -399,11 +425,13 @@ document.addEventListener('DOMContentLoaded', () => { }); logoutBtn.addEventListener('click', async () => { + localStorage.removeItem('notes_token'); await fetch('/api/logout', { method: 'POST' }); currentUser = null; window.location.reload(); }); + // ----------------------------------------------------------------------- // Data Loading: Cards, Collections, Tags, Stats // ----------------------------------------------------------------------- diff --git a/docker-compose.yml b/docker-compose.yml index 21b9e0a..1f9450f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,5 +14,5 @@ services: - DEBUG=False - SECRET_KEY=study-qpid-pro-secret-key-change-in-prod - ADMIN_USERNAME=kopri - - ADMIN_PASSWORD=kopri2026pass - DB_PATH=/app/data/notes.db + diff --git a/scripts/reset_password.py b/scripts/reset_password.py new file mode 100755 index 0000000..9da2b00 --- /dev/null +++ b/scripts/reset_password.py @@ -0,0 +1,73 @@ +#!/usr/bin/env python3 +""" +CLI utility to safely reset a user password directly in the database. +Usage: + python3 scripts/reset_password.py + python3 scripts/reset_password.py +""" +import sys +from pathlib import Path + +# Add project root to sys.path +BASE_DIR = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(BASE_DIR)) + +from app.database import init_db, get_db +from app.auth import hash_password, verify_password, _login_failures + + +def reset_password(username: str, new_password: str): + if not new_password or len(new_password) < 6: + print("❌ Ошибка: Пароль должен содержать минимум 6 символов.") + sys.exit(1) + + init_db() + clean_user = username.strip() + pwd_hash, salt = hash_password(new_password) + + with get_db() as conn: + row = conn.execute( + "SELECT id, username FROM users WHERE username = ? COLLATE NOCASE", + (clean_user,) + ).fetchone() + + if not row: + # If user does not exist, create it + cursor = conn.execute( + "INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)", + (clean_user, pwd_hash, salt) + ) + print(f"✅ Создан новый пользователь '{clean_user}' (ID: {cursor.lastrowid}).") + else: + conn.execute( + "UPDATE users SET password_hash = ?, salt = ? WHERE id = ?", + (pwd_hash, salt, row["id"]) + ) + print(f"✅ Пароль для пользователя '{row['username']}' (ID: {row['id']}) успешно обновлен.") + + # Clear login failures rate limit + _login_failures.clear() + + # Verification check + check = conn.execute( + "SELECT password_hash, salt FROM users WHERE username = ? COLLATE NOCASE", + (clean_user,) + ).fetchone() + if check and verify_password(new_password, check["password_hash"], check["salt"]): + print("🔒 Проверка хэша PBKDF2-HMAC-SHA256: УСПЕШНО.") + else: + print("⚠️ Предупреждение: Не удалось верифицировать хэш пароля.") + + +if __name__ == "__main__": + args = sys.argv[1:] + if len(args) == 1: + # Default to user kopri + reset_password("kopri", args[0]) + elif len(args) == 2: + reset_password(args[0], args[1]) + else: + print("Использование:") + print(" python3 scripts/reset_password.py <новый_пароль>") + print(" python3 scripts/reset_password.py <имя_пользователя> <новый_пароль>") + sys.exit(1)