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")
|
@app.post("/api/login")
|
||||||
async def api_login(req: LoginRequest, request: Request, response: Response):
|
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):
|
if not check_login_rate_limit(ip):
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
status_code=status.HTTP_429_TOO_MANY_REQUESTS,
|
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", "")
|
user_agent = request.headers.get("User-Agent", "")
|
||||||
session_token = create_session(user["id"], user_agent=user_agent, ip=ip)
|
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(
|
response.set_cookie(
|
||||||
key=config.SESSION_COOKIE_NAME,
|
key=config.SESSION_COOKIE_NAME,
|
||||||
value=session_token,
|
value=session_token,
|
||||||
max_age=config.SESSION_MAX_AGE,
|
max_age=config.SESSION_MAX_AGE,
|
||||||
httponly=True,
|
httponly=True,
|
||||||
samesite="lax",
|
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")
|
@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">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
|
||||||
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
<title>Notes — Персональный тренажер и словарь английского языка</title>
|
||||||
<meta name="description" content="Защищённый персональный тренажёр и словарь английского языка с интервальными повторениями SRS SM-2, контекстным поиском и озвучкой">
|
<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 -->
|
<!-- PWA meta and icons -->
|
||||||
<link rel="manifest" href="/manifest.json">
|
<link rel="manifest" href="/manifest.json">
|
||||||
@@ -928,6 +928,7 @@
|
|||||||
<!-- Audio Element for native streaming -->
|
<!-- Audio Element for native streaming -->
|
||||||
<audio id="global-audio-player" preload="none"></audio>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
||||||
|
|||||||
+29
-1
@@ -1,5 +1,27 @@
|
|||||||
// Notes Application Frontend Logic - Complete SRS, Heatmap, Quiz, and Analytics
|
// 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
|
// Register PWA Service Worker
|
||||||
if ('serviceWorker' in navigator) {
|
if ('serviceWorker' in navigator) {
|
||||||
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
navigator.serviceWorker.register('/sw.js', { scope: '/' })
|
||||||
@@ -7,6 +29,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
.catch(err => console.log('Notes ServiceWorker failed:', err));
|
.catch(err => console.log('Notes ServiceWorker failed:', err));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// App State
|
// App State
|
||||||
let currentUser = null;
|
let currentUser = null;
|
||||||
let currentLookupData = null;
|
let currentLookupData = null;
|
||||||
@@ -385,6 +408,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
const data = await resp.json();
|
const data = await resp.json();
|
||||||
if (resp.ok && data.status === 'ok') {
|
if (resp.ok && data.status === 'ok') {
|
||||||
|
if (data.token) {
|
||||||
|
localStorage.setItem('notes_token', data.token);
|
||||||
|
}
|
||||||
currentUser = data.user;
|
currentUser = data.user;
|
||||||
showApp();
|
showApp();
|
||||||
loadInitialData();
|
loadInitialData();
|
||||||
@@ -399,11 +425,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
logoutBtn.addEventListener('click', async () => {
|
logoutBtn.addEventListener('click', async () => {
|
||||||
|
localStorage.removeItem('notes_token');
|
||||||
await fetch('/api/logout', { method: 'POST' });
|
await fetch('/api/logout', { method: 'POST' });
|
||||||
currentUser = null;
|
currentUser = null;
|
||||||
window.location.reload();
|
window.location.reload();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
// Data Loading: Cards, Collections, Tags, Stats
|
// Data Loading: Cards, Collections, Tags, Stats
|
||||||
// -----------------------------------------------------------------------
|
// -----------------------------------------------------------------------
|
||||||
|
|||||||
+1
-1
@@ -14,5 +14,5 @@ services:
|
|||||||
- DEBUG=False
|
- DEBUG=False
|
||||||
- SECRET_KEY=study-qpid-pro-secret-key-change-in-prod
|
- SECRET_KEY=study-qpid-pro-secret-key-change-in-prod
|
||||||
- ADMIN_USERNAME=kopri
|
- ADMIN_USERNAME=kopri
|
||||||
- ADMIN_PASSWORD=kopri2026pass
|
|
||||||
- DB_PATH=/app/data/notes.db
|
- 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