middleware-fix+oauth-routes
All checks were successful
Deploy on push / deploy (push) Successful in 6s
All checks were successful
Deploy on push / deploy (push) Successful in 6s
This commit is contained in:
parent
f8ad73571c
commit
f160ab4d26
131
auth/internal.py
131
auth/internal.py
|
@ -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]:
|
||||||
"""
|
"""
|
||||||
Проверяет локальную авторизацию.
|
Проверяет локальную авторизацию.
|
||||||
|
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
10
main.py
|
@ -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,
|
||||||
|
|
|
@ -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")
|
||||||
|
|
Loading…
Reference in New Issue
Block a user