Files
Deal/archive/leadradar-legacy/backend/app/auth.py
T
Rustam Khalimov 9e07568ddd Инициализировать репозиторий «Дейл»
Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы
ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ,
инструкция пользователя, техдокументация, код-стайл), бэклог,
скрипты развёртывания и архив прототипа LeadRadar.
2026-09-11 02:50:17 +03:00

99 lines
3.4 KiB
Python

"""Авторизация в дашборд: 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