[0.9.29] - 2025-09-26
Some checks failed
Deploy on push / deploy (push) Failing after 39s

### 🚨 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:
2025-09-26 21:03:45 +03:00
parent ac0111cdb9
commit 05c188df62
18 changed files with 2255 additions and 56 deletions

View File

@@ -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}")