feat: добавлена пометка типа операции (Build/Push) в истории сборок Dockerfile
- Добавлена колонка 'Тип' во все таблицы истории сборок - Для push операций отображается registry вместо платформ - Сохранение пользователя при создании push лога - Исправлена ошибка с logger в push_docker_image endpoint - Улучшено отображение истории сборок с визуальными индикаторами
This commit is contained in:
1
app/auth/__init__.py
Normal file
1
app/auth/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
# Auth package
|
||||
84
app/auth/deps.py
Normal file
84
app/auth/deps.py
Normal file
@@ -0,0 +1,84 @@
|
||||
"""
|
||||
Зависимости для аутентификации
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
from fastapi import Depends, HTTPException, status, Request
|
||||
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
||||
from app.auth.security import decode_access_token
|
||||
|
||||
security = HTTPBearer(auto_error=False)
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||
):
|
||||
"""Получение текущего пользователя из токена (cookie или Authorization header)"""
|
||||
token = None
|
||||
|
||||
# Сначала проверяем, есть ли пользователь в request.state (установлен middleware)
|
||||
if hasattr(request.state, 'user') and request.state.user:
|
||||
return {"username": request.state.user}
|
||||
|
||||
# Проверяем cookie
|
||||
if request.cookies and "access_token" in request.cookies:
|
||||
token = request.cookies.get("access_token")
|
||||
# Проверяем заголовок Authorization
|
||||
elif credentials:
|
||||
token = credentials.credentials
|
||||
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Требуется аутентификация"
|
||||
)
|
||||
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверный токен аутентификации"
|
||||
)
|
||||
|
||||
username: str = payload.get("sub")
|
||||
if username is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Неверный токен аутентификации"
|
||||
)
|
||||
|
||||
return {"username": username}
|
||||
|
||||
|
||||
# Для простоты пока без реальной БД пользователей
|
||||
# В будущем можно добавить модель User
|
||||
async def get_current_user_optional(
|
||||
request: Request,
|
||||
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)
|
||||
):
|
||||
"""Опциональное получение пользователя (для публичных страниц)"""
|
||||
try:
|
||||
# Сначала проверяем, есть ли пользователь в request.state (установлен middleware)
|
||||
if hasattr(request.state, 'user') and request.state.user:
|
||||
return {"username": request.state.user}
|
||||
|
||||
# Проверяем cookie
|
||||
token = None
|
||||
if request.cookies and "access_token" in request.cookies:
|
||||
token = request.cookies.get("access_token")
|
||||
# Проверяем заголовок Authorization
|
||||
elif credentials:
|
||||
token = credentials.credentials
|
||||
|
||||
if token:
|
||||
payload = decode_access_token(token)
|
||||
if payload:
|
||||
username: str = payload.get("sub")
|
||||
if username:
|
||||
return {"username": username}
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
95
app/auth/middleware.py
Normal file
95
app/auth/middleware.py
Normal file
@@ -0,0 +1,95 @@
|
||||
"""
|
||||
Middleware для проверки аутентификации
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from fastapi import Request, HTTPException, status
|
||||
from fastapi.responses import RedirectResponse
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from app.auth.security import decode_access_token
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Публичные пути, не требующие аутентификации
|
||||
PUBLIC_PATHS = [
|
||||
"/login",
|
||||
"/api/v1/auth/login",
|
||||
"/logout",
|
||||
"/api/v1/auth/logout",
|
||||
"/health",
|
||||
"/static",
|
||||
"/api/docs",
|
||||
"/api/redoc",
|
||||
"/openapi.json",
|
||||
"/api/v1/stats", # Статистика доступна без аутентификации
|
||||
"/api/v1/dockerfiles/build-logs/webhook", # Webhook для получения логов от builder
|
||||
"/api/v1/dockerfiles/build-logs/recent" # Получение последних логов сборки (может использоваться на странице сборки)
|
||||
]
|
||||
|
||||
|
||||
class AuthMiddleware(BaseHTTPMiddleware):
|
||||
"""Middleware для проверки аутентификации"""
|
||||
|
||||
async def dispatch(self, request: Request, call_next):
|
||||
path = request.url.path
|
||||
|
||||
# Пропускаем публичные пути (проверяем точное совпадение или начало пути)
|
||||
is_public = False
|
||||
for public_path in PUBLIC_PATHS:
|
||||
if path == public_path or path.startswith(public_path + "/") or path.startswith(public_path):
|
||||
is_public = True
|
||||
break
|
||||
|
||||
if is_public:
|
||||
return await call_next(request)
|
||||
|
||||
# Проверка токена из cookie или заголовка
|
||||
token = None
|
||||
|
||||
# Проверяем cookie
|
||||
if request.cookies and "access_token" in request.cookies:
|
||||
token = request.cookies.get("access_token")
|
||||
# Проверяем заголовок Authorization
|
||||
elif "authorization" in request.headers:
|
||||
auth_header = request.headers.get("authorization", "")
|
||||
if auth_header and auth_header.startswith("Bearer "):
|
||||
token = auth_header.replace("Bearer ", "")
|
||||
|
||||
# Если токена нет, перенаправляем на страницу входа
|
||||
if not token:
|
||||
if path.startswith("/api/"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Требуется аутентификация"
|
||||
)
|
||||
return RedirectResponse(url="/login", status_code=302)
|
||||
|
||||
# Проверяем токен
|
||||
try:
|
||||
payload = decode_access_token(token)
|
||||
if payload is None:
|
||||
# Токен невалидный или истек
|
||||
if path.startswith("/api/"):
|
||||
# Для API запросов возвращаем JSON с ошибкой
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Токен аутентификации истек или недействителен"
|
||||
)
|
||||
# Для HTML запросов перенаправляем на страницу входа
|
||||
return RedirectResponse(url="/login?expired=1", status_code=302)
|
||||
except Exception as e:
|
||||
# Ошибка при декодировании токена
|
||||
logger.warning(f"Error decoding token: {e}")
|
||||
if path.startswith("/api/"):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Ошибка проверки токена аутентификации"
|
||||
)
|
||||
return RedirectResponse(url="/login?expired=1", status_code=302)
|
||||
|
||||
# Добавляем пользователя в request state
|
||||
request.state.user = payload.get("sub")
|
||||
|
||||
return await call_next(request)
|
||||
180
app/auth/security.py
Normal file
180
app/auth/security.py
Normal file
@@ -0,0 +1,180 @@
|
||||
"""
|
||||
Безопасность и аутентификация
|
||||
Автор: Сергей Антропов
|
||||
Сайт: https://devops.org.ru
|
||||
"""
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Optional
|
||||
from jose import JWTError, jwt
|
||||
from app.core.config import settings
|
||||
|
||||
# Ленивая инициализация CryptContext для избежания ошибок при импорте
|
||||
_pwd_context = None
|
||||
|
||||
# Глобальный патч для bcrypt.hashpw, чтобы ограничить длину пароля
|
||||
_bcrypt_patched = False
|
||||
|
||||
def _patch_bcrypt_hashpw():
|
||||
"""Патч для bcrypt.hashpw, чтобы ограничить длину пароля до 72 байт"""
|
||||
global _bcrypt_patched
|
||||
if _bcrypt_patched:
|
||||
return
|
||||
|
||||
try:
|
||||
# Патчим bcrypt модуль напрямую
|
||||
import bcrypt
|
||||
original_hashpw = bcrypt.hashpw
|
||||
|
||||
def safe_hashpw(secret, salt):
|
||||
"""Обертка для hashpw с ограничением длины до 72 байт"""
|
||||
if isinstance(secret, str):
|
||||
secret_bytes = secret.encode('utf-8')
|
||||
else:
|
||||
secret_bytes = secret
|
||||
if len(secret_bytes) > 72:
|
||||
secret_bytes = secret_bytes[:72]
|
||||
return original_hashpw(secret_bytes, salt)
|
||||
|
||||
# Заменяем hashpw глобально в модуле bcrypt
|
||||
bcrypt.hashpw = safe_hashpw
|
||||
|
||||
# Также патчим _bcrypt, если он доступен
|
||||
try:
|
||||
import bcrypt._bcrypt as _bcrypt
|
||||
_bcrypt.hashpw = safe_hashpw
|
||||
except (ImportError, AttributeError):
|
||||
pass
|
||||
|
||||
_bcrypt_patched = True
|
||||
except Exception:
|
||||
# Если не удалось патчить, продолжаем без патча
|
||||
pass
|
||||
|
||||
# Применяем патч при импорте модуля
|
||||
_patch_bcrypt_hashpw()
|
||||
|
||||
|
||||
def _get_pwd_context():
|
||||
"""Получение CryptContext с ленивой инициализацией"""
|
||||
global _pwd_context
|
||||
if _pwd_context is None:
|
||||
try:
|
||||
import os
|
||||
import warnings
|
||||
|
||||
# Отключаем предупреждения
|
||||
os.environ.setdefault('PASSLIB_SUPPRESS_USER_WARNING', '1')
|
||||
warnings.filterwarnings('ignore', category=UserWarning)
|
||||
|
||||
# Патчим passlib.handlers.bcrypt._bcrypt перед инициализацией
|
||||
# Это необходимо, чтобы избежать ошибки при автоматической проверке
|
||||
try:
|
||||
# Импортируем модуль passlib.handlers.bcrypt
|
||||
import passlib.handlers.bcrypt as bcrypt_handler
|
||||
|
||||
# Сохраняем оригинальную функцию hashpw
|
||||
if hasattr(bcrypt_handler, '_bcrypt') and hasattr(bcrypt_handler._bcrypt, 'hashpw'):
|
||||
original_hashpw = bcrypt_handler._bcrypt.hashpw
|
||||
|
||||
def safe_hashpw(secret, salt):
|
||||
"""Обертка для hashpw с ограничением длины до 72 байт"""
|
||||
if isinstance(secret, str):
|
||||
secret_bytes = secret.encode('utf-8')
|
||||
else:
|
||||
secret_bytes = secret
|
||||
if len(secret_bytes) > 72:
|
||||
secret_bytes = secret_bytes[:72]
|
||||
return original_hashpw(secret_bytes, salt)
|
||||
|
||||
# Заменяем hashpw НАВСЕГДА (не восстанавливаем)
|
||||
bcrypt_handler._bcrypt.hashpw = safe_hashpw
|
||||
|
||||
# Импортируем и инициализируем CryptContext
|
||||
from passlib.context import CryptContext
|
||||
|
||||
_pwd_context = CryptContext(
|
||||
schemes=["bcrypt"],
|
||||
deprecated="auto",
|
||||
bcrypt__rounds=12
|
||||
)
|
||||
else:
|
||||
# Если не удалось найти _bcrypt, инициализируем без патча
|
||||
from passlib.context import CryptContext
|
||||
_pwd_context = CryptContext(
|
||||
schemes=["bcrypt"],
|
||||
deprecated="auto",
|
||||
bcrypt__rounds=12
|
||||
)
|
||||
|
||||
except ImportError:
|
||||
# Если bcrypt не установлен, используем простой хеш
|
||||
raise ImportError("bcrypt не установлен")
|
||||
|
||||
except Exception as e:
|
||||
# Если не удалось инициализировать bcrypt, используем простой хеш
|
||||
import hashlib
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"Не удалось инициализировать bcrypt: {e}. Используется простой хеш.")
|
||||
|
||||
class SimpleHash:
|
||||
def hash(self, password: str) -> str:
|
||||
# Ограничиваем длину для безопасности
|
||||
if len(password.encode('utf-8')) > 72:
|
||||
password = password[:72]
|
||||
return hashlib.sha256(password.encode()).hexdigest()
|
||||
def verify(self, plain: str, hashed: str) -> bool:
|
||||
if len(plain.encode('utf-8')) > 72:
|
||||
plain = plain[:72]
|
||||
return self.hash(plain) == hashed
|
||||
_pwd_context = SimpleHash()
|
||||
return _pwd_context
|
||||
|
||||
|
||||
def _truncate_password_bytes(password: str, max_bytes: int = 72) -> str:
|
||||
"""Обрезает пароль до максимального количества байт"""
|
||||
password_bytes = password.encode('utf-8')
|
||||
if len(password_bytes) <= max_bytes:
|
||||
return password
|
||||
# Обрезаем до max_bytes байт
|
||||
truncated_bytes = password_bytes[:max_bytes]
|
||||
# Декодируем обратно в строку, игнорируя ошибки если обрезали посередине символа
|
||||
return truncated_bytes.decode('utf-8', errors='ignore')
|
||||
|
||||
|
||||
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
||||
"""Проверка пароля"""
|
||||
pwd_context = _get_pwd_context()
|
||||
# Ограничиваем длину пароля до 72 байт для bcrypt
|
||||
plain_password = _truncate_password_bytes(plain_password, 72)
|
||||
return pwd_context.verify(plain_password, hashed_password)
|
||||
|
||||
|
||||
def get_password_hash(password: str) -> str:
|
||||
"""Хеширование пароля"""
|
||||
pwd_context = _get_pwd_context()
|
||||
# Ограничиваем длину пароля до 72 байт для bcrypt
|
||||
password = _truncate_password_bytes(password, 72)
|
||||
return pwd_context.hash(password)
|
||||
|
||||
|
||||
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None) -> str:
|
||||
"""Создание JWT токена"""
|
||||
to_encode = data.copy()
|
||||
if expires_delta:
|
||||
expire = datetime.utcnow() + expires_delta
|
||||
else:
|
||||
expire = datetime.utcnow() + timedelta(minutes=settings.ACCESS_TOKEN_EXPIRE_MINUTES)
|
||||
to_encode.update({"exp": expire})
|
||||
encoded_jwt = jwt.encode(to_encode, settings.SECRET_KEY, algorithm=settings.ALGORITHM)
|
||||
return encoded_jwt
|
||||
|
||||
|
||||
def decode_access_token(token: str) -> Optional[dict]:
|
||||
"""Декодирование JWT токена"""
|
||||
try:
|
||||
payload = jwt.decode(token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM])
|
||||
return payload
|
||||
except JWTError:
|
||||
return None
|
||||
Reference in New Issue
Block a user