Первый коммит: модульный монолит ядра (.NET 10) и gRPC-сервисы ai/ml/telegram, фронтенд Vue 3/Vite/Tailwind, документация (ТЗ, инструкция пользователя, техдокументация, код-стайл), бэклог, скрипты развёртывания и архив прототипа LeadRadar.
99 lines
3.4 KiB
Python
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
|