diff --git a/auth/internal.py b/auth/internal.py index d21b5dca..cdd130a5 100644 --- a/auth/internal.py +++ b/auth/internal.py @@ -1,19 +1,18 @@ +""" +Утилитные функции для внутренней аутентификации +Используются в GraphQL резолверах и декораторах +""" + import time from typing import Any, Optional, Tuple from sqlalchemy.orm import exc -from starlette.authentication import AuthenticationBackend, BaseUser, UnauthenticatedUser -from starlette.requests import HTTPConnection from auth.credentials import AuthCredentials -from auth.exceptions import ExpiredToken, InvalidToken -from auth.jwtcodec import JWTCodec from auth.orm import Author from auth.sessions import SessionManager from auth.state import AuthState -from auth.tokenstorage import TokenStorage from services.db import local_session -from services.redis import redis from settings import ADMIN_EMAILS as ADMIN_EMAILS_LIST from settings import SESSION_COOKIE_NAME, SESSION_TOKEN_HEADER 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(",") -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]: """ Проверяет локальную авторизацию. diff --git a/auth/middleware.py b/auth/middleware.py index 49c6b7ab..3b84600c 100644 --- a/auth/middleware.py +++ b/auth/middleware.py @@ -1,15 +1,23 @@ """ -Middleware для обработки авторизации в GraphQL запросах +Единый middleware для обработки авторизации в GraphQL запросах """ +import time from typing import Any, Dict +from starlette.authentication import UnauthenticatedUser from starlette.datastructures import Headers from starlette.requests import Request from starlette.responses import JSONResponse, Response 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 ( + ADMIN_EMAILS as ADMIN_EMAILS_LIST, SESSION_COOKIE_HTTPONLY, SESSION_COOKIE_MAX_AGE, SESSION_COOKIE_NAME, @@ -19,21 +27,101 @@ from settings import ( ) 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: """ - Универсальный middleware для обработки авторизации и управления cookies. + Единый middleware для обработки авторизации и аутентификации. Основные функции: 1. Извлечение Bearer токена из заголовка Authorization или cookie - 2. Добавление токена в заголовки запроса для обработки AuthenticationMiddleware - 3. Предоставление методов для установки/удаления cookies в GraphQL резолверах + 2. Проверка сессии через SessionManager + 3. Создание request.user и request.auth + 4. Предоставление методов для установки/удаления cookies """ def __init__(self, app: ASGIApp): self.app = app 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): """Обработка ASGI запроса""" if scope["type"] != "http": @@ -87,26 +175,25 @@ class AuthMiddleware: ) break - # Если токен получен, обновляем заголовки в scope + # Аутентифицируем пользователя + auth, user = await self.authenticate_user(token) + + # Добавляем в scope данные авторизации и пользователя + scope["auth"] = auth + scope["user"] = user + if token: - # Создаем новый список заголовков + # Обновляем заголовки в scope для совместимости new_headers = [] for name, value in scope["headers"]: - # Пропускаем оригинальный заголовок авторизации if name.decode("latin1").lower() != SESSION_TOKEN_HEADER.lower(): new_headers.append((name, value)) - - # Добавляем заголовок с чистым токеном new_headers.append((SESSION_TOKEN_HEADER.encode("latin1"), token.encode("latin1"))) - - # Обновляем заголовки в scope scope["headers"] = new_headers - - # Также добавляем информацию о типе аутентификации для дальнейшего использования - scope["auth"] = {"type": "bearer", "token": token, "source": token_source} - logger.debug(f"[middleware] Токен добавлен в scope для аутентификации из источника: {token_source}") + + logger.debug(f"[middleware] Пользователь аутентифицирован: {user.is_authenticated}") else: - logger.debug(f"[middleware] Токен не найден ни в заголовке, ни в cookie") + logger.debug(f"[middleware] Токен не найден, пользователь неаутентифицирован") await self.app(scope, receive, send) diff --git a/auth/oauth.py b/auth/oauth.py index 410557d1..e85480cf 100644 --- a/auth/oauth.py +++ b/auth/oauth.py @@ -1,4 +1,5 @@ import time +import orjson from secrets import token_urlsafe from authlib.integrations.starlette_client import OAuth @@ -8,10 +9,16 @@ from starlette.responses import JSONResponse, RedirectResponse from auth.orm import Author from auth.tokenstorage import TokenStorage from services.db import local_session +from services.redis import redis 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 state management через Redis (TTL 10 минут) +OAUTH_STATE_TTL = 600 # 10 минут + # Конфигурация провайдеров PROVIDERS = { "google": { @@ -90,47 +97,68 @@ async def oauth_login(request): if not client: 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 code_verifier = token_urlsafe(32) code_challenge = create_s256_code_challenge(code_verifier) - # Сохраняем code_verifier в сессии - request.session["code_verifier"] = code_verifier - request.session["provider"] = provider - request.session["state"] = token_urlsafe(16) + # Сохраняем состояние OAuth в Redis + oauth_data = { + "code_verifier": code_verifier, + "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: return await client.authorize_redirect( request, - redirect_uri, + oauth_callback_uri, code_challenge=code_challenge, code_challenge_method="S256", - state=request.session["state"], + state=state, ) except Exception as e: + logger.error(f"OAuth redirect error for {provider}: {str(e)}") return JSONResponse({"error": str(e)}, status_code=500) async def oauth_callback(request): """Обрабатывает callback от OAuth провайдера""" 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: 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) if not client: return JSONResponse({"error": "Provider not configured"}, status_code=400) # Получаем токен с 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) @@ -142,10 +170,13 @@ async def oauth_callback(request): author = session.query(Author).filter(Author.email == profile["email"]).first() if not author: + # Генерируем slug из имени или email + slug = generate_unique_slug(profile["name"] or profile["email"].split("@")[0]) + author = Author( email=profile["email"], name=profile["name"], - username=profile["name"], + slug=slug, pic=profile.get("picture"), oauth=f"{provider}:{profile['id']}", email_verified=True, @@ -167,13 +198,9 @@ async def oauth_callback(request): # Создаем сессию session_token = await TokenStorage.create_session(author) - # Очищаем сессию OAuth - request.session.pop("code_verifier", None) - request.session.pop("provider", None) - request.session.pop("state", None) - - # Возвращаем токен через cookie - response = RedirectResponse(url=f"{FRONTEND_URL}/auth/success") + # Формируем URL для редиректа с токеном + redirect_url = f"{stored_redirect_uri}?state={state}&access_token={session_token}" + response = RedirectResponse(url=redirect_url) response.set_cookie( "session_token", session_token, @@ -185,4 +212,22 @@ async def oauth_callback(request): return response 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 diff --git a/main.py b/main.py index 70509504..ae502126 100644 --- a/main.py +++ b/main.py @@ -7,7 +7,6 @@ from ariadne import load_schema_from_path, make_executable_schema from ariadne.asgi import GraphQL from starlette.applications import Starlette from starlette.middleware import Middleware -from starlette.middleware.authentication import AuthenticationMiddleware from starlette.middleware.cors import CORSMiddleware from starlette.requests import Request from starlette.responses import JSONResponse, Response @@ -15,7 +14,6 @@ from starlette.routing import Mount, Route from starlette.staticfiles import StaticFiles from auth.handler import EnhancedGraphQLHTTPHandler -from auth.internal import InternalAuthentication from auth.middleware import AuthMiddleware, auth_middleware from cache.precache import precache_data 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 settings import DEV_SERVER_PID_FILE_NAME 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" DIST_DIR = join(os.path.dirname(__file__), "dist") # Директория для собранных файлов @@ -57,10 +56,8 @@ middleware = [ allow_headers=["*"], allow_credentials=True, ), - # Сначала AuthMiddleware (для обработки токенов) + # извлечение токена + аутентификация + cookies Middleware(AuthMiddleware), - # Затем AuthenticationMiddleware (для создания request.user на основе токена) - Middleware(AuthenticationMiddleware, backend=InternalAuthentication()), ] @@ -217,6 +214,9 @@ async def lifespan(_app): app = Starlette( routes=[ 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)), ], lifespan=lifespan, diff --git a/settings.py b/settings.py index 3bf874c8..65d994b8 100644 --- a/settings.py +++ b/settings.py @@ -61,12 +61,12 @@ JWT_SECRET = os.getenv("JWT_SECRET", "your-secret-key") JWT_ACCESS_TOKEN_EXPIRE_MINUTES = 30 JWT_REFRESH_TOKEN_EXPIRE_DAYS = 30 -# Настройки сессии +# Настройки для HTTP cookies (используется в auth middleware) SESSION_COOKIE_NAME = "auth_token" SESSION_COOKIE_SECURE = True SESSION_COOKIE_HTTPONLY = True 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_DOMAIN = os.getenv("MAILGUN_DOMAIN", "discours.io")