Files
CoRE.Vision/backend/app/auth.py
T
2026-05-30 00:08:59 +05:00

248 lines
9.8 KiB
Python

"""Авторизация по логину/паролю + роли (оператор / админ / суперадмин).
Пользователи читаются из .env.json (раздел "users"). Если его нет — он
выводится из старого раздела "auth" (как один суперадмин) или admin/admin по
умолчанию. Сессии — в памяти (перезапуск сервиса разлогинивает всех). Пароли
хранятся в открытом виде, как и раньше; для нескольких операторов этого
достаточно, при необходимости позже заменим на хеши.
"""
from __future__ import annotations
import json
import os
import secrets
from fastapi import APIRouter, HTTPException, Request, Response
from fastapi.responses import JSONResponse
COOKIE_NAME = "nvr_session"
ROLES = ("operator", "admin", "superadmin")
_RANK = {"operator": 1, "admin": 2, "superadmin": 3}
def role_at_least(role: str | None, minimum: str) -> bool:
return bool(role) and _RANK.get(role, 0) >= _RANK[minimum]
class AuthManager:
def __init__(self, settings_path: str | None = None):
self.path = settings_path or os.environ.get("NVR_SETTINGS", ".env.json")
self.users: list[dict] = []
self._sessions: dict[str, str] = {} # token -> username
self.reload()
# ── загрузка/сохранение ─────────────────────────────────────
def _read(self) -> dict:
try:
with open(self.path, "r", encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError):
return {}
def reload(self) -> None:
data = self._read()
users = data.get("users")
if not users:
auth = data.get("auth") or {}
users = [{
"username": auth.get("username", "admin"),
"password": auth.get("password", "admin"),
"role": "superadmin",
}]
norm: list[dict] = []
for u in users:
role = u.get("role", "operator")
if role not in ROLES:
role = "operator"
norm.append({
"username": u.get("username", ""),
"password": u.get("password", ""),
"role": role,
})
# гарантируем хотя бы одного суперадмина
if norm and not any(u["role"] == "superadmin" for u in norm):
norm[0]["role"] = "superadmin"
self.users = norm
def _write_users(self) -> None:
data = self._read()
data["users"] = self.users
with open(self.path, "w", encoding="utf-8") as fh:
json.dump(data, fh, ensure_ascii=False, indent=2)
def _find(self, username: str) -> dict | None:
return next((u for u in self.users if u["username"] == username), None)
def _superadmin_count(self) -> int:
return sum(1 for u in self.users if u["role"] == "superadmin")
# ── сессии ──────────────────────────────────────────────────
def login(self, username: str, password: str) -> str | None:
u = self._find(username)
if u and secrets.compare_digest(password, u["password"]):
token = secrets.token_urlsafe(32)
self._sessions[token] = username
return token
return None
def validate(self, token: str | None) -> bool:
return bool(token) and token in self._sessions and \
self._find(self._sessions[token]) is not None
def user_for(self, token: str | None) -> dict | None:
if not token:
return None
username = self._sessions.get(token)
return self._find(username) if username else None
def role_for(self, token: str | None) -> str | None:
u = self.user_for(token)
return u["role"] if u else None
def logout(self, token: str | None) -> None:
if token:
self._sessions.pop(token, None)
def verify_token_password(self, token: str | None, password: str) -> bool:
u = self.user_for(token)
return bool(u) and secrets.compare_digest(password or "", u["password"])
# ── управление пользователями ───────────────────────────────
def list_users(self) -> list[dict]:
return [{"username": u["username"], "role": u["role"]} for u in self.users]
def add_user(self, username: str, password: str, role: str) -> None:
username = (username or "").strip()
if not username:
raise ValueError("пустой логин")
if role not in ROLES:
raise ValueError("недопустимая роль")
if not password:
raise ValueError("пустой пароль")
if self._find(username):
raise ValueError("пользователь с таким логином уже существует")
self.users.append({"username": username, "password": password, "role": role})
self._write_users()
def update_user(self, username: str, password: str | None = None,
role: str | None = None) -> None:
u = self._find(username)
if not u:
raise ValueError("пользователь не найден")
if role is not None:
if role not in ROLES:
raise ValueError("недопустимая роль")
if u["role"] == "superadmin" and role != "superadmin" and \
self._superadmin_count() == 1:
raise ValueError("нельзя снять роль с последнего суперадмина")
u["role"] = role
if password:
u["password"] = password
self._write_users()
def delete_user(self, username: str) -> None:
u = self._find(username)
if not u:
raise ValueError("пользователь не найден")
if u["role"] == "superadmin" and self._superadmin_count() == 1:
raise ValueError("нельзя удалить последнего суперадмина")
self.users = [x for x in self.users if x["username"] != username]
self._sessions = {t: n for t, n in self._sessions.items() if n != username}
self._write_users()
auth_manager = AuthManager()
def require_role(request: Request, minimum: str) -> str:
"""Проверяет, что текущая сессия имеет роль не ниже minimum, иначе 403."""
role = auth_manager.role_for(request.cookies.get(COOKIE_NAME))
if not role_at_least(role, minimum):
raise HTTPException(403, "недостаточно прав")
return role
router = APIRouter(prefix="/api", tags=["auth"])
@router.post("/login")
async def login(request: Request) -> Response:
body = await request.json()
token = auth_manager.login(body.get("username", ""), body.get("password", ""))
if not token:
return JSONResponse({"detail": "неверный логин или пароль"}, status_code=401)
resp = JSONResponse({"ok": True})
resp.set_cookie(COOKIE_NAME, token, httponly=True, samesite="lax", max_age=7 * 86400)
return resp
@router.get("/me")
async def me(request: Request) -> Response:
u = auth_manager.user_for(request.cookies.get(COOKIE_NAME))
if not u:
return JSONResponse({"detail": "unauthorized"}, status_code=401)
return JSONResponse({"username": u["username"], "role": u["role"]})
@router.post("/verify-password")
async def verify_password(request: Request) -> Response:
"""Подтверждение пароля текущего оператора (для защищённых настроек)."""
body = await request.json()
token = request.cookies.get(COOKIE_NAME)
if not auth_manager.verify_token_password(token, body.get("password", "")):
return JSONResponse({"detail": "неверный пароль"}, status_code=403)
return JSONResponse({"ok": True})
@router.post("/logout")
async def logout(request: Request) -> Response:
auth_manager.logout(request.cookies.get(COOKIE_NAME))
resp = JSONResponse({"ok": True})
resp.delete_cookie(COOKIE_NAME)
return resp
# ── управление пользователями (только суперадмин) ───────────────
@router.get("/users")
async def users_list(request: Request) -> dict:
require_role(request, "superadmin")
return {"users": auth_manager.list_users()}
@router.post("/users")
async def users_create(request: Request) -> dict:
require_role(request, "superadmin")
body = await request.json()
try:
auth_manager.add_user(body.get("username", ""), body.get("password", ""),
body.get("role", "operator"))
except ValueError as e:
raise HTTPException(400, str(e))
return {"ok": True}
@router.put("/users/{username}")
async def users_update(username: str, request: Request) -> dict:
require_role(request, "superadmin")
body = await request.json()
try:
auth_manager.update_user(
username,
password=(body.get("password") or None),
role=(body.get("role") or None),
)
except ValueError as e:
raise HTTPException(400, str(e))
return {"ok": True}
@router.delete("/users/{username}")
async def users_delete(username: str, request: Request) -> dict:
require_role(request, "superadmin")
try:
auth_manager.delete_user(username)
except ValueError as e:
raise HTTPException(400, str(e))
return {"ok": True}