middleware-fix+oauth-routes
All checks were successful
Deploy on push / deploy (push) Successful in 6s

This commit is contained in:
Untone 2025-05-30 14:05:50 +03:00
parent f8ad73571c
commit f160ab4d26
5 changed files with 183 additions and 172 deletions

View File

@ -1,19 +1,18 @@
"""
Утилитные функции для внутренней аутентификации
Используются в GraphQL резолверах и декораторах
"""
import time import time
from typing import Any, Optional, Tuple from typing import Any, Optional, Tuple
from sqlalchemy.orm import exc from sqlalchemy.orm import exc
from starlette.authentication import AuthenticationBackend, BaseUser, UnauthenticatedUser
from starlette.requests import HTTPConnection
from auth.credentials import AuthCredentials from auth.credentials import AuthCredentials
from auth.exceptions import ExpiredToken, InvalidToken
from auth.jwtcodec import JWTCodec
from auth.orm import Author from auth.orm import Author
from auth.sessions import SessionManager from auth.sessions import SessionManager
from auth.state import AuthState from auth.state import AuthState
from auth.tokenstorage import TokenStorage
from services.db import local_session from services.db import local_session
from services.redis import redis
from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST
from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER
from utils.logger import root_logger as logger from utils.logger import root_logger as logger
@ -21,126 +20,6 @@ from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",") ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
class AuthenticatedUser(BaseUser):
"""Аутентифицированный пользователь для Starlette"""
def __init__(
self, user_id: str, username: str = "", roles: list = None, permissions: dict = None, token: str = None
):
self.user_id = user_id
self.username = username
self.roles = roles or []
self.permissions = permissions or {}
self.token = token
@property
def is_authenticated(self) -> bool:
return True
@property
def display_name(self) -> str:
return self.username
@property
def identity(self) -> str:
return self.user_id
class InternalAuthentication(AuthenticationBackend):
"""Внутренняя аутентификация через базу данных и Redis"""
async def authenticate(self, request: HTTPConnection):
"""
Аутентифицирует пользователя по токену из заголовка или cookie.
Порядок поиска токена:
1. Проверяем заголовок SESSION_TOKEN_HEADER (может быть установлен middleware)
2. Проверяем scope/auth в request, куда middleware мог сохранить токен
3. Проверяем cookie
Возвращает:
tuple: (AuthCredentials, BaseUser)
"""
token = None
# 1. Проверяем заголовок
if SESSION_TOKEN_HEADER in request.headers:
token_header = request.headers.get(SESSION_TOKEN_HEADER)
if token_header:
if token_header.startswith("Bearer "):
token = token_header.replace("Bearer ", "", 1).strip()
logger.debug(f"[auth.authenticate] Извлечен Bearer токен из заголовка {SESSION_TOKEN_HEADER}")
else:
token = token_header.strip()
logger.debug(f"[auth.authenticate] Извлечен прямой токен из заголовка {SESSION_TOKEN_HEADER}")
# 2. Проверяем scope/auth, который мог быть установлен middleware
if not token and hasattr(request, "scope") and "auth" in request.scope:
auth_data = request.scope.get("auth", {})
if isinstance(auth_data, dict) and "token" in auth_data:
token = auth_data["token"]
logger.debug(f"[auth.authenticate] Извлечен токен из request.scope['auth']")
# 3. Проверяем cookie
if not token and hasattr(request, "cookies") and SESSION_COOKIE_NAME in request.cookies:
token = request.cookies.get(SESSION_COOKIE_NAME)
logger.debug(f"[auth.authenticate] Извлечен токен из cookie {SESSION_COOKIE_NAME}")
# Если токен не найден, возвращаем неаутентифицированного пользователя
if not token:
logger.debug("[auth.authenticate] Токен не найден")
return AuthCredentials(scopes={}, error_message="no token"), UnauthenticatedUser()
# Проверяем сессию в Redis
payload = await SessionManager.verify_session(token)
if not payload:
logger.debug("[auth.authenticate] Недействительный токен")
return AuthCredentials(scopes={}, error_message="Invalid token"), UnauthenticatedUser()
with local_session() as session:
try:
author = (
session.query(Author)
.filter(Author.id == payload.user_id)
.filter(Author.is_active == True) # noqa
.one()
)
if author.is_locked():
logger.debug(f"[auth.authenticate] Аккаунт заблокирован: {author.id}")
return AuthCredentials(scopes={}, error_message="Account is locked"), UnauthenticatedUser()
# Получаем разрешения из ролей
scopes = author.get_permissions()
# Получаем роли для пользователя
roles = [role.id for role in author.roles] if author.roles else []
# Обновляем last_seen
author.last_seen = int(time.time())
session.commit()
# Создаем объекты авторизации с сохранением токена
credentials = AuthCredentials(
author_id=author.id, scopes=scopes, logged_in=True, email=author.email, token=token
)
user = AuthenticatedUser(
user_id=str(author.id),
username=author.slug or author.email or "",
roles=roles,
permissions=scopes,
token=token,
)
logger.debug(f"[auth.authenticate] Успешная аутентификация: {author.email}")
return credentials, user
except exc.NoResultFound:
logger.debug("[auth.authenticate] Пользователь не найден")
return AuthCredentials(scopes={}, error_message="User not found"), UnauthenticatedUser()
async def verify_internal_auth(token: str) -> Tuple[str, list, bool]: async def verify_internal_auth(token: str) -> Tuple[str, list, bool]:
""" """
Проверяет локальную авторизацию. Проверяет локальную авторизацию.

View File

@ -1,15 +1,23 @@
""" """
Middleware для обработки авторизации в GraphQL запросах Единый middleware для обработки авторизации в GraphQL запросах
""" """
import time
from typing import Any, Dict from typing import Any, Dict
from starlette.authentication import UnauthenticatedUser
from starlette.datastructures import Headers from starlette.datastructures import Headers
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse, Response from starlette.responses import JSONResponse, Response
from starlette.types import ASGIApp, Receive, Scope, Send from starlette.types import ASGIApp, Receive, Scope, Send
from sqlalchemy.orm import exc
from auth.credentials import AuthCredentials
from auth.orm import Author
from auth.sessions import SessionManager
from services.db import local_session
from settings import ( from settings import (
ADMIN_EMAILS as ADMIN_EMAILS_LIST,
SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_HTTPONLY,
SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_MAX_AGE,
SESSION_COOKIE_NAME, SESSION_COOKIE_NAME,
@ -19,21 +27,101 @@ from settings import (
) )
from utils.logger import root_logger as logger from utils.logger import root_logger as logger
ADMIN_EMAILS = ADMIN_EMAILS_LIST.split(",")
class AuthenticatedUser:
"""Аутентифицированный пользователь"""
def __init__(self, user_id: str, username: str = "", roles: list = None, permissions: dict = None, token: str = None):
self.user_id = user_id
self.username = username
self.roles = roles or []
self.permissions = permissions or {}
self.token = token
@property
def is_authenticated(self) -> bool:
return True
@property
def display_name(self) -> str:
return self.username
@property
def identity(self) -> str:
return self.user_id
class AuthMiddleware: class AuthMiddleware:
""" """
Универсальный middleware для обработки авторизации и управления cookies. Единый middleware для обработки авторизации и аутентификации.
Основные функции: Основные функции:
1. Извлечение Bearer токена из заголовка Authorization или cookie 1. Извлечение Bearer токена из заголовка Authorization или cookie
2. Добавление токена в заголовки запроса для обработки AuthenticationMiddleware 2. Проверка сессии через SessionManager
3. Предоставление методов для установки/удаления cookies в GraphQL резолверах 3. Создание request.user и request.auth
4. Предоставление методов для установки/удаления cookies
""" """
def __init__(self, app: ASGIApp): def __init__(self, app: ASGIApp):
self.app = app self.app = app
self._context = None self._context = None
async def authenticate_user(self, token: str):
"""Аутентифицирует пользователя по токену"""
if not token:
return AuthCredentials(scopes={}, error_message="no token"), UnauthenticatedUser()
# Проверяем сессию в Redis
payload = await SessionManager.verify_session(token)
if not payload:
logger.debug("[auth.authenticate] Недействительный токен")
return AuthCredentials(scopes={}, error_message="Invalid token"), UnauthenticatedUser()
with local_session() as session:
try:
author = (
session.query(Author)
.filter(Author.id == payload.user_id)
.filter(Author.is_active == True) # noqa
.one()
)
if author.is_locked():
logger.debug(f"[auth.authenticate] Аккаунт заблокирован: {author.id}")
return AuthCredentials(scopes={}, error_message="Account is locked"), UnauthenticatedUser()
# Получаем разрешения из ролей
scopes = author.get_permissions()
# Получаем роли для пользователя
roles = [role.id for role in author.roles] if author.roles else []
# Обновляем last_seen
author.last_seen = int(time.time())
session.commit()
# Создаем объекты авторизации с сохранением токена
credentials = AuthCredentials(
author_id=author.id, scopes=scopes, logged_in=True, email=author.email, token=token
)
user = AuthenticatedUser(
user_id=str(author.id),
username=author.slug or author.email or "",
roles=roles,
permissions=scopes,
token=token,
)
logger.debug(f"[auth.authenticate] Успешная аутентификация: {author.email}")
return credentials, user
except exc.NoResultFound:
logger.debug("[auth.authenticate] Пользователь не найден")
return AuthCredentials(scopes={}, error_message="User not found"), UnauthenticatedUser()
async def __call__(self, scope: Scope, receive: Receive, send: Send): async def __call__(self, scope: Scope, receive: Receive, send: Send):
"""Обработка ASGI запроса""" """Обработка ASGI запроса"""
if scope["type"] != "http": if scope["type"] != "http":
@ -87,26 +175,25 @@ class AuthMiddleware:
) )
break break
# Если токен получен, обновляем заголовки в scope # Аутентифицируем пользователя
auth, user = await self.authenticate_user(token)
# Добавляем в scope данные авторизации и пользователя
scope["auth"] = auth
scope["user"] = user
if token: if token:
# Создаем новый список заголовков # Обновляем заголовки в scope для совместимости
new_headers = [] new_headers = []
for name, value in scope["headers"]: for name, value in scope["headers"]:
# Пропускаем оригинальный заголовок авторизации
if name.decode("latin1").lower() != SESSION_TOKEN_HEADER.lower(): if name.decode("latin1").lower() != SESSION_TOKEN_HEADER.lower():
new_headers.append((name, value)) new_headers.append((name, value))
# Добавляем заголовок с чистым токеном
new_headers.append((SESSION_TOKEN_HEADER.encode("latin1"), token.encode("latin1"))) new_headers.append((SESSION_TOKEN_HEADER.encode("latin1"), token.encode("latin1")))
# Обновляем заголовки в scope
scope["headers"] = new_headers scope["headers"] = new_headers
# Также добавляем информацию о типе аутентификации для дальнейшего использования logger.debug(f"[middleware] Пользователь аутентифицирован: {user.is_authenticated}")
scope["auth"] = {"type": "bearer", "token": token, "source": token_source}
logger.debug(f"[middleware] Токен добавлен в scope для аутентификации из источника: {token_source}")
else: else:
logger.debug(f"[middleware] Токен не найден ни в заголовке, ни в cookie") logger.debug(f"[middleware] Токен не найден, пользователь неаутентифицирован")
await self.app(scope, receive, send) await self.app(scope, receive, send)

View File

@ -1,4 +1,5 @@
import time import time
import orjson
from secrets import token_urlsafe from secrets import token_urlsafe
from authlib.integrations.starlette_client import OAuth from authlib.integrations.starlette_client import OAuth
@ -8,10 +9,16 @@ from starlette.responses import JSONResponse, RedirectResponse
from auth.orm import Author from auth.orm import Author
from auth.tokenstorage import TokenStorage from auth.tokenstorage import TokenStorage
from services.db import local_session from services.db import local_session
from services.redis import redis
from settings import FRONTEND_URL, OAUTH_CLIENTS from settings import FRONTEND_URL, OAUTH_CLIENTS
from utils.logger import root_logger as logger
from resolvers.auth import generate_unique_slug
oauth = OAuth() oauth = OAuth()
# OAuth state management через Redis (TTL 10 минут)
OAUTH_STATE_TTL = 600 # 10 минут
# Конфигурация провайдеров # Конфигурация провайдеров
PROVIDERS = { PROVIDERS = {
"google": { "google": {
@ -90,47 +97,68 @@ async def oauth_login(request):
if not client: if not client:
return JSONResponse({"error": "Provider not configured"}, status_code=400) return JSONResponse({"error": "Provider not configured"}, status_code=400)
# Получаем параметры из query string
state = request.query_params.get("state")
redirect_uri = request.query_params.get("redirect_uri", FRONTEND_URL)
if not state:
return JSONResponse({"error": "State parameter is required"}, status_code=400)
# Генерируем PKCE challenge # Генерируем PKCE challenge
code_verifier = token_urlsafe(32) code_verifier = token_urlsafe(32)
code_challenge = create_s256_code_challenge(code_verifier) code_challenge = create_s256_code_challenge(code_verifier)
# Сохраняем code_verifier в сессии # Сохраняем состояние OAuth в Redis
request.session["code_verifier"] = code_verifier oauth_data = {
request.session["provider"] = provider "code_verifier": code_verifier,
request.session["state"] = token_urlsafe(16) "provider": provider,
"redirect_uri": redirect_uri,
"created_at": int(time.time())
}
await store_oauth_state(state, oauth_data)
redirect_uri = f"{FRONTEND_URL}/oauth/callback" # Используем URL из фронтенда для callback
oauth_callback_uri = f"{request.base_url}oauth/{provider}/callback"
try: try:
return await client.authorize_redirect( return await client.authorize_redirect(
request, request,
redirect_uri, oauth_callback_uri,
code_challenge=code_challenge, code_challenge=code_challenge,
code_challenge_method="S256", code_challenge_method="S256",
state=request.session["state"], state=state,
) )
except Exception as e: except Exception as e:
logger.error(f"OAuth redirect error for {provider}: {str(e)}")
return JSONResponse({"error": str(e)}, status_code=500) return JSONResponse({"error": str(e)}, status_code=500)
async def oauth_callback(request): async def oauth_callback(request):
"""Обрабатывает callback от OAuth провайдера""" """Обрабатывает callback от OAuth провайдера"""
try: try:
provider = request.session.get("provider") # Получаем state из query параметров
state = request.query_params.get("state")
if not state:
return JSONResponse({"error": "State parameter missing"}, status_code=400)
# Получаем сохраненные данные OAuth из Redis
oauth_data = await get_oauth_state(state)
if not oauth_data:
return JSONResponse({"error": "Invalid or expired OAuth state"}, status_code=400)
provider = oauth_data.get("provider")
code_verifier = oauth_data.get("code_verifier")
stored_redirect_uri = oauth_data.get("redirect_uri", FRONTEND_URL)
if not provider: if not provider:
return JSONResponse({"error": "No active OAuth session"}, status_code=400) return JSONResponse({"error": "No active OAuth session"}, status_code=400)
# Проверяем state
state = request.query_params.get("state")
if state != request.session.get("state"):
return JSONResponse({"error": "Invalid state"}, status_code=400)
client = oauth.create_client(provider) client = oauth.create_client(provider)
if not client: if not client:
return JSONResponse({"error": "Provider not configured"}, status_code=400) return JSONResponse({"error": "Provider not configured"}, status_code=400)
# Получаем токен с PKCE verifier # Получаем токен с PKCE verifier
token = await client.authorize_access_token(request, code_verifier=request.session.get("code_verifier")) token = await client.authorize_access_token(request, code_verifier=code_verifier)
# Получаем профиль пользователя # Получаем профиль пользователя
profile = await get_user_profile(provider, client, token) profile = await get_user_profile(provider, client, token)
@ -142,10 +170,13 @@ async def oauth_callback(request):
author = session.query(Author).filter(Author.email == profile["email"]).first() author = session.query(Author).filter(Author.email == profile["email"]).first()
if not author: if not author:
# Генерируем slug из имени или email
slug = generate_unique_slug(profile["name"] or profile["email"].split("@")[0])
author = Author( author = Author(
email=profile["email"], email=profile["email"],
name=profile["name"], name=profile["name"],
username=profile["name"], slug=slug,
pic=profile.get("picture"), pic=profile.get("picture"),
oauth=f"{provider}:{profile['id']}", oauth=f"{provider}:{profile['id']}",
email_verified=True, email_verified=True,
@ -167,13 +198,9 @@ async def oauth_callback(request):
# Создаем сессию # Создаем сессию
session_token = await TokenStorage.create_session(author) session_token = await TokenStorage.create_session(author)
# Очищаем сессию OAuth # Формируем URL для редиректа с токеном
request.session.pop("code_verifier", None) redirect_url = f"{stored_redirect_uri}?state={state}&access_token={session_token}"
request.session.pop("provider", None) response = RedirectResponse(url=redirect_url)
request.session.pop("state", None)
# Возвращаем токен через cookie
response = RedirectResponse(url=f"{FRONTEND_URL}/auth/success")
response.set_cookie( response.set_cookie(
"session_token", "session_token",
session_token, session_token,
@ -185,4 +212,22 @@ async def oauth_callback(request):
return response return response
except Exception as e: except Exception as e:
return RedirectResponse(url=f"{FRONTEND_URL}/auth/error?message={str(e)}") logger.error(f"OAuth callback error: {str(e)}")
# В случае ошибки редиректим на фронтенд с ошибкой
fallback_redirect = request.query_params.get("redirect_uri", FRONTEND_URL)
return RedirectResponse(url=f"{fallback_redirect}?error=oauth_failed&message={str(e)}")
async def store_oauth_state(state: str, data: dict) -> None:
"""Сохраняет OAuth состояние в Redis с TTL"""
key = f"oauth_state:{state}"
await redis.execute("SETEX", key, OAUTH_STATE_TTL, orjson.dumps(data))
async def get_oauth_state(state: str) -> dict:
"""Получает и удаляет OAuth состояние из Redis (one-time use)"""
key = f"oauth_state:{state}"
data = await redis.execute("GET", key)
if data:
await redis.execute("DEL", key) # Одноразовое использование
return orjson.loads(data)
return None

10
main.py
View File

@ -7,7 +7,6 @@ from ariadne import load_schema_from_path, make_executable_schema
from ariadne.asgi import GraphQL from ariadne.asgi import GraphQL
from starlette.applications import Starlette from starlette.applications import Starlette
from starlette.middleware import Middleware from starlette.middleware import Middleware
from starlette.middleware.authentication import AuthenticationMiddleware
from starlette.middleware.cors import CORSMiddleware from starlette.middleware.cors import CORSMiddleware
from starlette.requests import Request from starlette.requests import Request
from starlette.responses import JSONResponse, Response from starlette.responses import JSONResponse, Response
@ -15,7 +14,6 @@ from starlette.routing import Mount, Route
from starlette.staticfiles import StaticFiles from starlette.staticfiles import StaticFiles
from auth.handler import EnhancedGraphQLHTTPHandler from auth.handler import EnhancedGraphQLHTTPHandler
from auth.internal import InternalAuthentication
from auth.middleware import AuthMiddleware, auth_middleware from auth.middleware import AuthMiddleware, auth_middleware
from cache.precache import precache_data from cache.precache import precache_data
from cache.revalidator import revalidation_manager from cache.revalidator import revalidation_manager
@ -26,6 +24,7 @@ from services.search import check_search_service, initialize_search_index_backgr
from services.viewed import ViewedStorage from services.viewed import ViewedStorage
from settings import DEV_SERVER_PID_FILE_NAME from settings import DEV_SERVER_PID_FILE_NAME
from utils.logger import root_logger as logger from utils.logger import root_logger as logger
from auth.oauth import oauth_login, oauth_callback
DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false" DEVMODE = os.getenv("DOKKU_APP_TYPE", "false").lower() == "false"
DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов
@ -57,10 +56,8 @@ middleware = [
allow_headers=["*"], allow_headers=["*"],
allow_credentials=True, allow_credentials=True,
), ),
# Сначала AuthMiddleware (для обработки токенов) # извлечение токена + аутентификация + cookies
Middleware(AuthMiddleware), Middleware(AuthMiddleware),
# Затем AuthenticationMiddleware (для создания request.user на основе токена)
Middleware(AuthenticationMiddleware, backend=InternalAuthentication()),
] ]
@ -217,6 +214,9 @@ async def lifespan(_app):
app = Starlette( app = Starlette(
routes=[ routes=[
Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]), Route("/graphql", graphql_handler, methods=["GET", "POST", "OPTIONS"]),
# OAuth маршруты
Route("/oauth/{provider}", oauth_login, methods=["GET"]),
Route("/oauth/{provider}/callback", oauth_callback, methods=["GET"]),
Mount("/", app=StaticFiles(directory=DIST_DIR, html=True)), Mount("/", app=StaticFiles(directory=DIST_DIR, html=True)),
], ],
lifespan=lifespan, lifespan=lifespan,

View File

@ -61,12 +61,12 @@ JWT_SECRET = os.getenv("JWT_SECRET", "your-secret-key")
JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30 JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30
JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30 JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30
# Настройки сессии # Настройки для HTTP cookies (используется в auth middleware)
SESSION_COOKIE_NAME = "auth_token" SESSION_COOKIE_NAME = "auth_token"
SESSION_COOKIE_SECURE = True SESSION_COOKIE_SECURE = True
SESSION_COOKIE_HTTPONLY = True SESSION_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = "lax" SESSION_COOKIE_SAMESITE = "lax"
SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 days SESSION_COOKIE_MAX_AGE = 30 * 24 * 60 * 60 # 30 дней
MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY", "") MAILGUN_API_KEY = os.getenv("MAILGUN_API_KEY", "")
MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN", "discours.io") MAILGUN_DOMAIN = os.getenv("MAILGUN_DOMAIN", "discours.io")