Files

161 lines
5.6 KiB
Python

import hashlib
import os
import secrets
import time
from datetime import datetime, timezone, timedelta
from typing import Optional
from fastapi import Request, HTTPException, status
from app import config
from app.database import get_db
# In-memory IP-based rate limiting for login attempts
_login_failures: dict[str, list[float]] = {}
def hash_password(password: str, salt: Optional[str] = None) -> tuple[str, str]:
if not salt:
salt = secrets.token_hex(16)
salt_bytes = bytes.fromhex(salt)
key = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt_bytes, 100_000)
return key.hex(), salt
def verify_password(password: str, stored_hash: str, salt: str) -> bool:
new_hash, _ = hash_password(password, salt)
return secrets.compare_digest(new_hash, stored_hash)
def check_login_rate_limit(ip: str) -> bool:
now = time.time()
cutoff = now - config.RATE_LIMIT_LOGIN_WINDOW_SEC
attempts = _login_failures.get(ip, [])
# Filter attempts within the time window
recent_attempts = [t for t in attempts if t > cutoff]
_login_failures[ip] = recent_attempts
return len(recent_attempts) < config.RATE_LIMIT_LOGIN_MAX
def record_login_failure(ip: str):
now = time.time()
if ip not in _login_failures:
_login_failures[ip] = []
_login_failures[ip].append(now)
def record_login_success(ip: str):
if ip in _login_failures:
_login_failures.pop(ip, None)
def create_user(username: str, password: str) -> int:
pwd_hash, salt = hash_password(password)
with get_db() as conn:
cursor = conn.execute(
"INSERT INTO users (username, password_hash, salt) VALUES (?, ?, ?)",
(username.strip(), pwd_hash, salt)
)
return cursor.lastrowid
def authenticate_user(username: str, password: str) -> Optional[dict]:
with get_db() as conn:
row = conn.execute(
"SELECT id, username, password_hash, salt FROM users WHERE username = ?",
(username.strip(),)
).fetchone()
if not row:
return None
if verify_password(password, row["password_hash"], row["salt"]):
return {"id": row["id"], "username": row["username"]}
return None
def create_session(user_id: int, user_agent: str = "", ip: str = "") -> str:
token = secrets.token_urlsafe(32)
expires_at = (datetime.now(timezone.utc) + timedelta(days=config.SESSION_EXPIRE_DAYS)).isoformat()
with get_db() as conn:
conn.execute(
"INSERT INTO sessions (token, user_id, expires_at, user_agent, ip) VALUES (?, ?, ?, ?, ?)",
(token, user_id, expires_at, user_agent, ip)
)
return token
def get_user_from_session(token: str) -> Optional[dict]:
if not token:
return None
with get_db() as conn:
row = conn.execute(
"""
SELECT u.id, u.username, s.expires_at
FROM sessions s
JOIN users u ON s.user_id = u.id
WHERE s.token = ?
""",
(token,)
).fetchone()
if not row:
return None
expires_at_str = row["expires_at"]
try:
expires_at = datetime.fromisoformat(expires_at_str)
if expires_at.tzinfo is None:
expires_at = expires_at.replace(tzinfo=timezone.utc)
except Exception:
return None
if expires_at < datetime.now(timezone.utc):
# Session expired
delete_session(token)
return None
return {"id": row["id"], "username": row["username"]}
def delete_session(token: str):
if not token:
return
with get_db() as conn:
conn.execute("DELETE FROM sessions WHERE token = ?", (token,))
def change_user_password(user_id: int, old_password: str, new_password: str) -> tuple[bool, str]:
"""Changes password for the specified user after verifying old password."""
if not new_password or len(new_password) < 6:
return False, "Новый пароль должен содержать минимум 6 символов"
with get_db() as conn:
row = conn.execute(
"SELECT password_hash, salt FROM users WHERE id = ?",
(user_id,)
).fetchone()
if not row:
return False, "Пользователь не найден"
if not verify_password(old_password, row["password_hash"], row["salt"]):
return False, "Неверный текущий пароль"
pwd_hash, salt = hash_password(new_password)
conn.execute(
"UPDATE users SET password_hash = ?, salt = ? WHERE id = ?",
(pwd_hash, salt, user_id)
)
return True, "Пароль успешно изменён"
def ensure_admin_user():
"""Ensures at least one user exists; creates default admin from config if table empty."""
with get_db() as conn:
count = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0]
if count == 0 and config.ADMIN_PASSWORD:
create_user(config.ADMIN_USERNAME, config.ADMIN_PASSWORD)
def get_optional_user(request: Request) -> Optional[dict]:
token = request.cookies.get(config.SESSION_COOKIE_NAME)
if not token:
auth_header = request.headers.get("Authorization", "")
if auth_header.startswith("Bearer "):
token = auth_header[7:].strip()
if token:
return get_user_from_session(token)
return None
def require_user(request: Request) -> dict:
user = get_optional_user(request)
if not user:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Authentication required",
)
return user