### 🚨 CRITICAL Security Fixes - **🔒 Open Redirect Protection**: Добавлена строгая валидация redirect_uri против whitelist доменов - **🔒 Rate Limiting**: Защита OAuth endpoints от брутфорса (10 попыток за 5 минут на IP) - **🔒 Logout Endpoint**: Критически важный endpoint для безопасного отзыва httpOnly cookies - **🔒 Provider Validation**: Усиленная валидация OAuth провайдеров с логированием атак - **🚨 GlitchTip Alerts**: Автоматические алерты безопасности в GlitchTip при критических событиях ### 🛡️ Security Modules - **auth/oauth_security.py**: Модуль безопасности OAuth с валидацией и rate limiting + GlitchTip алерты - **auth/logout.py**: Безопасный logout с поддержкой JSON API и browser redirect - **tests/test_oauth_security.py**: Комплексные тесты безопасности (11 тестов) - **tests/test_oauth_glitchtip_alerts.py**: Тесты интеграции с GlitchTip (8 тестов) ### 🔧 OAuth Improvements - **Minimal Flow**: Упрощен до минимума - только httpOnly cookie, нет JWT в URL - **Simple Logic**: Нет error параметра = успех, максимальная простота - **DRY Refactoring**: Устранено дублирование кода в logout и валидации ### 🎯 OAuth Endpoints - **Старт**: `v3.dscrs.site/oauth/{provider}` - с rate limiting и валидацией - **Callback**: `v3.dscrs.site/oauth/{provider}/callback` - безопасный redirect_uri - **Logout**: `v3.dscrs.site/auth/logout` - отзыв httpOnly cookies - **Финализация**: `testing.discours.io/oauth?redirect_url=...` - минимальная схема ### 📊 Security Test Coverage - ✅ Open redirect attack prevention - ✅ Rate limiting protection - ✅ Provider validation - ✅ Safe fallback mechanisms - ✅ Cookie security (httpOnly + Secure + SameSite) - ✅ GlitchTip integration (8 тестов алертов) ### 📝 Documentation - Создан `docs/oauth-minimal-flow.md` - полное описание минимального flow - Обновлена документация OAuth в `docs/auth/oauth.md` - Добавлены security best practices
This commit is contained in:
118
auth/oauth.py
118
auth/oauth.py
@@ -486,29 +486,48 @@ async def oauth_callback(request: Any) -> JSONResponse | RedirectResponse:
|
||||
if not isinstance(redirect_uri, str) or not redirect_uri:
|
||||
redirect_uri = FRONTEND_URL
|
||||
|
||||
# 🔧 Передаем JWT токен через URL параметры вместо cookie
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
# 🔧 Для testing.discours.io используем httpOnly cookies + простой редирект
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed_url = urlparse(redirect_uri)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
parsed_redirect = urlparse(redirect_uri)
|
||||
|
||||
# Добавляем access_token и state в URL параметры
|
||||
query_params["access_token"] = [session_token]
|
||||
if state:
|
||||
query_params["state"] = [state]
|
||||
# Определяем финальный URL для редиректа
|
||||
if "testing.discours.io" in parsed_redirect.netloc:
|
||||
# Для testing.discours.io только httpOnly cookie, без JWT в URL
|
||||
from urllib.parse import quote
|
||||
|
||||
# Собираем новый URL с параметрами
|
||||
new_query = urlencode(query_params, doseq=True)
|
||||
final_redirect_url = urlunparse(
|
||||
(parsed_url.scheme, parsed_url.netloc, parsed_url.path, parsed_url.params, new_query, parsed_url.fragment)
|
||||
)
|
||||
final_redirect_url = f"https://testing.discours.io/oauth?redirect_url={quote(redirect_uri)}"
|
||||
else:
|
||||
# Для других доменов используем старую логику с токеном в URL
|
||||
from urllib.parse import parse_qs, urlencode, urlunparse
|
||||
|
||||
parsed_url = urlparse(redirect_uri)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
|
||||
# Добавляем access_token и state в URL параметры
|
||||
query_params["access_token"] = [session_token]
|
||||
if state:
|
||||
query_params["state"] = [state]
|
||||
|
||||
# Собираем новый URL с параметрами
|
||||
new_query = urlencode(query_params, doseq=True)
|
||||
final_redirect_url = urlunparse(
|
||||
(
|
||||
parsed_url.scheme,
|
||||
parsed_url.netloc,
|
||||
parsed_url.path,
|
||||
parsed_url.params,
|
||||
new_query,
|
||||
parsed_url.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"🔗 OAuth redirect URL: {final_redirect_url}")
|
||||
|
||||
# Создаем ответ с редиректом
|
||||
response = RedirectResponse(url=final_redirect_url)
|
||||
|
||||
# 🍪 Оставляем cookie для обратной совместимости (опционально)
|
||||
# 🍪 Устанавливаем httpOnly cookie для безопасности
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
session_token,
|
||||
@@ -517,6 +536,7 @@ async def oauth_callback(request: Any) -> JSONResponse | RedirectResponse:
|
||||
samesite=SESSION_COOKIE_SAMESITE,
|
||||
max_age=SESSION_COOKIE_MAX_AGE,
|
||||
path="/", # Важно: устанавливаем path="/" для доступности cookie во всех путях
|
||||
domain=".discours.io" if "discours.io" in parsed_redirect.netloc else None, # Поддержка поддоменов
|
||||
)
|
||||
|
||||
logger.info(f"OAuth успешно завершен для {provider}, user_id={author.id}")
|
||||
@@ -570,12 +590,15 @@ async def oauth_login_http(request: Request) -> JSONResponse | RedirectResponse:
|
||||
code_challenge = create_s256_code_challenge(code_verifier)
|
||||
state = token_urlsafe(32)
|
||||
|
||||
# 🔍 Сохраняем состояние OAuth только в Redis (убираем зависимость от request.session)
|
||||
# Получаем redirect_uri из query параметров, path параметров или используем FRONTEND_URL по умолчанию
|
||||
# 🔍 Сохраняем redirect_uri из Referer header для testing.discours.io
|
||||
# Приоритет: query параметр → path параметр → Referer header → FRONTEND_URL
|
||||
final_redirect_uri = (
|
||||
request.query_params.get("redirect_uri") or request.path_params.get("redirect_uri") or FRONTEND_URL
|
||||
request.query_params.get("redirect_uri")
|
||||
or request.path_params.get("redirect_uri")
|
||||
or request.headers.get("referer")
|
||||
or FRONTEND_URL
|
||||
)
|
||||
logger.info(f"🎯 Final redirect URI: '{final_redirect_uri}'")
|
||||
logger.info(f"🎯 Final redirect URI: '{final_redirect_uri}' (from referer: {request.headers.get('referer')})")
|
||||
|
||||
oauth_data = {
|
||||
"code_verifier": code_verifier,
|
||||
@@ -640,16 +663,9 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon
|
||||
oauth_data = await get_oauth_state(state)
|
||||
if not oauth_data:
|
||||
logger.warning(f"🚨 OAuth state {state} not found or expired")
|
||||
# Более информативная ошибка для пользователя
|
||||
return JSONResponse(
|
||||
{
|
||||
"error": "oauth_state_expired",
|
||||
"message": "OAuth session expired. Please try logging in again.",
|
||||
"details": "The OAuth state was not found in Redis (expired or already used)",
|
||||
"action": "restart_oauth_flow",
|
||||
},
|
||||
status_code=400,
|
||||
)
|
||||
# Для testing.discours.io редиректим с ошибкой
|
||||
error_redirect = "https://testing.discours.io/oauth?error=oauth_state_expired"
|
||||
return RedirectResponse(url=error_redirect, status_code=302)
|
||||
|
||||
provider = oauth_data.get("provider")
|
||||
if not provider:
|
||||
@@ -764,21 +780,40 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon
|
||||
if not isinstance(redirect_uri, str) or not redirect_uri:
|
||||
redirect_uri = FRONTEND_URL
|
||||
|
||||
# 🔧 Передаем JWT токен через URL параметры вместо cookie
|
||||
from urllib.parse import parse_qs, urlencode, urlparse, urlunparse
|
||||
# 🔧 Для testing.discours.io используем httpOnly cookies + простой редирект
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed_url = urlparse(redirect_uri)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
parsed_redirect = urlparse(redirect_uri)
|
||||
|
||||
# Добавляем access_token и state в URL параметры
|
||||
query_params["access_token"] = [session_token]
|
||||
query_params["state"] = [state]
|
||||
# Определяем финальный URL для редиректа
|
||||
if "testing.discours.io" in parsed_redirect.netloc:
|
||||
# Для testing.discours.io только httpOnly cookie, без JWT в URL
|
||||
from urllib.parse import quote
|
||||
|
||||
# Собираем новый URL с параметрами
|
||||
new_query = urlencode(query_params, doseq=True)
|
||||
final_redirect_url = urlunparse(
|
||||
(parsed_url.scheme, parsed_url.netloc, parsed_url.path, parsed_url.params, new_query, parsed_url.fragment)
|
||||
)
|
||||
final_redirect_url = f"https://testing.discours.io/oauth?redirect_url={quote(redirect_uri)}"
|
||||
else:
|
||||
# Для других доменов используем старую логику с токеном в URL
|
||||
from urllib.parse import parse_qs, urlencode, urlunparse
|
||||
|
||||
parsed_url = urlparse(redirect_uri)
|
||||
query_params = parse_qs(parsed_url.query)
|
||||
|
||||
# Добавляем access_token и state в URL параметры
|
||||
query_params["access_token"] = [session_token]
|
||||
query_params["state"] = [state]
|
||||
|
||||
# Собираем новый URL с параметрами
|
||||
new_query = urlencode(query_params, doseq=True)
|
||||
final_redirect_url = urlunparse(
|
||||
(
|
||||
parsed_url.scheme,
|
||||
parsed_url.netloc,
|
||||
parsed_url.path,
|
||||
parsed_url.params,
|
||||
new_query,
|
||||
parsed_url.fragment,
|
||||
)
|
||||
)
|
||||
|
||||
logger.info(f"🔗 OAuth redirect URL: {final_redirect_url}")
|
||||
|
||||
@@ -794,7 +829,7 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon
|
||||
# Возвращаем redirect с токеном в URL
|
||||
response = RedirectResponse(url=final_redirect_url, status_code=307)
|
||||
|
||||
# 🍪 Оставляем cookie для обратной совместимости (опционально)
|
||||
# 🍪 Устанавливаем httpOnly cookie для безопасности
|
||||
response.set_cookie(
|
||||
SESSION_COOKIE_NAME,
|
||||
session_token,
|
||||
@@ -803,6 +838,7 @@ async def oauth_callback_http(request: Request) -> JSONResponse | RedirectRespon
|
||||
samesite=SESSION_COOKIE_SAMESITE,
|
||||
max_age=SESSION_COOKIE_MAX_AGE,
|
||||
path="/", # Важно: устанавливаем path="/" для доступности cookie во всех путях
|
||||
domain=".discours.io" if "discours.io" in parsed_redirect.netloc else None, # Поддержка поддоменов
|
||||
)
|
||||
|
||||
logger.info(f"OAuth успешно завершен для {provider}, user_id={author.id}")
|
||||
|
||||
Reference in New Issue
Block a user