fix(auth): implement DB-first auth, persistent bearer token, secure cookies, and CLI reset_password script
This commit is contained in:
+14
-3
@@ -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")
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=2.8">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=2.9">
|
||||
|
||||
<!-- PWA meta and icons -->
|
||||
<link rel="manifest" href="/manifest.json">
|
||||
@@ -928,6 +928,7 @@
|
||||
<!-- Audio Element for native streaming -->
|
||||
<audio id="global-audio-player" preload="none"></audio>
|
||||
|
||||
<script src="/static/js/app.js?v=2.8"></script>
|
||||
<script src="/static/js/app.js?v=2.9"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
|
||||
+29
-1
@@ -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
|
||||
// -----------------------------------------------------------------------
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
Executable
+73
@@ -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 <new_password>
|
||||
python3 scripts/reset_password.py <username> <new_password>
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user