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

151
MIGRATION_GUIDE.md Normal file
View File

@ -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 <your-jwt-token>
```
### 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 токены больше не будут работать. Все пользователи должны будут войти заново через новую систему авторизации.

View File

@ -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)"

View File

@ -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
```
---

293
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:
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
return raw == f"{BASIC_USER}:{BASIC_PASS}"
# ---------- 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)

View File

@ -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: ''

View File

@ -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

View File

@ -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

View File

@ -4,7 +4,7 @@
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>LogBoard+</title>
<meta name="x-token" content="__TOKEN__"/>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
/* THEME TOKENS */
@ -1336,7 +1336,6 @@ footer{position:fixed;right:10px;bottom:10px;opacity:.6;font-size:11px}
console.log('LogBoard+ script loaded - VERSION 2');
const state = {
token: (document.querySelector('meta[name="x-token"]')?.content)||'',
services: [],
current: null,
open: {}, // id -> {ws, logEl, wrapEl, counters, pausedBuffer:[], serviceName}
@ -1714,8 +1713,24 @@ async function fetchProjects(){
try {
console.log('Fetching projects...');
const url = new URL(location.origin + '/api/projects');
const res = await fetch(url);
const token = localStorage.getItem('access_token');
if (!token) {
console.error('No access token found');
window.location.href = '/login';
return;
}
const res = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!res.ok){
if (res.status === 401) {
console.error('Unauthorized, redirecting to login');
window.location.href = '/login';
return;
}
console.error('Failed to fetch projects:', res.status, res.statusText);
return;
}
@ -1835,8 +1850,23 @@ function getSelectedProjects() {
// Функции для работы с исключенными контейнерами
async function loadExcludedContainers() {
try {
const response = await fetch('/api/excluded-containers');
const token = localStorage.getItem('access_token');
if (!token) {
console.error('No access token found');
return [];
}
const response = await fetch('/api/excluded-containers', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
if (response.status === 401) {
console.error('Unauthorized, redirecting to login');
window.location.href = '/login';
return [];
}
console.error('Ошибка загрузки исключенных контейнеров:', response.status);
return [];
}
@ -1850,15 +1880,27 @@ async function loadExcludedContainers() {
async function saveExcludedContainers(containers) {
try {
const token = localStorage.getItem('access_token');
if (!token) {
console.error('No access token found');
return false;
}
const response = await fetch('/api/excluded-containers', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(containers)
});
if (!response.ok) {
if (response.status === 401) {
console.error('Unauthorized, redirecting to login');
window.location.href = '/login';
return false;
}
console.error('Ошибка сохранения исключенных контейнеров:', response.status);
return false;
}
@ -2381,8 +2423,24 @@ async function fetchServices(){
url.searchParams.set('projects', selectedProjects.join(','));
}
const res = await fetch(url);
const token = localStorage.getItem('access_token');
if (!token) {
console.error('No access token found');
window.location.href = '/login';
return;
}
const res = await fetch(url, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!res.ok){
if (res.status === 401) {
console.error('Unauthorized, redirecting to login');
window.location.href = '/login';
return;
}
console.error('Auth failed (HTTP):', res.status, res.statusText);
alert('Auth failed (HTTP)');
return;
@ -2404,7 +2462,7 @@ async function fetchServices(){
function wsUrl(containerId, service, project){
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const tail = els.tail.value || '500';
const token = encodeURIComponent(state.token || '');
const token = encodeURIComponent(localStorage.getItem('access_token') || '');
const sp = service?`&service=${encodeURIComponent(service)}`:'';
const pj = project?`&project=${encodeURIComponent(project)}`:'';
if (els.aggregate && els.aggregate.checked && service){
@ -2441,9 +2499,28 @@ async function sendSnapshot(id){
console.log('Saving snapshot with content length:', text.length);
const token = localStorage.getItem('access_token');
if (!token) {
console.error('No access token found');
window.location.href = '/login';
return;
}
const payload = {container_id: id, service: o.serviceName || id, content: text};
const res = await fetch('/api/snapshot', {method:'POST', headers:{'Content-Type':'application/json'}, body: JSON.stringify(payload)});
const res = await fetch('/api/snapshot', {
method:'POST',
headers:{
'Content-Type':'application/json',
'Authorization': `Bearer ${token}`
},
body: JSON.stringify(payload)
});
if (!res.ok){
if (res.status === 401) {
console.error('Unauthorized, redirecting to login');
window.location.href = '/login';
return;
}
console.error('Snapshot failed:', res.status, res.statusText);
alert('snapshot failed');
return;
@ -3545,7 +3622,7 @@ els.copyFab.addEventListener('click', async ()=>{
function fanGroupUrl(servicesCsv, project){
const proto = location.protocol === 'https:' ? 'wss' : 'ws';
const tail = els.tail.value || '500';
const token = encodeURIComponent(state.token || '');
const token = encodeURIComponent(localStorage.getItem('access_token') || '');
const pj = project?`&project=${encodeURIComponent(project)}`:'';
return `${proto}://${location.host}/ws/fan_group?services=${encodeURIComponent(servicesCsv)}&tail=${tail}&token=${token}${pj}`;
}
@ -3613,7 +3690,17 @@ if (els.groupBtn && els.groupBtn.onclick !== null) {
// Функция для обновления счетчиков через Ajax
async function updateCounters(containerId) {
try {
const response = await fetch(`/api/logs/stats/${containerId}`);
const token = localStorage.getItem('access_token');
if (!token) {
console.error('No access token found');
return;
}
const response = await fetch(`/api/logs/stats/${containerId}`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
const stats = await response.json();
const cdbg = document.querySelector('.cdbg');
@ -4176,12 +4263,24 @@ document.addEventListener('DOMContentLoaded', () => {
// Обработчик для кнопки выхода
if (els.logoutBtn) {
els.logoutBtn.addEventListener('click', () => {
els.logoutBtn.addEventListener('click', async () => {
if (confirm('Вы уверены, что хотите выйти?')) {
try {
// Вызываем API для выхода
await fetch('/api/auth/logout', {
method: 'POST',
headers: {
'Authorization': `Bearer ${localStorage.getItem('access_token')}`
}
});
} catch (error) {
console.error('Logout error:', error);
} finally {
// Очищаем localStorage
localStorage.clear();
localStorage.removeItem('access_token');
// Перенаправляем на страницу входа
window.location.href = '/';
window.location.href = '/login';
}
}
});
}
@ -4398,8 +4497,37 @@ window.addEventListener('keydown', async (e)=>{
// Инициализация
(async function init() {
console.log('Initializing LogBoard+...');
console.log('Elements found:', {
// Проверяем авторизацию
const token = localStorage.getItem('access_token');
if (!token) {
console.log('No access token found, redirecting to login');
window.location.href = '/login';
return;
}
// Проверяем валидность токена
try {
const response = await fetch('/api/auth/me', {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (!response.ok) {
console.log('Invalid token, redirecting to login');
localStorage.removeItem('access_token');
window.location.href = '/login';
return;
}
} catch (error) {
console.error('Error checking auth:', error);
localStorage.removeItem('access_token');
window.location.href = '/login';
return;
}
console.log('Elements found:', {
containerList: !!els.containerList,
logTitle: !!els.logTitle,
logContent: !!els.logContent,

462
templates/login.html Normal file
View File

@ -0,0 +1,462 @@
<!DOCTYPE html>
<html lang="ru" data-theme="dark">
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width,initial-scale=1"/>
<title>LogBoard+ - Вход</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" rel="stylesheet">
<style>
/* THEME TOKENS */
:root{
--bg:#0e0f13; --panel:#151821; --muted:#8b94a8; --accent:#7aa2f7; --ok:#9ece6a; --warn:#e0af68; --err:#f7768e; --fg:#e5e9f0;
--border:#2a2f3a; --tab:#1b2030; --tab-active:#22283a; --chip:#2b3142; --link:#9ab8ff;
}
:root[data-theme="light"]{
--bg:#f7f9fc; --panel:#ffffff; --muted:#667085; --accent:#3b82f6; --ok:#15803d; --warn:#b45309; --err:#b91c1c; --fg:#0f172a;
--border:#e5e7eb; --tab:#eef2ff; --tab-active:#dbeafe; --chip:#eef2f7; --link:#1d4ed8;
}
*{box-sizing:border-box}
html,body{height:100%; margin: 0; padding: 0;}
body{background:var(--bg);color:var(--fg);font:13px/1.45 ui-monospace,Menlo,Consolas,monospace;}
.login-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
padding: 20px;
}
.login-card {
background: var(--panel);
border: 1px solid var(--border);
border-radius: 12px;
padding: 40px;
width: 100%;
max-width: 400px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3);
}
.login-header {
text-align: center;
margin-bottom: 32px;
}
.login-logo {
font-size: 32px;
color: var(--accent);
margin-bottom: 16px;
}
.login-title {
font-size: 24px;
font-weight: 600;
color: var(--fg);
margin: 0 0 8px 0;
}
.login-subtitle {
font-size: 14px;
color: var(--muted);
margin: 0;
}
.login-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-group {
display: flex;
flex-direction: column;
gap: 8px;
}
.form-label {
font-size: 12px;
font-weight: 500;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.form-input {
background: var(--chip);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 16px;
font-size: 14px;
color: var(--fg);
transition: all 0.2s ease;
font-family: inherit;
}
.form-input:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(122, 162, 247, 0.1);
}
.form-input::placeholder {
color: var(--muted);
}
.password-input-wrapper {
position: relative;
width: 100%;
}
.password-toggle {
position: absolute;
right: 8px; /* Ближе к краю для всех устройств */
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: var(--muted);
cursor: pointer;
padding: 6px;
border-radius: 4px;
transition: all 0.2s ease;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
min-width: 24px;
min-height: 24px;
}
.password-toggle:hover {
color: var(--fg);
background: var(--chip);
}
.password-toggle:active {
transform: translateY(-50%) scale(0.95);
}
.password-input-wrapper .form-input {
padding-right: 40px; /* Место для кнопки */
width: 100%; /* Поле на всю ширину */
}
.login-button {
background: var(--accent);
color: #0b0d12;
border: none;
border-radius: 8px;
padding: 14px 24px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
font-family: inherit;
margin-top: 8px;
}
.login-button:hover {
background: #6b8fd8;
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(122, 162, 247, 0.3);
}
.login-button:disabled {
background: var(--muted);
cursor: not-allowed;
transform: none;
box-shadow: none;
}
.error-message {
background: rgba(247, 118, 142, 0.1);
border: 1px solid var(--err);
border-radius: 8px;
padding: 12px 16px;
font-size: 14px;
color: var(--err);
display: none;
}
.error-message.show {
display: block;
}
.loading-spinner {
display: none;
width: 16px;
height: 16px;
border: 2px solid transparent;
border-top: 2px solid currentColor;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-right: 8px;
}
.loading-spinner.show {
display: inline-block;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.footer {
text-align: center;
margin-top: 32px;
padding-top: 24px;
border-top: 1px solid var(--border);
font-size: 12px;
color: var(--muted);
}
.footer a {
color: var(--link);
text-decoration: none;
}
.footer a:hover {
text-decoration: underline;
}
/* Theme toggle */
.theme-toggle {
position: fixed;
top: 20px;
right: 20px;
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--muted);
background: var(--panel);
border: 1px solid var(--border);
border-radius: 8px;
padding: 8px 12px;
}
.theme-toggle input {
appearance: none;
width: 36px;
height: 20px;
border-radius: 999px;
position: relative;
background: var(--chip);
border: 1px solid var(--border);
cursor: pointer;
transition: background 0.2s ease;
}
.theme-toggle input::after {
content: "";
position: absolute;
top: 2px;
left: 2px;
width: 14px;
height: 14px;
border-radius: 50%;
background: var(--fg);
transition: transform 0.2s ease;
}
.theme-toggle input:checked::after {
transform: translateX(16px);
}
.theme-toggle input:checked {
background: var(--accent);
}
@media (max-width: 480px) {
.login-card {
padding: 24px;
margin: 16px;
}
.login-title {
font-size: 20px;
}
.theme-toggle {
top: 16px;
right: 16px;
padding: 6px 10px;
font-size: 11px;
}
}
</style>
</head>
<body>
<div class="theme-toggle">
<span>Theme</span>
<input id="themeSwitch" type="checkbox" />
</div>
<div class="login-container">
<div class="login-card">
<div class="login-header">
<div class="login-logo">
<i class="fas fa-terminal"></i>
</div>
<h1 class="login-title">LogBoard+</h1>
<p class="login-subtitle">Веб-панель для просмотра логов микросервисов</p>
</div>
<form class="login-form" id="loginForm">
<div class="error-message" id="errorMessage"></div>
<div class="form-group">
<label for="username" class="form-label">Имя пользователя</label>
<input
type="text"
id="username"
name="username"
class="form-input"
placeholder="Введите имя пользователя"
required
autocomplete="username"
>
</div>
<div class="form-group">
<label for="password" class="form-label">Пароль</label>
<div class="password-input-wrapper">
<input
type="password"
id="password"
name="password"
class="form-input"
placeholder="Введите пароль"
required
autocomplete="current-password"
>
<button type="button" class="password-toggle" id="passwordToggle">
<i class="fas fa-eye"></i>
</button>
</div>
</div>
<button type="submit" class="login-button" id="loginButton">
<span class="loading-spinner" id="loadingSpinner"></span>
<span id="buttonText">Войти</span>
</button>
</form>
<div class="footer">
<p>Автор: <a href="https://devops.org.ru" target="_blank">Сергей Антропов</a></p>
</div>
</div>
</div>
<script>
// Theme toggle
(function initTheme(){
const saved = localStorage.lb_theme || 'dark';
document.documentElement.setAttribute('data-theme', saved);
document.getElementById('themeSwitch').checked = (saved==='light');
document.getElementById('themeSwitch').addEventListener('change', ()=>{
const t = document.getElementById('themeSwitch').checked ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', t);
localStorage.lb_theme = t;
});
})();
// Password toggle
document.getElementById('passwordToggle').addEventListener('click', function() {
const passwordInput = document.getElementById('password');
const icon = this.querySelector('i');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
icon.className = 'fas fa-eye-slash';
} else {
passwordInput.type = 'password';
icon.className = 'fas fa-eye';
}
});
// Login form
document.getElementById('loginForm').addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const loginButton = document.getElementById('loginButton');
const buttonText = document.getElementById('buttonText');
const loadingSpinner = document.getElementById('loadingSpinner');
const errorMessage = document.getElementById('errorMessage');
// Validation
if (!username || !password) {
showError('Пожалуйста, заполните все поля');
return;
}
// Show loading state
loginButton.disabled = true;
buttonText.textContent = 'Вход...';
loadingSpinner.classList.add('show');
hideError();
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
username: username,
password: password
})
});
if (response.ok) {
const data = await response.json();
// Store token in localStorage
localStorage.setItem('access_token', data.access_token);
// Redirect to main page
window.location.href = '/';
} else {
const errorData = await response.json();
showError(errorData.detail || 'Ошибка входа в систему');
}
} catch (error) {
console.error('Login error:', error);
showError('Ошибка соединения с сервером');
} finally {
// Reset loading state
loginButton.disabled = false;
buttonText.textContent = 'Войти';
loadingSpinner.classList.remove('show');
}
});
function showError(message) {
const errorMessage = document.getElementById('errorMessage');
errorMessage.textContent = message;
errorMessage.classList.add('show');
}
function hideError() {
const errorMessage = document.getElementById('errorMessage');
errorMessage.classList.remove('show');
}
// Auto-focus on username field
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('username').focus();
});
// Handle Enter key in password field
document.getElementById('password').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
document.getElementById('loginForm').dispatchEvent(new Event('submit'));
}
});
</script>
</body>
</html>

199
test_auth.py Normal file
View File

@ -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()