feat: Добавлена новая система авторизации с JWT токенами

- Удален Basic Auth, заменен на современную JWT авторизацию
- Добавлена страница входа с красивым интерфейсом
- Обновлен фронтенд для работы с JWT токенами
- Добавлены новые зависимости: PyJWT, passlib[bcrypt], jinja2
- Создан тестовый скрипт для проверки авторизации
- Добавлено руководство по миграции
- Обновлена документация и README
- Улучшен дизайн поля ввода пароля на странице входа

Автор: Сергей Антропов
Сайт: https://devops.org.ru
This commit is contained in:
Сергей Антропов
2025-08-17 18:29:06 +03:00
parent 3126ff4eb6
commit a979dd2838
10 changed files with 1238 additions and 98 deletions

297
app.py
View File

@@ -1,56 +1,137 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
LogBoard+ - Веб-панель для просмотра логов микросервисов
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import asyncio
import base64
import json
import os
import re
from typing import Optional, List, Dict
import secrets
from datetime import datetime, timedelta
from typing import Optional, List, Dict, Union
from pathlib import Path
import docker
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, status, Body
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, status, Body, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from pydantic import BaseModel
import jwt
from passlib.context import CryptContext
# Настройки приложения
APP_PORT = int(os.getenv("LOGBOARD_PORT", "9001"))
DEFAULT_TAIL = int(os.getenv("LOGBOARD_TAIL", "500"))
BASIC_USER = os.getenv("LOGBOARD_USER", "admin")
BASIC_PASS = os.getenv("LOGBOARD_PASS", "admin")
DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME") # filter by compose project
DEFAULT_PROJECTS = os.getenv("LOGBOARD_PROJECTS") # multiple projects filter
DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME")
DEFAULT_PROJECTS = os.getenv("LOGBOARD_PROJECTS")
SKIP_UNHEALTHY = os.getenv("LOGBOARD_SKIP_UNHEALTHY", "true").lower() == "true"
CONTAINER_LIST_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_LIST_TIMEOUT", "10"))
CONTAINER_INFO_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_INFO_TIMEOUT", "3"))
HEALTH_CHECK_TIMEOUT = int(os.getenv("LOGBOARD_HEALTH_CHECK_TIMEOUT", "2"))
security = HTTPBasic()
app = FastAPI(title="LogBoard+")
# Настройки безопасности
SECRET_KEY = os.getenv("SECRET_KEY", "your-secret-key-here-change-in-production")
ALGORITHM = "HS256"
ACCESS_TOKEN_EXPIRE_MINUTES = int(os.getenv("SESSION_TIMEOUT", "3600")) // 60 # 1 час по умолчанию
# Настройки пользователей
ADMIN_USERNAME = os.getenv("LOGBOARD_USER", "admin")
ADMIN_PASSWORD = os.getenv("LOGBOARD_PASS", "admin")
# Инициализация FastAPI
app = FastAPI(
title="LogBoard+",
description="Веб-панель для просмотра логов микросервисов",
version="1.0.0"
)
# Инициализация шаблонов
templates = Jinja2Templates(directory="templates")
# Инициализация безопасности
security = HTTPBearer()
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
# Инициализация Docker клиента
docker_client = docker.from_env()
# serve snapshots directory (downloadable files)
SNAP_DIR = os.getenv("LOGBOARD_SNAPSHOT_DIR", "./snapshots")
os.makedirs(SNAP_DIR, exist_ok=True)
app.mount("/snapshots", StaticFiles(directory=SNAP_DIR), name="snapshots")
docker_client = docker.from_env()
# Модели данных
class UserLogin(BaseModel):
username: str
password: str
# ---------- AUTH ----------
def check_basic(creds: HTTPBasicCredentials = Depends(security)):
if creds.username == BASIC_USER and creds.password == BASIC_PASS:
return creds
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"})
class Token(BaseModel):
access_token: str
token_type: str
def token_from_creds(creds: HTTPBasicCredentials) -> str:
return base64.b64encode(f"{creds.username}:{creds.password}".encode()).decode()
class TokenData(BaseModel):
username: Optional[str] = None
def verify_ws_token(token: str) -> bool:
# Функции для работы с паролями
def verify_password(plain_password: str, hashed_password: str) -> bool:
"""Проверяет пароль"""
return pwd_context.verify(plain_password, hashed_password)
def get_password_hash(password: str) -> str:
"""Хеширует пароль"""
return pwd_context.hash(password)
# Функции для работы с JWT токенами
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
"""Создает JWT токен"""
to_encode = data.copy()
if expires_delta:
expire = datetime.utcnow() + expires_delta
else:
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
to_encode.update({"exp": expire})
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
return encoded_jwt
def verify_token(token: str) -> Optional[str]:
"""Проверяет JWT токен и возвращает имя пользователя"""
try:
raw = base64.b64decode(token.encode(), validate=True).decode()
except Exception:
return False
return raw == f"{BASIC_USER}:{BASIC_PASS}"
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
username: str = payload.get("sub")
if username is None:
return None
return username
except jwt.PyJWTError:
return None
# Функция для проверки аутентификации
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> str:
"""Получает текущего пользователя из токена"""
token = credentials.credentials
username = verify_token(token)
if username is None:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Недействительный токен",
headers={"WWW-Authenticate": "Bearer"},
)
return username
# Функция для проверки пользователя
def authenticate_user(username: str, password: str) -> bool:
"""Аутентифицирует пользователя"""
if username == ADMIN_USERNAME:
# Для простоты используем прямое сравнение паролей
# В продакшене рекомендуется использовать хешированные пароли
return password == ADMIN_PASSWORD
return False
# ---------- DOCKER HELPERS ----------
def load_excluded_containers() -> List[str]:
@@ -274,30 +355,83 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool
# ---------- HTML ----------
INDEX_PATH = os.getenv("LOGBOARD_INDEX_HTML", "./templates/index.html")
def load_index_html(token: str) -> str:
def load_index_html() -> str:
"""Загружает HTML шаблон главной страницы"""
with open(INDEX_PATH, "r", encoding="utf-8") as f:
html = f.read()
return html.replace("__TOKEN__", token)
return f.read()
def load_login_html() -> str:
"""Загружает HTML шаблон страницы входа"""
login_path = "./templates/login.html"
with open(login_path, "r", encoding="utf-8") as f:
return f.read()
# ---------- ROUTES ----------
@app.get("/", response_class=HTMLResponse)
def index(creds: HTTPBasicCredentials = Depends(check_basic)):
token = token_from_creds(creds)
return HTMLResponse(
content=load_index_html(token),
headers={
"Cache-Control": "no-cache, no-store, must-revalidate",
"Pragma": "no-cache",
"Expires": "0"
}
)
async def index(request: Request):
"""Главная страница приложения"""
# Проверяем наличие токена в cookie
access_token = request.cookies.get("access_token")
if not access_token:
return RedirectResponse(url="/login")
# Проверяем валидность токена
username = verify_token(access_token)
if not username:
return RedirectResponse(url="/login")
return templates.TemplateResponse("index.html", {"request": request})
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""Страница входа"""
return templates.TemplateResponse("login.html", {"request": request})
@app.post("/api/auth/login", response_model=Token)
async def login(user_data: UserLogin, response: Response):
"""API для входа в систему"""
if authenticate_user(user_data.username, user_data.password):
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
access_token = create_access_token(
data={"sub": user_data.username}, expires_delta=access_token_expires
)
# Устанавливаем cookie с токеном
response.set_cookie(
key="access_token",
value=access_token,
httponly=True,
secure=False, # Установите True для HTTPS
samesite="lax",
max_age=ACCESS_TOKEN_EXPIRE_MINUTES * 60
)
return {"access_token": access_token, "token_type": "bearer"}
else:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Неверное имя пользователя или пароль",
headers={"WWW-Authenticate": "Bearer"},
)
@app.post("/api/auth/logout")
async def logout(response: Response):
"""API для выхода из системы"""
response.delete_cookie(key="access_token")
return {"message": "Успешный выход из системы"}
@app.get("/api/auth/me")
async def get_current_user_info(current_user: str = Depends(get_current_user)):
"""Получить информацию о текущем пользователе"""
return {"username": current_user}
@app.get("/healthz", response_class=PlainTextResponse)
def healthz():
"""Health check endpoint"""
return "ok"
@app.get("/api/logs/stats/{container_id}")
def api_logs_stats(container_id: str, _: HTTPBasicCredentials = Depends(check_basic)):
def api_logs_stats(container_id: str, current_user: str = Depends(get_current_user)):
"""Получить статистику логов контейнера"""
try:
# Ищем контейнер
@@ -344,10 +478,8 @@ def api_logs_stats(container_id: str, _: HTTPBasicCredentials = Depends(check_ba
return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/api/excluded-containers")
def api_get_excluded_containers(_: HTTPBasicCredentials = Depends(check_basic)):
"""
Получить список исключенных контейнеров
"""
def api_get_excluded_containers(current_user: str = Depends(get_current_user)):
"""Получить список исключенных контейнеров"""
return JSONResponse(
content={"excluded_containers": load_excluded_containers()},
headers={
@@ -360,11 +492,9 @@ def api_get_excluded_containers(_: HTTPBasicCredentials = Depends(check_basic)):
@app.post("/api/excluded-containers")
def api_update_excluded_containers(
containers: List[str] = Body(...),
_: HTTPBasicCredentials = Depends(check_basic)
current_user: str = Depends(get_current_user)
):
"""
Обновить список исключенных контейнеров
"""
"""Обновить список исключенных контейнеров"""
success = save_excluded_containers(containers)
if success:
return JSONResponse(
@@ -379,7 +509,7 @@ def api_update_excluded_containers(
raise HTTPException(status_code=500, detail="Ошибка сохранения списка")
@app.get("/api/projects")
def api_projects(_: HTTPBasicCredentials = Depends(check_basic)):
def api_projects(current_user: str = Depends(get_current_user)):
"""Получить список всех проектов Docker Compose"""
return JSONResponse(
content=get_all_projects(),
@@ -391,12 +521,12 @@ def api_projects(_: HTTPBasicCredentials = Depends(check_basic)):
)
@app.get("/api/services")
def api_services(projects: Optional[str] = Query(None), include_stopped: bool = Query(False),
_: HTTPBasicCredentials = Depends(check_basic)):
"""
Получить список контейнеров с поддержкой множественных проектов
projects: список проектов через запятую (например: "project1,project2")
"""
def api_services(
projects: Optional[str] = Query(None),
include_stopped: bool = Query(False),
current_user: str = Depends(get_current_user)
):
"""Получить список контейнеров с поддержкой множественных проектов"""
project_list = None
if projects:
project_list = [p.strip() for p in projects.split(",") if p.strip()]
@@ -416,11 +546,12 @@ def api_services(projects: Optional[str] = Query(None), include_stopped: bool =
@app.post("/api/snapshot")
def api_snapshot(
creds: HTTPBasicCredentials = Depends(check_basic),
current_user: str = Depends(get_current_user),
container_id: str = Body(..., embed=True),
service: str = Body("", embed=True),
content: str = Body("", embed=True),
):
"""Сохранить снимок логов"""
# Save posted content as a snapshot file
safe_service = re.sub(r"[^a-zA-Z0-9_.-]+", "_", service or container_id[:12])
ts = os.getenv("TZ_TS") or ""
@@ -434,19 +565,23 @@ def api_snapshot(
return {"file": fname, "url": url}
# WebSocket: verify token (?token=base64(user:pass))
# WebSocket: verify token (?token=base64(user:pass)). Supports auto-reconnect by service label.
@app.websocket("/ws/logs/{container_id}")
async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
service: Optional[str] = None, project: Optional[str] = None):
"""Упрощенный WebSocket для получения логов контейнера"""
"""WebSocket для получения логов контейнера"""
# Принимаем соединение
await ws.accept()
# Проверяем токен
if not token or not verify_ws_token(token):
await ws.send_text("ERROR: unauthorized")
if not token:
await ws.send_text("ERROR: token required")
await ws.close()
return
username = verify_token(token)
if not username:
await ws.send_text("ERROR: invalid token")
await ws.close()
return
@@ -499,20 +634,23 @@ async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, to
except:
pass
if __name__ == "__main__":
import uvicorn
print(f"LogBoard+ http://0.0.0.0:{APP_PORT}")
uvicorn.run(app, host="0.0.0.0", port=APP_PORT)
# WebSocket: fan-in by compose service (aggregate all replicas), prefixing with short container id
@app.websocket("/ws/fan/{service_name}")
async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
project: Optional[str] = None):
await ws.accept()
if not token or not verify_ws_token(token):
await ws.send_text("ERROR: unauthorized")
await ws.close(); return
# Проверяем токен
if not token:
await ws.send_text("ERROR: token required")
await ws.close()
return
username = verify_token(token)
if not username:
await ws.send_text("ERROR: invalid token")
await ws.close()
return
# Track active streaming tasks by container id
active = {}
@@ -589,15 +727,23 @@ async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, tok
except Exception:
pass
# WebSocket: fan-in for multiple compose services (comma-separated), optional project filter.
@app.websocket("/ws/fan_group")
async def ws_fan_group(ws: WebSocket, services: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
project: Optional[str] = None):
await ws.accept()
if not token or not verify_ws_token(token):
await ws.send_text("ERROR: unauthorized")
await ws.close(); return
# Проверяем токен
if not token:
await ws.send_text("ERROR: token required")
await ws.close()
return
username = verify_token(token)
if not username:
await ws.send_text("ERROR: invalid token")
await ws.close()
return
svc_set = {s.strip() for s in services.split(",") if s.strip()}
if not svc_set:
@@ -673,3 +819,8 @@ async def ws_fan_group(ws: WebSocket, services: str, tail: int = DEFAULT_TAIL, t
await ws.close()
except Exception:
pass
if __name__ == "__main__":
import uvicorn
print(f"LogBoard+ http://0.0.0.0:{APP_PORT}")
uvicorn.run(app, host="0.0.0.0", port=APP_PORT)