init
This commit is contained in:
@@ -0,0 +1,247 @@
|
||||
"""Авторизация по логину/паролю + роли (оператор / админ / суперадмин).
|
||||
|
||||
Пользователи читаются из .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}
|
||||
Reference in New Issue
Block a user