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 # Сайт: 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 COMPOSE_FILE = docker-compose.yml
@ -109,4 +109,16 @@ rebuild: ## Пересобрать и запустить сервисы
@echo "$(GREEN)Сервисы пересобраны и запущены!$(NC)" @echo "$(GREEN)Сервисы пересобраны и запущены!$(NC)"
@echo "$(YELLOW)Приложение доступно по адресу: http://localhost:9001$(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+ # 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 ## Исправления дублирования строк и правильных переносов строк в режимах Single View и MultiView
@ -153,8 +180,8 @@ http://localhost:9001
| Переменная | Описание | Значение по умолчанию | | Переменная | Описание | Значение по умолчанию |
|------------|----------|----------------------| |------------|----------|----------------------|
| `LOGBOARD_PORT` | Порт веб-интерфейса | `9001` | | `LOGBOARD_PORT` | Порт веб-интерфейса | `9001` |
| `LOGBOARD_USER` | Имя пользователя для Basic Auth | `admin` | | `LOGBOARD_USER` | Имя пользователя для авторизации | `admin` |
| `LOGBOARD_PASS` | Пароль для Basic Auth | `s3cret-change-me` | | `LOGBOARD_PASS` | Пароль для авторизации | `admin123` |
| `LOGBOARD_TAIL` | Количество строк истории | `500` | | `LOGBOARD_TAIL` | Количество строк истории | `500` |
| `LOGBOARD_SNAPSHOT_DIR` | Директория для снимков | `/app/snapshots` | | `LOGBOARD_SNAPSHOT_DIR` | Директория для снимков | `/app/snapshots` |
| `LOGBOARD_INDEX_HTML` | Путь к HTML шаблону | `./templates/index.html` | | `LOGBOARD_INDEX_HTML` | Путь к HTML шаблону | `./templates/index.html` |
@ -169,7 +196,7 @@ http://localhost:9001
| `SECRET_KEY` | Секретный ключ для шифрования | `your-secret-key-here` | | `SECRET_KEY` | Секретный ключ для шифрования | `your-secret-key-here` |
| `ENCRYPTION_KEY` | Ключ шифрования | `your-encryption-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 http://localhost:9001
``` ```
По умолчанию логин/пароль для Basic Auth задаются в `docker-compose.yml`: По умолчанию логин/пароль для авторизации задаются в `docker-compose.yml`:
```yaml ```yaml
environment: environment:
- LB_USER=admin - LOGBOARD_USER=admin
- LB_PASS=admin - LOGBOARD_PASS=admin123
``` ```
--- ---

297
app.py
View File

@ -1,56 +1,137 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
"""
LogBoard+ - Веб-панель для просмотра логов микросервисов
Автор: Сергей Антропов
Сайт: https://devops.org.ru
"""
import asyncio import asyncio
import base64 import base64
import json import json
import os import os
import re 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 import docker
from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, status, Body from fastapi import FastAPI, WebSocket, WebSocketDisconnect, Query, Depends, HTTPException, status, Body, Request, Response
from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
from fastapi.security import HTTPBasic, HTTPBasicCredentials from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
from fastapi.staticfiles import StaticFiles 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")) APP_PORT = int(os.getenv("LOGBOARD_PORT", "9001"))
DEFAULT_TAIL = int(os.getenv("LOGBOARD_TAIL", "500")) DEFAULT_TAIL = int(os.getenv("LOGBOARD_TAIL", "500"))
BASIC_USER = os.getenv("LOGBOARD_USER", "admin") DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME")
BASIC_PASS = os.getenv("LOGBOARD_PASS", "admin") DEFAULT_PROJECTS = os.getenv("LOGBOARD_PROJECTS")
DEFAULT_PROJECT = os.getenv("COMPOSE_PROJECT_NAME") # filter by compose project
DEFAULT_PROJECTS = os.getenv("LOGBOARD_PROJECTS") # multiple projects filter
SKIP_UNHEALTHY = os.getenv("LOGBOARD_SKIP_UNHEALTHY", "true").lower() == "true" SKIP_UNHEALTHY = os.getenv("LOGBOARD_SKIP_UNHEALTHY", "true").lower() == "true"
CONTAINER_LIST_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_LIST_TIMEOUT", "10")) CONTAINER_LIST_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_LIST_TIMEOUT", "10"))
CONTAINER_INFO_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_INFO_TIMEOUT", "3")) CONTAINER_INFO_TIMEOUT = int(os.getenv("LOGBOARD_CONTAINER_INFO_TIMEOUT", "3"))
HEALTH_CHECK_TIMEOUT = int(os.getenv("LOGBOARD_HEALTH_CHECK_TIMEOUT", "2")) 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) # serve snapshots directory (downloadable files)
SNAP_DIR = os.getenv("LOGBOARD_SNAPSHOT_DIR", "./snapshots") SNAP_DIR = os.getenv("LOGBOARD_SNAPSHOT_DIR", "./snapshots")
os.makedirs(SNAP_DIR, exist_ok=True) os.makedirs(SNAP_DIR, exist_ok=True)
app.mount("/snapshots", StaticFiles(directory=SNAP_DIR), name="snapshots") app.mount("/snapshots", StaticFiles(directory=SNAP_DIR), name="snapshots")
docker_client = docker.from_env() # Модели данных
class UserLogin(BaseModel):
username: str
password: str
# ---------- AUTH ---------- class Token(BaseModel):
def check_basic(creds: HTTPBasicCredentials = Depends(security)): access_token: str
if creds.username == BASIC_USER and creds.password == BASIC_PASS: token_type: str
return creds
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid credentials",
headers={"WWW-Authenticate": "Basic"})
def token_from_creds(creds: HTTPBasicCredentials) -> str: class TokenData(BaseModel):
return base64.b64encode(f"{creds.username}:{creds.password}".encode()).decode() 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: try:
raw = base64.b64decode(token.encode(), validate=True).decode() payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
except Exception: username: str = payload.get("sub")
return False if username is None:
return raw == f"{BASIC_USER}:{BASIC_PASS}" 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 ---------- # ---------- DOCKER HELPERS ----------
def load_excluded_containers() -> List[str]: def load_excluded_containers() -> List[str]:
@ -274,30 +355,83 @@ def list_containers(projects: Optional[List[str]] = None, include_stopped: bool
# ---------- HTML ---------- # ---------- HTML ----------
INDEX_PATH = os.getenv("LOGBOARD_INDEX_HTML", "./templates/index.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: with open(INDEX_PATH, "r", encoding="utf-8") as f:
html = f.read() return f.read()
return html.replace("__TOKEN__", token)
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 ---------- # ---------- ROUTES ----------
@app.get("/", response_class=HTMLResponse) @app.get("/", response_class=HTMLResponse)
def index(creds: HTTPBasicCredentials = Depends(check_basic)): async def index(request: Request):
token = token_from_creds(creds) """Главная страница приложения"""
return HTMLResponse( # Проверяем наличие токена в cookie
content=load_index_html(token), access_token = request.cookies.get("access_token")
headers={ if not access_token:
"Cache-Control": "no-cache, no-store, must-revalidate", return RedirectResponse(url="/login")
"Pragma": "no-cache",
"Expires": "0" # Проверяем валидность токена
} 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) @app.get("/healthz", response_class=PlainTextResponse)
def healthz(): def healthz():
"""Health check endpoint"""
return "ok" return "ok"
@app.get("/api/logs/stats/{container_id}") @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: try:
# Ищем контейнер # Ищем контейнер
@ -344,10 +478,8 @@ def api_logs_stats(container_id: str, _: HTTPBasicCredentials = Depends(check_ba
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
@app.get("/api/excluded-containers") @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( return JSONResponse(
content={"excluded_containers": load_excluded_containers()}, content={"excluded_containers": load_excluded_containers()},
headers={ headers={
@ -360,11 +492,9 @@ def api_get_excluded_containers(_: HTTPBasicCredentials = Depends(check_basic)):
@app.post("/api/excluded-containers") @app.post("/api/excluded-containers")
def api_update_excluded_containers( def api_update_excluded_containers(
containers: List[str] = Body(...), containers: List[str] = Body(...),
_: HTTPBasicCredentials = Depends(check_basic) current_user: str = Depends(get_current_user)
): ):
""" """Обновить список исключенных контейнеров"""
Обновить список исключенных контейнеров
"""
success = save_excluded_containers(containers) success = save_excluded_containers(containers)
if success: if success:
return JSONResponse( return JSONResponse(
@ -379,7 +509,7 @@ def api_update_excluded_containers(
raise HTTPException(status_code=500, detail="Ошибка сохранения списка") raise HTTPException(status_code=500, detail="Ошибка сохранения списка")
@app.get("/api/projects") @app.get("/api/projects")
def api_projects(_: HTTPBasicCredentials = Depends(check_basic)): def api_projects(current_user: str = Depends(get_current_user)):
"""Получить список всех проектов Docker Compose""" """Получить список всех проектов Docker Compose"""
return JSONResponse( return JSONResponse(
content=get_all_projects(), content=get_all_projects(),
@ -391,12 +521,12 @@ def api_projects(_: HTTPBasicCredentials = Depends(check_basic)):
) )
@app.get("/api/services") @app.get("/api/services")
def api_services(projects: Optional[str] = Query(None), include_stopped: bool = Query(False), def api_services(
_: HTTPBasicCredentials = Depends(check_basic)): projects: Optional[str] = Query(None),
""" include_stopped: bool = Query(False),
Получить список контейнеров с поддержкой множественных проектов current_user: str = Depends(get_current_user)
projects: список проектов через запятую (например: "project1,project2") ):
""" """Получить список контейнеров с поддержкой множественных проектов"""
project_list = None project_list = None
if projects: if projects:
project_list = [p.strip() for p in projects.split(",") if p.strip()] 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") @app.post("/api/snapshot")
def api_snapshot( def api_snapshot(
creds: HTTPBasicCredentials = Depends(check_basic), current_user: str = Depends(get_current_user),
container_id: str = Body(..., embed=True), container_id: str = Body(..., embed=True),
service: str = Body("", embed=True), service: str = Body("", embed=True),
content: str = Body("", embed=True), content: str = Body("", embed=True),
): ):
"""Сохранить снимок логов"""
# Save posted content as a snapshot file # Save posted content as a snapshot file
safe_service = re.sub(r"[^a-zA-Z0-9_.-]+", "_", service or container_id[:12]) safe_service = re.sub(r"[^a-zA-Z0-9_.-]+", "_", service or container_id[:12])
ts = os.getenv("TZ_TS") or "" ts = os.getenv("TZ_TS") or ""
@ -434,19 +565,23 @@ def api_snapshot(
return {"file": fname, "url": url} return {"file": fname, "url": url}
# WebSocket: verify token (?token=base64(user:pass)) # 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}") @app.websocket("/ws/logs/{container_id}")
async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None, 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): service: Optional[str] = None, project: Optional[str] = None):
"""Упрощенный WebSocket для получения логов контейнера""" """WebSocket для получения логов контейнера"""
# Принимаем соединение # Принимаем соединение
await ws.accept() await ws.accept()
# Проверяем токен # Проверяем токен
if not token or not verify_ws_token(token): if not token:
await ws.send_text("ERROR: unauthorized") 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() await ws.close()
return return
@ -499,20 +634,23 @@ async def ws_logs(ws: WebSocket, container_id: str, tail: int = DEFAULT_TAIL, to
except: except:
pass 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 # WebSocket: fan-in by compose service (aggregate all replicas), prefixing with short container id
@app.websocket("/ws/fan/{service_name}") @app.websocket("/ws/fan/{service_name}")
async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None, async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
project: Optional[str] = None): project: Optional[str] = None):
await ws.accept() 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 # Track active streaming tasks by container id
active = {} active = {}
@ -589,15 +727,23 @@ async def ws_fan(ws: WebSocket, service_name: str, tail: int = DEFAULT_TAIL, tok
except Exception: except Exception:
pass pass
# WebSocket: fan-in for multiple compose services (comma-separated), optional project filter. # WebSocket: fan-in for multiple compose services (comma-separated), optional project filter.
@app.websocket("/ws/fan_group") @app.websocket("/ws/fan_group")
async def ws_fan_group(ws: WebSocket, services: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None, async def ws_fan_group(ws: WebSocket, services: str, tail: int = DEFAULT_TAIL, token: Optional[str] = None,
project: Optional[str] = None): project: Optional[str] = None):
await ws.accept() 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()} svc_set = {s.strip() for s in services.split(",") if s.strip()}
if not svc_set: 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() await ws.close()
except Exception: except Exception:
pass 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' CONNECTION_TIMEOUT: '30'
READ_TIMEOUT: '60' READ_TIMEOUT: '60'
AUTH_ENABLED: 'true' AUTH_ENABLED: 'true'
AUTH_METHOD: basic AUTH_METHOD: jwt
SESSION_TIMEOUT: '3600' SESSION_TIMEOUT: '3600'
NOTIFICATIONS_ENABLED: 'false' NOTIFICATIONS_ENABLED: 'false'
SMTP_HOST: '' SMTP_HOST: ''

View File

@ -63,8 +63,9 @@ LOGBOARD_HEALTH_CHECK_TIMEOUT=2
# Настройки аутентификации # Настройки аутентификации
AUTH_ENABLED=true AUTH_ENABLED=true
AUTH_METHOD=basic AUTH_METHOD=jwt
SESSION_TIMEOUT=3600 SESSION_TIMEOUT=3600
SECRET_KEY=your-secret-key-here-change-in-production
# Настройки уведомлений # Настройки уведомлений
NOTIFICATIONS_ENABLED=false NOTIFICATIONS_ENABLED=false

View File

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