From a979dd2838f4d7ec1f640994d5f9db5389c2e55f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A1=D0=B5=D1=80=D0=B3=D0=B5=D0=B9=20=D0=90=D0=BD=D1=82?= =?UTF-8?q?=D1=80=D0=BE=D0=BF=D0=BE=D0=B2?= Date: Sun, 17 Aug 2025 18:29:06 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=D0=B0=20=D0=BD=D0=BE=D0=B2=D0=B0=D1=8F=20=D1=81?= =?UTF-8?q?=D0=B8=D1=81=D1=82=D0=B5=D0=BC=D0=B0=20=D0=B0=D0=B2=D1=82=D0=BE?= =?UTF-8?q?=D1=80=D0=B8=D0=B7=D0=B0=D1=86=D0=B8=D0=B8=20=D1=81=20JWT=20?= =?UTF-8?q?=D1=82=D0=BE=D0=BA=D0=B5=D0=BD=D0=B0=D0=BC=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Удален Basic Auth, заменен на современную JWT авторизацию - Добавлена страница входа с красивым интерфейсом - Обновлен фронтенд для работы с JWT токенами - Добавлены новые зависимости: PyJWT, passlib[bcrypt], jinja2 - Создан тестовый скрипт для проверки авторизации - Добавлено руководство по миграции - Обновлена документация и README - Улучшен дизайн поля ввода пароля на странице входа Автор: Сергей Антропов Сайт: https://devops.org.ru --- MIGRATION_GUIDE.md | 151 ++++++++++++++ Makefile | 14 +- README.md | 41 +++- app.py | 297 +++++++++++++++++++++------- docker-compose.yml | 2 +- env.example | 3 +- requirements.txt | 9 + templates/index.html | 158 +++++++++++++-- templates/login.html | 462 +++++++++++++++++++++++++++++++++++++++++++ test_auth.py | 199 +++++++++++++++++++ 10 files changed, 1238 insertions(+), 98 deletions(-) create mode 100644 MIGRATION_GUIDE.md create mode 100644 templates/login.html create mode 100644 test_auth.py diff --git a/MIGRATION_GUIDE.md b/MIGRATION_GUIDE.md new file mode 100644 index 0000000..3829715 --- /dev/null +++ b/MIGRATION_GUIDE.md @@ -0,0 +1,151 @@ +# Руководство по миграции с Basic Auth на JWT + +## 🔄 Миграция с Basic Auth на JWT авторизацию + +Это руководство поможет вам перейти с устаревшей системы Basic Auth на современную систему авторизации на основе JWT токенов. + +## 📋 Что изменилось + +### ✅ Новые возможности: +- **JWT токены** вместо Basic Auth +- **Страница входа** с современным интерфейсом +- **Безопасные сессии** с автоматическим истечением +- **Защищенные API** эндпоинты +- **Автоматическое перенаправление** на страницу входа + +### 🔧 Технические изменения: +- Обновлен `app.py` с новой системой авторизации +- Добавлена страница входа `templates/login.html` +- Обновлен фронтенд для работы с JWT токенами +- Добавлены новые зависимости в `requirements.txt` +- Обновлены переменные окружения + +## 🚀 Быстрая миграция + +### 1. Обновление зависимостей +```bash +# Остановите текущий сервис +make down + +# Обновите зависимости +pip install -r requirements.txt +# или для Docker +docker compose build --no-cache +``` + +### 2. Обновление переменных окружения +```bash +# Обновите .env файл +LOGBOARD_USER=admin # Ваше имя пользователя +LOGBOARD_PASS=admin123 # Ваш пароль +SECRET_KEY=your-secret-key # Уникальный секретный ключ +AUTH_METHOD=jwt # Изменено с basic на jwt +SESSION_TIMEOUT=3600 # Время жизни сессии в секундах +``` + +### 3. Запуск обновленного сервиса +```bash +# Запустите обновленный сервис +make up + +# Проверьте работу +make test-auth +``` + +## 🔐 Настройка безопасности + +### Рекомендуемые настройки для продакшена: + +```bash +# Генерируйте уникальные ключи +SECRET_KEY=$(openssl rand -hex 32) +ENCRYPTION_KEY=$(openssl rand -hex 32) + +# Используйте сложные пароли +LOGBOARD_PASS=YourComplexPassword123! + +# Настройте время жизни сессии +SESSION_TIMEOUT=3600 # 1 час +``` + +### Переменные окружения: + +| Переменная | Описание | Значение по умолчанию | +|------------|----------|----------------------| +| `LOGBOARD_USER` | Имя пользователя | `admin` | +| `LOGBOARD_PASS` | Пароль | `admin123` | +| `SECRET_KEY` | Секретный ключ для JWT | `your-secret-key-here` | +| `AUTH_METHOD` | Метод авторизации | `jwt` | +| `SESSION_TIMEOUT` | Время жизни сессии (сек) | `3600` | + +## 🧪 Тестирование + +### Автоматическое тестирование: +```bash +# Запустите тесты авторизации +make test-auth +``` + +### Ручное тестирование: +1. Откройте браузер: `http://localhost:9001` +2. Должны быть перенаправлены на страницу входа +3. Введите логин и пароль +4. Проверьте доступ к панели управления + +## 🔄 API изменения + +### Новые эндпоинты: +- `POST /api/auth/login` - вход в систему +- `POST /api/auth/logout` - выход из системы +- `GET /api/auth/me` - информация о текущем пользователе + +### Изменения в существующих эндпоинтах: +Все API эндпоинты теперь требуют JWT токен в заголовке: +``` +Authorization: Bearer +``` + +### WebSocket изменения: +WebSocket соединения теперь используют JWT токены вместо base64 токенов. + +## 🐛 Устранение неполадок + +### Проблема: "Unauthorized" ошибки +**Решение:** Проверьте правильность логина и пароля в `.env` файле + +### Проблема: Токен истекает слишком быстро +**Решение:** Увеличьте `SESSION_TIMEOUT` в настройках + +### Проблема: Не работает WebSocket +**Решение:** Убедитесь, что JWT токен передается в URL параметре `token` + +### Проблема: Страница входа не загружается +**Решение:** Проверьте, что `templates/login.html` существует и доступен + +## 📝 Логи изменений + +### Версия 2.0.0: +- ✅ Удален Basic Auth +- ✅ Добавлена JWT авторизация +- ✅ Создана страница входа +- ✅ Обновлен фронтенд +- ✅ Добавлены тесты авторизации +- ✅ Обновлена документация + +## 🆘 Поддержка + +Если у вас возникли проблемы с миграцией: + +1. Проверьте логи: `make logs` +2. Запустите тесты: `make test-auth` +3. Проверьте настройки в `.env` файле +4. Убедитесь, что все зависимости установлены + +## 📞 Контакты + +**Автор:** Сергей Антропов +**Сайт:** https://devops.org.ru + +--- + +**Примечание:** После миграции старые Basic Auth токены больше не будут работать. Все пользователи должны будут войти заново через новую систему авторизации. diff --git a/Makefile b/Makefile index 9ee9ce7..a381bbf 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # Автор: Сергей Антропов # Сайт: https://devops.org.ru -.PHONY: help setup build up down restart logs clean status ps shell generate +.PHONY: help setup build up down restart logs clean status ps shell generate test-auth # Переменные COMPOSE_FILE = docker-compose.yml @@ -109,4 +109,16 @@ rebuild: ## Пересобрать и запустить сервисы @echo "$(GREEN)Сервисы пересобраны и запущены!$(NC)" @echo "$(YELLOW)Приложение доступно по адресу: http://localhost:9001$(NC)" +test-auth: ## Тестирование новой системы авторизации + @echo "$(GREEN)Тестирование системы авторизации...$(NC)" + @if [ ! -f test_auth.py ]; then \ + echo "$(RED)Файл test_auth.py не найден!$(NC)"; \ + exit 1; \ + fi + @echo "$(YELLOW)Убедитесь, что сервис запущен: make up$(NC)" + @echo "$(YELLOW)Ожидание запуска сервиса...$(NC)" + @sleep 5 + python3 test_auth.py + @echo "$(GREEN)Тестирование завершено!$(NC)" + diff --git a/README.md b/README.md index 4dfb018..add455d 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,34 @@ # LogBoard+ -Веб-интерфейс для просмотра логов Docker контейнеров в реальном времени. +Веб-интерфейс для просмотра логов Docker контейнеров в реальном времени с современной системой авторизации. + +## 🔐 Новая система авторизации + +LogBoard+ теперь использует современную систему авторизации на основе JWT токенов вместо Basic Auth: + +### Основные изменения: +- **JWT токены** вместо Basic Auth +- **Страница входа** с красивым интерфейсом +- **Безопасные сессии** с автоматическим истечением +- **Защищенные API** эндпоинты +- **Автоматическое перенаправление** на страницу входа при отсутствии авторизации + +### Как использовать: +1. **Откройте LogBoard+** в браузере +2. **Автоматически перенаправление** на страницу входа +3. **Введите логин и пароль** (по умолчанию: admin/admin123) +4. **Получите доступ** к панели управления логами +5. **Используйте кнопку "Выйти"** для завершения сессии + +### Настройка пользователей: +В файле `.env` или `docker-compose.yml`: +```bash +LOGBOARD_USER=admin # Имя пользователя +LOGBOARD_PASS=admin123 # Пароль +SECRET_KEY=your-secret-key # Секретный ключ для JWT +SESSION_TIMEOUT=3600 # Время жизни сессии в секундах +``` ## Исправления дублирования строк и правильных переносов строк в режимах Single View и MultiView @@ -153,8 +180,8 @@ http://localhost:9001 | Переменная | Описание | Значение по умолчанию | |------------|----------|----------------------| | `LOGBOARD_PORT` | Порт веб-интерфейса | `9001` | -| `LOGBOARD_USER` | Имя пользователя для Basic Auth | `admin` | -| `LOGBOARD_PASS` | Пароль для Basic Auth | `s3cret-change-me` | +| `LOGBOARD_USER` | Имя пользователя для авторизации | `admin` | +| `LOGBOARD_PASS` | Пароль для авторизации | `admin123` | | `LOGBOARD_TAIL` | Количество строк истории | `500` | | `LOGBOARD_SNAPSHOT_DIR` | Директория для снимков | `/app/snapshots` | | `LOGBOARD_INDEX_HTML` | Путь к HTML шаблону | `./templates/index.html` | @@ -169,7 +196,7 @@ http://localhost:9001 | `SECRET_KEY` | Секретный ключ для шифрования | `your-secret-key-here` | | `ENCRYPTION_KEY` | Ключ шифрования | `your-encryption-key-here` | -**⚠️ Важно:** Измените значения `LOGBOARD_PASS`, `SECRET_KEY` и `ENCRYPTION_KEY` в продакшене! +**⚠️ Важно:** Измените значения `LOGBOARD_PASS`, `SECRET_KEY` и `ENCRYPTION_KEY` в продакшене! Для безопасности используйте сложные пароли и уникальные секретные ключи. ### Работа с множественными проектами @@ -265,11 +292,11 @@ docker compose up --build -d http://localhost:9001 ``` -По умолчанию логин/пароль для Basic Auth задаются в `docker-compose.yml`: +По умолчанию логин/пароль для авторизации задаются в `docker-compose.yml`: ```yaml environment: - - LB_USER=admin - - LB_PASS=admin + - LOGBOARD_USER=admin + - LOGBOARD_PASS=admin123 ``` --- diff --git a/app.py b/app.py index bf70aec..0036146 100644 --- a/app.py +++ b/app.py @@ -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) diff --git a/docker-compose.yml b/docker-compose.yml index 5516f9b..13f809f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -22,7 +22,7 @@ services: CONNECTION_TIMEOUT: '30' READ_TIMEOUT: '60' AUTH_ENABLED: 'true' - AUTH_METHOD: basic + AUTH_METHOD: jwt SESSION_TIMEOUT: '3600' NOTIFICATIONS_ENABLED: 'false' SMTP_HOST: '' diff --git a/env.example b/env.example index a82e6ae..f0be156 100644 --- a/env.example +++ b/env.example @@ -63,8 +63,9 @@ LOGBOARD_HEALTH_CHECK_TIMEOUT=2 # Настройки аутентификации AUTH_ENABLED=true -AUTH_METHOD=basic +AUTH_METHOD=jwt SESSION_TIMEOUT=3600 +SECRET_KEY=your-secret-key-here-change-in-production # Настройки уведомлений NOTIFICATIONS_ENABLED=false diff --git a/requirements.txt b/requirements.txt index 86a7dbf..87d1bc2 100644 --- a/requirements.txt +++ b/requirements.txt @@ -21,3 +21,12 @@ websockets==12.0 # Зависимости для Docker SDK requests==2.31.0 urllib3==2.1.0 + +# JWT токены +PyJWT==2.8.0 + +# Хеширование паролей +passlib[bcrypt]==1.7.4 + +# Шаблоны Jinja2 +jinja2==3.1.2 diff --git a/templates/index.html b/templates/index.html index 6ca4c2e..ba8a4b4 100644 --- a/templates/index.html +++ b/templates/index.html @@ -4,7 +4,7 @@ LogBoard+ - + + + +
+ Theme + +
+ + + + + + diff --git a/test_auth.py b/test_auth.py new file mode 100644 index 0000000..72c75d8 --- /dev/null +++ b/test_auth.py @@ -0,0 +1,199 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +""" +Тестовый скрипт для проверки новой системы авторизации LogBoard+ +Автор: Сергей Антропов +Сайт: https://devops.org.ru +""" + +import requests +import json +import sys +from datetime import datetime + +# Настройки +BASE_URL = "http://localhost:9001" +USERNAME = "admin" +PASSWORD = "admin123" + +def test_login(): + """Тест входа в систему""" + print("🔐 Тестирование входа в систему...") + + try: + response = requests.post( + f"{BASE_URL}/api/auth/login", + json={ + "username": USERNAME, + "password": PASSWORD + }, + headers={"Content-Type": "application/json"} + ) + + if response.status_code == 200: + data = response.json() + print(f"✅ Успешный вход! Получен токен: {data['access_token'][:20]}...") + return data['access_token'] + else: + print(f"❌ Ошибка входа: {response.status_code} - {response.text}") + return None + + except Exception as e: + print(f"❌ Ошибка соединения: {e}") + return None + +def test_protected_endpoint(token): + """Тест защищенного эндпоинта""" + print("\n🔒 Тестирование защищенного эндпоинта...") + + try: + response = requests.get( + f"{BASE_URL}/api/auth/me", + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 200: + data = response.json() + print(f"✅ Доступ к защищенному эндпоинту: {data}") + return True + else: + print(f"❌ Ошибка доступа: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"❌ Ошибка соединения: {e}") + return False + +def test_projects_api(token): + """Тест API проектов""" + print("\n📋 Тестирование API проектов...") + + try: + response = requests.get( + f"{BASE_URL}/api/projects", + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 200: + projects = response.json() + print(f"✅ Получен список проектов: {projects}") + return True + else: + print(f"❌ Ошибка получения проектов: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"❌ Ошибка соединения: {e}") + return False + +def test_services_api(token): + """Тест API сервисов""" + print("\n🐳 Тестирование API сервисов...") + + try: + response = requests.get( + f"{BASE_URL}/api/services", + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 200: + services = response.json() + print(f"✅ Получен список сервисов: {len(services)} контейнеров") + return True + else: + print(f"❌ Ошибка получения сервисов: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"❌ Ошибка соединения: {e}") + return False + +def test_logout(token): + """Тест выхода из системы""" + print("\n🚪 Тестирование выхода из системы...") + + try: + response = requests.post( + f"{BASE_URL}/api/auth/logout", + headers={"Authorization": f"Bearer {token}"} + ) + + if response.status_code == 200: + print("✅ Успешный выход из системы") + return True + else: + print(f"❌ Ошибка выхода: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"❌ Ошибка соединения: {e}") + return False + +def test_unauthorized_access(): + """Тест доступа без авторизации""" + print("\n🚫 Тестирование доступа без авторизации...") + + try: + response = requests.get(f"{BASE_URL}/api/projects") + + if response.status_code == 401: + print("✅ Правильно отклонен доступ без авторизации") + return True + else: + print(f"❌ Неожиданный ответ: {response.status_code} - {response.text}") + return False + + except Exception as e: + print(f"❌ Ошибка соединения: {e}") + return False + +def main(): + """Основная функция тестирования""" + print("🧪 Тестирование новой системы авторизации LogBoard+") + print("=" * 60) + print(f"📅 Время тестирования: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f"🌐 URL: {BASE_URL}") + print(f"👤 Пользователь: {USERNAME}") + print("=" * 60) + + # Проверяем доступность сервера + try: + response = requests.get(f"{BASE_URL}/healthz") + if response.status_code != 200: + print("❌ Сервер недоступен") + sys.exit(1) + print("✅ Сервер доступен") + except Exception as e: + print(f"❌ Сервер недоступен: {e}") + sys.exit(1) + + # Тестируем вход + token = test_login() + if not token: + print("❌ Тест провален: не удалось войти в систему") + sys.exit(1) + + # Тестируем защищенные эндпоинты + success = True + success &= test_protected_endpoint(token) + success &= test_projects_api(token) + success &= test_services_api(token) + + # Тестируем выход + success &= test_logout(token) + + # Тестируем доступ без авторизации + success &= test_unauthorized_access() + + print("\n" + "=" * 60) + if success: + print("🎉 Все тесты пройдены успешно!") + print("✅ Новая система авторизации работает корректно") + else: + print("❌ Некоторые тесты провалились") + print("🔧 Проверьте настройки и логи сервера") + + print("=" * 60) + +if __name__ == "__main__": + main()