"""Авторизация в дашборд: admin/admin по умолчанию, сессия 30 дней. Хэш пароля — PBKDF2-HMAC-SHA256. Токен сессии — случайный, хранится в БД, передаётся httpOnly-кукой. По ТЗ смена пароля — через интерфейс. """ from __future__ import annotations import hashlib import hmac import secrets import time from fastapi import Cookie, HTTPException, Response from . import config from .db import store _ITERATIONS = 200_000 def _hash_password(password: str, salt: str) -> str: return hashlib.pbkdf2_hmac("sha256", password.encode(), salt.encode(), _ITERATIONS).hex() def _make_salt() -> str: return secrets.token_hex(16) def ensure_creds() -> None: """Создаёт учётные данные по умолчанию, если их нет.""" if store.scalar("SELECT count(*) FROM creds") == 0: salt = _make_salt() store.execute( "INSERT INTO creds(login, password_hash, salt) VALUES (?, ?, ?)", [config.BOOTSTRAP_LOGIN, _hash_password(config.BOOTSTRAP_PASSWORD, salt), salt], ) def verify_password(login: str, password: str) -> bool: row = store.query_one("SELECT password_hash, salt FROM creds WHERE login = ?", [login]) if not row: return False return hmac.compare_digest(row["password_hash"], _hash_password(password, row["salt"])) def change_password(login: str, old_password: str, new_password: str) -> bool: if not verify_password(login, old_password): return False if len(new_password) < 4: raise HTTPException(400, "Пароль слишком короткий (минимум 4 символа)") salt = _make_salt() store.execute( "UPDATE creds SET password_hash = ?, salt = ? WHERE login = ?", [_hash_password(new_password, salt), salt, login], ) # разлогиниваем все старые сессии пользователя store.execute("DELETE FROM sessions WHERE login = ?", [login]) return True def create_session(login: str) -> str: token = secrets.token_urlsafe(32) expires = time.time_ns() // 1_000_000 + config.SESSION_DAYS * 86_400_000 store.execute("INSERT INTO sessions(token, login, expires) VALUES (?, ?, ?)", [token, login, expires]) return token def resolve_session(token: str | None) -> str | None: if not token: return None row = store.query_one( "SELECT login FROM sessions WHERE token = ? AND expires > ?", [token, time.time_ns() // 1_000_000], ) return row["login"] if row else None def set_session_cookie(response: Response, token: str) -> None: response.set_cookie( key=config.COOKIE_NAME, value=token, max_age=config.SESSION_DAYS * 86400, httponly=True, samesite="lax", # secure включается при HTTPS-проксировании; флаг не критичен локально ) def destroy_session(token: str | None) -> None: if token: store.execute("DELETE FROM sessions WHERE token = ?", [token]) def current_login(token: str | None = Cookie(default=None, alias=config.COOKIE_NAME)) -> str | None: login = resolve_session(token) if not login: raise HTTPException(401, "Требуется авторизация") return login