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