Spaces:
Running
Running
Upload 12 files
Browse files- Dockerfile +2 -1
- pyproject.toml +1 -0
- src/auth/__init__.py +23 -0
- src/auth/config.py +113 -0
- src/auth/magic_link.py +276 -0
- src/auth/oauth_provider.py +379 -0
- src/auth/routes.py +568 -0
- src/auth/session_manager.py +232 -0
- src/auth/token_validator.py +142 -0
- src/auth/tool_handlers.py +343 -0
- src/db/supabase_adapter.py +152 -0
- src/mcp/server_streamable.py +109 -2
Dockerfile
CHANGED
|
@@ -30,7 +30,8 @@ RUN pip install --no-cache-dir \
|
|
| 30 |
python-frontmatter \
|
| 31 |
pyyaml \
|
| 32 |
click \
|
| 33 |
-
aiohttp
|
|
|
|
| 34 |
|
| 35 |
# ์ ํ๋ฆฌ์ผ์ด์
์ฝ๋ ๋ณต์ฌ
|
| 36 |
COPY . .
|
|
|
|
| 30 |
python-frontmatter \
|
| 31 |
pyyaml \
|
| 32 |
click \
|
| 33 |
+
aiohttp \
|
| 34 |
+
PyJWT
|
| 35 |
|
| 36 |
# ์ ํ๋ฆฌ์ผ์ด์
์ฝ๋ ๋ณต์ฌ
|
| 37 |
COPY . .
|
pyproject.toml
CHANGED
|
@@ -26,6 +26,7 @@ uvicorn = "^0.27.0"
|
|
| 26 |
aiohttp = "^3.9.0"
|
| 27 |
sentence-transformers = "^2.2.0"
|
| 28 |
torch = "^2.0.0"
|
|
|
|
| 29 |
|
| 30 |
|
| 31 |
[tool.poetry.group.dev.dependencies]
|
|
|
|
| 26 |
aiohttp = "^3.9.0"
|
| 27 |
sentence-transformers = "^2.2.0"
|
| 28 |
torch = "^2.0.0"
|
| 29 |
+
PyJWT = "^2.8.0"
|
| 30 |
|
| 31 |
|
| 32 |
[tool.poetry.group.dev.dependencies]
|
src/auth/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User Authentication Module
|
| 3 |
+
==========================
|
| 4 |
+
|
| 5 |
+
Supabase Auth ๊ธฐ๋ฐ ์ฌ์ฉ์ ์ธ์ฆ ๋ฐ ์ธ์
๊ด๋ฆฌ.
|
| 6 |
+
|
| 7 |
+
์ง์ ์ธ์ฆ ๋ฐฉ์:
|
| 8 |
+
- OAuth2 Authorization Code Flow (GPTs Actions์ฉ)
|
| 9 |
+
- Magic Link + ์ธ์ฆ์ฝ๋ (Claude Desktop/MCP ํด๋ผ์ด์ธํธ์ฉ)
|
| 10 |
+
|
| 11 |
+
์ฃผ์ ์ปดํฌ๋ํธ:
|
| 12 |
+
- OAuthProvider: GPTs์ฉ OAuth2 ์๋ํฌ์ธํธ ์ ๊ณต
|
| 13 |
+
- MagicLinkAuth: MCP ํด๋ผ์ด์ธํธ์ฉ ์ด๋ฉ์ผ ์ธ์ฆ
|
| 14 |
+
- UserSessionManager: ์ฌ์ฉ์ ์ธ์
ํ ํฐ ๊ด๋ฆฌ (MCP SessionManager์ ๋ณ๊ฐ)
|
| 15 |
+
- TokenValidator: JWT ํ ํฐ ๊ฒ์ฆ
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
from .config import SUPPORTED_CHAINS, AUTH_CODE_TTL_SECONDS
|
| 19 |
+
|
| 20 |
+
__all__ = [
|
| 21 |
+
"SUPPORTED_CHAINS",
|
| 22 |
+
"AUTH_CODE_TTL_SECONDS",
|
| 23 |
+
]
|
src/auth/config.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication Configuration
|
| 3 |
+
=============================
|
| 4 |
+
|
| 5 |
+
์ธ์ฆ ๊ด๋ จ ํ๊ฒฝ๋ณ์ ๋ฐ ์์ ์ ์.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import os
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger("eodi.auth.config")
|
| 12 |
+
|
| 13 |
+
# =============================================================================
|
| 14 |
+
# ํ๊ฒฝ๋ณ์ ๋ก๋
|
| 15 |
+
# =============================================================================
|
| 16 |
+
|
| 17 |
+
# Supabase ์ค์ (๊ธฐ์กด - ์ฌ์ฌ์ฉ)
|
| 18 |
+
SUPABASE_URL = os.getenv("SUPABASE_URL")
|
| 19 |
+
SUPABASE_KEY = os.getenv("SUPABASE_KEY")
|
| 20 |
+
|
| 21 |
+
# Supabase JWT Secret (ํ ํฐ ๊ฒ์ฆ์ฉ)
|
| 22 |
+
# ์์น: Supabase Dashboard > Settings > API > JWT Settings > JWT Secret
|
| 23 |
+
SUPABASE_JWT_SECRET = os.getenv("SUPABASE_JWT_SECRET")
|
| 24 |
+
|
| 25 |
+
# OAuth2 Provider ์ค์ (GPTs์ฉ)
|
| 26 |
+
OAUTH_CLIENT_ID = os.getenv("OAUTH_CLIENT_ID", "eodi-gpts")
|
| 27 |
+
OAUTH_CLIENT_SECRET = os.getenv("OAUTH_CLIENT_SECRET")
|
| 28 |
+
|
| 29 |
+
# ์๋ฒ URL (์ฝ๋ฐฑ์ฉ)
|
| 30 |
+
SERVER_BASE_URL = os.getenv(
|
| 31 |
+
"SERVER_BASE_URL",
|
| 32 |
+
"https://lovelymango-eodi-mcp.hf.space"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Magic Link ๋ฆฌ๋ค์ด๋ ํธ URL
|
| 36 |
+
MAGIC_LINK_REDIRECT_URL = f"{SERVER_BASE_URL}/auth/callback"
|
| 37 |
+
|
| 38 |
+
# =============================================================================
|
| 39 |
+
# ์์
|
| 40 |
+
# =============================================================================
|
| 41 |
+
|
| 42 |
+
# ์ธ์ฆ ์ฝ๋ ์ค์
|
| 43 |
+
AUTH_CODE_TTL_SECONDS = 180 # 3๋ถ
|
| 44 |
+
AUTH_CODE_LENGTH = 6
|
| 45 |
+
AUTH_CODE_MAX_ATTEMPTS = 5 # ์ต๋ ์๋ ํ์ (brute force ๋ฐฉ์ง)
|
| 46 |
+
|
| 47 |
+
# OAuth State TTL
|
| 48 |
+
OAUTH_STATE_TTL_SECONDS = 600 # 10๋ถ
|
| 49 |
+
|
| 50 |
+
# ์ง์ ์ฒด์ธ ๋ฐ ๋ฑ๊ธ
|
| 51 |
+
SUPPORTED_CHAINS = {
|
| 52 |
+
"HILTON": ["Member", "Silver", "Gold", "Diamond"],
|
| 53 |
+
"MARRIOTT": ["Member", "Silver", "Gold", "Platinum", "Titanium", "Ambassador"],
|
| 54 |
+
"IHG": ["Member", "Silver", "Gold", "Platinum", "Diamond"],
|
| 55 |
+
"ACCOR": ["Member", "Silver", "Gold", "Platinum", "Diamond"],
|
| 56 |
+
"HYATT": ["Member", "Discoverist", "Explorist", "Globalist"],
|
| 57 |
+
}
|
| 58 |
+
|
| 59 |
+
# ๋ฑ๊ธ ์ ๊ทํ ๋งคํ (์ฌ์ฉ์ ์
๋ ฅ โ ์ ์ ๋ช
์นญ)
|
| 60 |
+
TIER_ALIASES = {
|
| 61 |
+
# ํํผ
|
| 62 |
+
"๊ณจ๋": "Gold",
|
| 63 |
+
"๋ค์ด์": "Diamond",
|
| 64 |
+
"๋ค์ด์๋ชฌ๋": "Diamond",
|
| 65 |
+
"์ค๋ฒ": "Silver",
|
| 66 |
+
# ๋ฉ๋ฆฌ์ดํธ
|
| 67 |
+
"ํ๋ํฐ๋": "Platinum",
|
| 68 |
+
"ํ๋": "Platinum",
|
| 69 |
+
"ํฐํ๋": "Titanium",
|
| 70 |
+
"์ฐ๋ฒ์๋": "Ambassador",
|
| 71 |
+
# IHG
|
| 72 |
+
"๋ค์ด์": "Diamond",
|
| 73 |
+
# ํ์ํธ
|
| 74 |
+
"๊ธ๋ก๋ฒ๋ฆฌ์คํธ": "Globalist",
|
| 75 |
+
"์ต์คํ๋ก๋ฆฌ์คํธ": "Explorist",
|
| 76 |
+
}
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
# =============================================================================
|
| 80 |
+
# ํ๊ฒฝ๋ณ์ ๊ฒ์ฆ
|
| 81 |
+
# =============================================================================
|
| 82 |
+
|
| 83 |
+
def validate_config():
|
| 84 |
+
"""ํ์ ํ๊ฒฝ๋ณ์ ๊ฒ์ฆ. ์๋ฒ ์์ ์ ํธ์ถ."""
|
| 85 |
+
missing = []
|
| 86 |
+
|
| 87 |
+
if not SUPABASE_URL:
|
| 88 |
+
missing.append("SUPABASE_URL")
|
| 89 |
+
if not SUPABASE_KEY:
|
| 90 |
+
missing.append("SUPABASE_KEY")
|
| 91 |
+
|
| 92 |
+
# JWT Secret์ ํ ํฐ ๊ฒ์ฆ์๋ง ํ์, ์์ผ๋ฉด ๊ฒฝ๊ณ ๋ง
|
| 93 |
+
if not SUPABASE_JWT_SECRET:
|
| 94 |
+
logger.warning(
|
| 95 |
+
"SUPABASE_JWT_SECRET์ด ์ค์ ๋์ง ์์์ต๋๋ค. "
|
| 96 |
+
"์๋ฒ ์ธก JWT ๊ฒ์ฆ์ด ๋นํ์ฑํ๋ฉ๋๋ค. "
|
| 97 |
+
"์ค์ ์์น: Supabase Dashboard > Settings > API > JWT Settings"
|
| 98 |
+
)
|
| 99 |
+
|
| 100 |
+
# OAuth Secret์ GPTs ์ฐ๋์๋ง ํ์, ์์ผ๋ฉด ๊ฒฝ๊ณ ๋ง
|
| 101 |
+
if not OAUTH_CLIENT_SECRET:
|
| 102 |
+
logger.warning(
|
| 103 |
+
"OAUTH_CLIENT_SECRET์ด ์ค์ ๋์ง ์์์ต๋๋ค. "
|
| 104 |
+
"GPTs OAuth ์ฐ๋์ด ๋นํ์ฑํ๋ฉ๋๋ค."
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
if missing:
|
| 108 |
+
raise ValueError(
|
| 109 |
+
f"ํ์ ํ๊ฒฝ๋ณ์๊ฐ ๋๋ฝ๋์์ต๋๋ค: {', '.join(missing)}. "
|
| 110 |
+
"HF Space: Settings > Repository secrets์์ ์ค์ ํ์ธ์."
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
logger.info("์ธ์ฆ ์ค์ ๊ฒ์ฆ ์๋ฃ")
|
src/auth/magic_link.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Magic Link Authentication
|
| 3 |
+
=========================
|
| 4 |
+
|
| 5 |
+
Claude Desktop ๋ฑ OAuth๋ฅผ ์ง์ํ์ง ์๋ MCP ํด๋ผ์ด์ธํธ์ฉ.
|
| 6 |
+
|
| 7 |
+
ํ๋ฆ:
|
| 8 |
+
1. ์ฌ์ฉ์๊ฐ ์ด๋ฉ์ผ ์
๋ ฅ โ user_request_auth Tool ํธ์ถ
|
| 9 |
+
2. Supabase Magic Link ๋ฐ์ก
|
| 10 |
+
3. ์ฌ์ฉ์๊ฐ ์ด๋ฉ์ผ์์ ๋งํฌ ํด๋ฆญ โ /auth/callback ํ์ด์ง
|
| 11 |
+
4. ํ์ด์ง์์ 6์๋ฆฌ ์ฝ๋ ํ์
|
| 12 |
+
5. ์ฌ์ฉ์๊ฐ GPT/Claude๋ก ๋์์์ ์ฝ๋ ์
๋ ฅ โ user_verify_code Tool ํธ์ถ
|
| 13 |
+
6. ์ฝ๋ ๊ฒ์ฆ โ ์ธ์
ํ ํฐ ๋ฐํ
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import secrets
|
| 17 |
+
import time
|
| 18 |
+
import logging
|
| 19 |
+
from typing import Dict, Any, Optional, Tuple
|
| 20 |
+
from collections import defaultdict
|
| 21 |
+
|
| 22 |
+
import httpx
|
| 23 |
+
|
| 24 |
+
from .config import (
|
| 25 |
+
SUPABASE_URL,
|
| 26 |
+
SUPABASE_KEY,
|
| 27 |
+
MAGIC_LINK_REDIRECT_URL,
|
| 28 |
+
AUTH_CODE_TTL_SECONDS,
|
| 29 |
+
AUTH_CODE_LENGTH,
|
| 30 |
+
AUTH_CODE_MAX_ATTEMPTS,
|
| 31 |
+
)
|
| 32 |
+
|
| 33 |
+
logger = logging.getLogger("eodi.auth.magic_link")
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
class AuthCodeStore:
|
| 37 |
+
"""
|
| 38 |
+
์ธ์ฆ ์ฝ๋ ์์ ์ ์ฅ์.
|
| 39 |
+
|
| 40 |
+
Magic Link ํด๋ฆญ ํ ํ์๋๋ 6์๋ฆฌ ์ฝ๋ ๊ด๋ฆฌ.
|
| 41 |
+
Rate limiting ๋ฐ brute force ๋ฐฉ์ง ํฌํจ.
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
def __init__(self, ttl_seconds: int = AUTH_CODE_TTL_SECONDS):
|
| 45 |
+
self.ttl = ttl_seconds
|
| 46 |
+
self._codes: Dict[str, Dict[str, Any]] = {}
|
| 47 |
+
self._attempts: Dict[str, int] = defaultdict(int) # IP๋ณ ์๋ ํ์
|
| 48 |
+
self._last_cleanup = time.time()
|
| 49 |
+
|
| 50 |
+
def _cleanup(self):
|
| 51 |
+
"""๋ง๋ฃ๋ ์ฝ๋ ๋ฐ ์๋ ํ์ ์ ๋ฆฌ"""
|
| 52 |
+
now = time.time()
|
| 53 |
+
if now - self._last_cleanup < 60:
|
| 54 |
+
return
|
| 55 |
+
|
| 56 |
+
self._last_cleanup = now
|
| 57 |
+
|
| 58 |
+
# ๋ง๋ฃ๋ ์ฝ๋ ์ญ์
|
| 59 |
+
expired = [
|
| 60 |
+
c for c, data in self._codes.items()
|
| 61 |
+
if now - data["created_at"] > self.ttl
|
| 62 |
+
]
|
| 63 |
+
for c in expired:
|
| 64 |
+
del self._codes[c]
|
| 65 |
+
|
| 66 |
+
# ์ค๋๋ ์๋ ํ์ ๋ฆฌ์
|
| 67 |
+
self._attempts.clear()
|
| 68 |
+
|
| 69 |
+
def generate_code(
|
| 70 |
+
self,
|
| 71 |
+
user_id: str,
|
| 72 |
+
email: str,
|
| 73 |
+
access_token: str,
|
| 74 |
+
refresh_token: str
|
| 75 |
+
) -> str:
|
| 76 |
+
"""
|
| 77 |
+
6์๋ฆฌ ์ธ์ฆ ์ฝ๋ ์์ฑ.
|
| 78 |
+
|
| 79 |
+
Args:
|
| 80 |
+
user_id: Supabase Auth UUID
|
| 81 |
+
email: ์ฌ์ฉ์ ์ด๋ฉ์ผ
|
| 82 |
+
access_token: Supabase access_token
|
| 83 |
+
refresh_token: Supabase refresh_token
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
6์๋ฆฌ ์ซ์ ์ฝ๋
|
| 87 |
+
"""
|
| 88 |
+
self._cleanup()
|
| 89 |
+
|
| 90 |
+
# ๊ฐ์ user_id์ ๊ธฐ์กด ์ฝ๋ ์ญ์ (์ค๋ณต ๋ฐฉ์ง)
|
| 91 |
+
existing = [
|
| 92 |
+
c for c, data in self._codes.items()
|
| 93 |
+
if data["user_id"] == user_id
|
| 94 |
+
]
|
| 95 |
+
for c in existing:
|
| 96 |
+
del self._codes[c]
|
| 97 |
+
|
| 98 |
+
# 6์๋ฆฌ ์ซ์ ์ฝ๋ ์์ฑ
|
| 99 |
+
code = "".join([str(secrets.randbelow(10)) for _ in range(AUTH_CODE_LENGTH)])
|
| 100 |
+
|
| 101 |
+
self._codes[code] = {
|
| 102 |
+
"user_id": user_id,
|
| 103 |
+
"email": email,
|
| 104 |
+
"access_token": access_token,
|
| 105 |
+
"refresh_token": refresh_token,
|
| 106 |
+
"created_at": time.time(),
|
| 107 |
+
}
|
| 108 |
+
|
| 109 |
+
return code
|
| 110 |
+
|
| 111 |
+
def verify_code(
|
| 112 |
+
self,
|
| 113 |
+
code: str,
|
| 114 |
+
client_ip: str = "unknown"
|
| 115 |
+
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
| 116 |
+
"""
|
| 117 |
+
์ฝ๋ ๊ฒ์ฆ ๋ฐ ์๋น.
|
| 118 |
+
|
| 119 |
+
Args:
|
| 120 |
+
code: 6์๋ฆฌ ์ธ์ฆ ์ฝ๋
|
| 121 |
+
client_ip: ํด๋ผ์ด์ธํธ IP (rate limiting์ฉ)
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
(์ ํจ์ฌ๋ถ, code_data, error_message)
|
| 125 |
+
"""
|
| 126 |
+
self._cleanup()
|
| 127 |
+
|
| 128 |
+
# Rate limiting ์ฒดํฌ
|
| 129 |
+
if self._attempts[client_ip] >= AUTH_CODE_MAX_ATTEMPTS:
|
| 130 |
+
logger.warning(f"Rate limit exceeded for IP: {client_ip[:20]}...")
|
| 131 |
+
return False, None, f"๋๋ฌด ๋ง์ ์๋์
๋๋ค. {self.ttl}์ด ํ ๋ค์ ์๋ํด์ฃผ์ธ์."
|
| 132 |
+
|
| 133 |
+
self._attempts[client_ip] += 1
|
| 134 |
+
|
| 135 |
+
# ์ฝ๋ ์ ๊ทํ (๊ณต๋ฐฑ ์ ๊ฑฐ ๋ฑ)
|
| 136 |
+
code = code.strip().replace(" ", "").replace("-", "")
|
| 137 |
+
|
| 138 |
+
if code not in self._codes:
|
| 139 |
+
return False, None, "์ฝ๋๊ฐ ์ผ์นํ์ง ์์ต๋๋ค. ๋ค์ ํ์ธํด์ฃผ์ธ์."
|
| 140 |
+
|
| 141 |
+
code_data = self._codes.pop(code)
|
| 142 |
+
|
| 143 |
+
if time.time() - code_data["created_at"] > self.ttl:
|
| 144 |
+
return False, None, "์ฝ๋๊ฐ ๋ง๋ฃ๋์์ต๋๋ค. ์ ์ฝ๋๋ฅผ ์์ฒญํด์ฃผ์ธ์."
|
| 145 |
+
|
| 146 |
+
# ์ฑ๊ณต ์ ์๋ ํ์ ๋ฆฌ์
|
| 147 |
+
self._attempts[client_ip] = 0
|
| 148 |
+
|
| 149 |
+
return True, code_data, None
|
| 150 |
+
|
| 151 |
+
|
| 152 |
+
class MagicLinkAuth:
|
| 153 |
+
"""
|
| 154 |
+
Magic Link ์ธ์ฆ ํธ๋ค๋ฌ.
|
| 155 |
+
"""
|
| 156 |
+
|
| 157 |
+
def __init__(self):
|
| 158 |
+
self.code_store = AuthCodeStore()
|
| 159 |
+
self._http_client = None
|
| 160 |
+
|
| 161 |
+
async def _get_client(self) -> httpx.AsyncClient:
|
| 162 |
+
"""HTTP ํด๋ผ์ด์ธํธ (์ฌ์ฌ์ฉ)"""
|
| 163 |
+
if self._http_client is None:
|
| 164 |
+
self._http_client = httpx.AsyncClient(timeout=30)
|
| 165 |
+
return self._http_client
|
| 166 |
+
|
| 167 |
+
async def send_magic_link(self, email: str) -> Tuple[bool, Optional[str]]:
|
| 168 |
+
"""
|
| 169 |
+
Magic Link ๏ฟฝ๏ฟฝ๏ฟฝ๋ฉ์ผ ๋ฐ์ก.
|
| 170 |
+
|
| 171 |
+
Args:
|
| 172 |
+
email: ์ฌ์ฉ์ ์ด๋ฉ์ผ
|
| 173 |
+
|
| 174 |
+
Returns:
|
| 175 |
+
(์ฑ๊ณต์ฌ๋ถ, error_message)
|
| 176 |
+
"""
|
| 177 |
+
try:
|
| 178 |
+
client = await self._get_client()
|
| 179 |
+
|
| 180 |
+
response = await client.post(
|
| 181 |
+
f"{SUPABASE_URL}/auth/v1/magiclink",
|
| 182 |
+
json={
|
| 183 |
+
"email": email,
|
| 184 |
+
"options": {
|
| 185 |
+
"redirectTo": MAGIC_LINK_REDIRECT_URL
|
| 186 |
+
}
|
| 187 |
+
},
|
| 188 |
+
headers={
|
| 189 |
+
"apikey": SUPABASE_KEY,
|
| 190 |
+
"Content-Type": "application/json"
|
| 191 |
+
}
|
| 192 |
+
)
|
| 193 |
+
|
| 194 |
+
if response.status_code == 200:
|
| 195 |
+
logger.info(f"Magic Link ๋ฐ์ก ์ฑ๊ณต: {email[:3]}***")
|
| 196 |
+
return True, None
|
| 197 |
+
else:
|
| 198 |
+
error_text = response.text[:200]
|
| 199 |
+
logger.error(f"Magic Link ๋ฐ์ก ์คํจ: {response.status_code} - {error_text}")
|
| 200 |
+
return False, f"์ด๋ฉ์ผ ๋ฐ์ก์ ์คํจํ์ต๋๋ค: {error_text}"
|
| 201 |
+
|
| 202 |
+
except Exception as e:
|
| 203 |
+
logger.error(f"Magic Link ๋ฐ์ก ์ค๋ฅ: {e}")
|
| 204 |
+
return False, f"์ด๋ฉ์ผ ๋ฐ์ก ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: {e}"
|
| 205 |
+
|
| 206 |
+
def generate_auth_code(
|
| 207 |
+
self,
|
| 208 |
+
user_id: str,
|
| 209 |
+
email: str,
|
| 210 |
+
access_token: str,
|
| 211 |
+
refresh_token: str
|
| 212 |
+
) -> str:
|
| 213 |
+
"""
|
| 214 |
+
์ธ์ฆ ์ฑ๊ณต ํ 6์๋ฆฌ ์ฝ๋ ์์ฑ.
|
| 215 |
+
|
| 216 |
+
Magic Link ํด๋ฆญ โ /auth/callback์์ ํธ์ถ.
|
| 217 |
+
"""
|
| 218 |
+
return self.code_store.generate_code(user_id, email, access_token, refresh_token)
|
| 219 |
+
|
| 220 |
+
def verify_auth_code(
|
| 221 |
+
self,
|
| 222 |
+
code: str,
|
| 223 |
+
client_ip: str = "unknown"
|
| 224 |
+
) -> Tuple[bool, Optional[str], Optional[str], Optional[str]]:
|
| 225 |
+
"""
|
| 226 |
+
์ธ์ฆ ์ฝ๋ ๊ฒ์ฆ.
|
| 227 |
+
|
| 228 |
+
Args:
|
| 229 |
+
code: 6์๋ฆฌ ์ธ์ฆ ์ฝ๋
|
| 230 |
+
client_ip: ํด๋ผ์ด์ธํธ IP
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
(์ ํจ์ฌ๋ถ, session_token, email_masked, error_message)
|
| 234 |
+
"""
|
| 235 |
+
is_valid, code_data, error = self.code_store.verify_code(code, client_ip)
|
| 236 |
+
|
| 237 |
+
if not is_valid:
|
| 238 |
+
return False, None, None, error
|
| 239 |
+
|
| 240 |
+
# ์ธ์
์์ฑ
|
| 241 |
+
from .session_manager import get_user_session_manager
|
| 242 |
+
|
| 243 |
+
session_manager = get_user_session_manager()
|
| 244 |
+
session_token = session_manager.create_session(
|
| 245 |
+
user_id=code_data["user_id"],
|
| 246 |
+
email=code_data["email"],
|
| 247 |
+
access_token=code_data["access_token"],
|
| 248 |
+
refresh_token=code_data["refresh_token"],
|
| 249 |
+
expires_in=3600
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
# ์ด๋ฉ์ผ ๋ง์คํน
|
| 253 |
+
email = code_data["email"]
|
| 254 |
+
if "@" in email:
|
| 255 |
+
local, domain = email.split("@", 1)
|
| 256 |
+
if len(local) <= 2:
|
| 257 |
+
masked = local[0] + "*"
|
| 258 |
+
else:
|
| 259 |
+
masked = local[0] + "*" * (len(local) - 2) + local[-1]
|
| 260 |
+
email_masked = f"{masked}@{domain}"
|
| 261 |
+
else:
|
| 262 |
+
email_masked = email
|
| 263 |
+
|
| 264 |
+
return True, session_token, email_masked, None
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
# ์ ์ญ ์ธ์คํด์ค (Lazy loading)
|
| 268 |
+
_magic_link_auth = None
|
| 269 |
+
|
| 270 |
+
|
| 271 |
+
def get_magic_link_auth() -> MagicLinkAuth:
|
| 272 |
+
"""MagicLinkAuth ์ฑ๊ธํค ์ธ์คํด์ค ๋ฐํ"""
|
| 273 |
+
global _magic_link_auth
|
| 274 |
+
if _magic_link_auth is None:
|
| 275 |
+
_magic_link_auth = MagicLinkAuth()
|
| 276 |
+
return _magic_link_auth
|
src/auth/oauth_provider.py
ADDED
|
@@ -0,0 +1,379 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
OAuth2 Provider for GPTs Actions
|
| 3 |
+
================================
|
| 4 |
+
|
| 5 |
+
Supabase Auth๋ฅผ ๊ฐ์ธ๋ OAuth2 Provider ๋ ์ด์ด.
|
| 6 |
+
GPTs Actions๊ฐ ์๊ตฌํ๋ /authorize, /token ์๋ํฌ์ธํธ ์ ๊ณต.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import secrets
|
| 10 |
+
import time
|
| 11 |
+
import logging
|
| 12 |
+
from typing import Dict, Any, Optional, Tuple
|
| 13 |
+
from urllib.parse import urlencode
|
| 14 |
+
|
| 15 |
+
import httpx
|
| 16 |
+
|
| 17 |
+
from .config import (
|
| 18 |
+
SUPABASE_URL,
|
| 19 |
+
SUPABASE_KEY,
|
| 20 |
+
OAUTH_CLIENT_ID,
|
| 21 |
+
OAUTH_CLIENT_SECRET,
|
| 22 |
+
SERVER_BASE_URL,
|
| 23 |
+
OAUTH_STATE_TTL_SECONDS,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
logger = logging.getLogger("eodi.auth.oauth")
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
class OAuthStateManager:
|
| 30 |
+
"""
|
| 31 |
+
OAuth state ํ๋ผ๋ฏธํฐ ๊ด๋ฆฌ (CSRF ๋ฐฉ์ง).
|
| 32 |
+
"""
|
| 33 |
+
|
| 34 |
+
MAX_STATES = 1000
|
| 35 |
+
|
| 36 |
+
def __init__(self, ttl_seconds: int = OAUTH_STATE_TTL_SECONDS):
|
| 37 |
+
self.ttl = ttl_seconds
|
| 38 |
+
self._states: Dict[str, Dict[str, Any]] = {}
|
| 39 |
+
self._last_cleanup = time.time()
|
| 40 |
+
|
| 41 |
+
def _cleanup_expired(self):
|
| 42 |
+
"""๋ง๋ฃ๋ state ์ ๋ฆฌ"""
|
| 43 |
+
now = time.time()
|
| 44 |
+
if now - self._last_cleanup < 60:
|
| 45 |
+
return
|
| 46 |
+
|
| 47 |
+
self._last_cleanup = now
|
| 48 |
+
expired = [
|
| 49 |
+
s for s, data in self._states.items()
|
| 50 |
+
if now - data["created_at"] > self.ttl
|
| 51 |
+
]
|
| 52 |
+
for s in expired:
|
| 53 |
+
del self._states[s]
|
| 54 |
+
|
| 55 |
+
if expired:
|
| 56 |
+
logger.debug(f"๋ง๋ฃ๋ state {len(expired)}๊ฐ ์ ๋ฆฌ๋จ")
|
| 57 |
+
|
| 58 |
+
def create_state(self, redirect_uri: str, scope: str = "profile") -> str:
|
| 59 |
+
"""
|
| 60 |
+
์ state ์์ฑ.
|
| 61 |
+
|
| 62 |
+
Args:
|
| 63 |
+
redirect_uri: GPTs ์ฝ๋ฐฑ URL
|
| 64 |
+
scope: ์์ฒญ ์ค์ฝํ
|
| 65 |
+
|
| 66 |
+
Returns:
|
| 67 |
+
state ๋ฌธ์์ด
|
| 68 |
+
"""
|
| 69 |
+
self._cleanup_expired()
|
| 70 |
+
|
| 71 |
+
# ๋ฉ๋ชจ๋ฆฌ ๋ณดํธ
|
| 72 |
+
if len(self._states) >= self.MAX_STATES:
|
| 73 |
+
oldest = min(self._states.items(), key=lambda x: x[1]["created_at"])
|
| 74 |
+
del self._states[oldest[0]]
|
| 75 |
+
|
| 76 |
+
state = secrets.token_urlsafe(32)
|
| 77 |
+
self._states[state] = {
|
| 78 |
+
"redirect_uri": redirect_uri,
|
| 79 |
+
"scope": scope,
|
| 80 |
+
"created_at": time.time(),
|
| 81 |
+
}
|
| 82 |
+
return state
|
| 83 |
+
|
| 84 |
+
def validate_and_consume(self, state: str) -> Tuple[bool, Optional[Dict[str, Any]]]:
|
| 85 |
+
"""
|
| 86 |
+
state ๊ฒ์ฆ ๋ฐ ์๋น (์ผํ์ฉ).
|
| 87 |
+
|
| 88 |
+
Args:
|
| 89 |
+
state: ๊ฒ์ฆํ state
|
| 90 |
+
|
| 91 |
+
Returns:
|
| 92 |
+
(์ ํจ์ฌ๋ถ, state_data)
|
| 93 |
+
"""
|
| 94 |
+
self._cleanup_expired()
|
| 95 |
+
|
| 96 |
+
if state not in self._states:
|
| 97 |
+
logger.warning(f"State not found: {state[:10]}...")
|
| 98 |
+
return False, None
|
| 99 |
+
|
| 100 |
+
state_data = self._states.pop(state)
|
| 101 |
+
|
| 102 |
+
if time.time() - state_data["created_at"] > self.ttl:
|
| 103 |
+
logger.warning(f"State expired: {state[:10]}...")
|
| 104 |
+
return False, None
|
| 105 |
+
|
| 106 |
+
return True, state_data
|
| 107 |
+
|
| 108 |
+
|
| 109 |
+
class AuthCodeStore:
|
| 110 |
+
"""
|
| 111 |
+
OAuth Authorization Code ์์ ์ ์ฅ์.
|
| 112 |
+
|
| 113 |
+
Supabase ์ธ์ฆ ์๋ฃ ํ โ GPTs์ ์ ๋ฌํ code ๊ด๋ฆฌ.
|
| 114 |
+
"""
|
| 115 |
+
|
| 116 |
+
CODE_TTL_SECONDS = 60 # 1๋ถ (๋งค์ฐ ์งง๊ฒ)
|
| 117 |
+
MAX_CODES = 500
|
| 118 |
+
|
| 119 |
+
def __init__(self):
|
| 120 |
+
self._codes: Dict[str, Dict[str, Any]] = {}
|
| 121 |
+
self._last_cleanup = time.time()
|
| 122 |
+
|
| 123 |
+
def _cleanup(self):
|
| 124 |
+
now = time.time()
|
| 125 |
+
if now - self._last_cleanup < 30:
|
| 126 |
+
return
|
| 127 |
+
self._last_cleanup = now
|
| 128 |
+
|
| 129 |
+
expired = [
|
| 130 |
+
c for c, data in self._codes.items()
|
| 131 |
+
if now - data["created_at"] > self.CODE_TTL_SECONDS
|
| 132 |
+
]
|
| 133 |
+
for c in expired:
|
| 134 |
+
del self._codes[c]
|
| 135 |
+
|
| 136 |
+
def create_code(
|
| 137 |
+
self,
|
| 138 |
+
user_id: str,
|
| 139 |
+
email: str,
|
| 140 |
+
access_token: str,
|
| 141 |
+
refresh_token: str,
|
| 142 |
+
redirect_uri: str
|
| 143 |
+
) -> str:
|
| 144 |
+
"""Authorization code ์์ฑ"""
|
| 145 |
+
self._cleanup()
|
| 146 |
+
|
| 147 |
+
if len(self._codes) >= self.MAX_CODES:
|
| 148 |
+
oldest = min(self._codes.items(), key=lambda x: x[1]["created_at"])
|
| 149 |
+
del self._codes[oldest[0]]
|
| 150 |
+
|
| 151 |
+
code = secrets.token_urlsafe(32)
|
| 152 |
+
self._codes[code] = {
|
| 153 |
+
"user_id": user_id,
|
| 154 |
+
"email": email,
|
| 155 |
+
"access_token": access_token,
|
| 156 |
+
"refresh_token": refresh_token,
|
| 157 |
+
"redirect_uri": redirect_uri,
|
| 158 |
+
"created_at": time.time(),
|
| 159 |
+
}
|
| 160 |
+
return code
|
| 161 |
+
|
| 162 |
+
def consume_code(
|
| 163 |
+
self,
|
| 164 |
+
code: str,
|
| 165 |
+
redirect_uri: str
|
| 166 |
+
) -> Tuple[bool, Optional[Dict[str, Any]]]:
|
| 167 |
+
"""
|
| 168 |
+
Code ๊ฒ์ฆ ๋ฐ ์๋น.
|
| 169 |
+
|
| 170 |
+
Returns:
|
| 171 |
+
(์ ํจ์ฌ๋ถ, code_data)
|
| 172 |
+
"""
|
| 173 |
+
self._cleanup()
|
| 174 |
+
|
| 175 |
+
if code not in self._codes:
|
| 176 |
+
return False, None
|
| 177 |
+
|
| 178 |
+
code_data = self._codes.pop(code)
|
| 179 |
+
|
| 180 |
+
# redirect_uri ๊ฒ์ฆ
|
| 181 |
+
if code_data["redirect_uri"] != redirect_uri:
|
| 182 |
+
logger.warning("redirect_uri mismatch")
|
| 183 |
+
return False, None
|
| 184 |
+
|
| 185 |
+
if time.time() - code_data["created_at"] > self.CODE_TTL_SECONDS:
|
| 186 |
+
return False, None
|
| 187 |
+
|
| 188 |
+
return True, code_data
|
| 189 |
+
|
| 190 |
+
|
| 191 |
+
class OAuthProvider:
|
| 192 |
+
"""
|
| 193 |
+
OAuth2 Authorization Code Flow Provider.
|
| 194 |
+
|
| 195 |
+
GPTs Actions ์ฐ๋์ฉ.
|
| 196 |
+
|
| 197 |
+
ํ๋ฆ:
|
| 198 |
+
1. GPTs โ /oauth/authorize โ Supabase ๋ก๊ทธ์ธ ํ์ด์ง
|
| 199 |
+
2. ์ฌ์ฉ์ ๋ก๊ทธ์ธ โ Supabase โ /oauth/callback
|
| 200 |
+
3. /oauth/callback โ GPTs callback URL๋ก code ์ ๋ฌ
|
| 201 |
+
4. GPTs โ /oauth/token โ access_token ๋ฐ๊ธ
|
| 202 |
+
"""
|
| 203 |
+
|
| 204 |
+
def __init__(self):
|
| 205 |
+
self.state_manager = OAuthStateManager()
|
| 206 |
+
self.code_store = AuthCodeStore()
|
| 207 |
+
self._http_client = None
|
| 208 |
+
|
| 209 |
+
@property
|
| 210 |
+
def is_configured(self) -> bool:
|
| 211 |
+
"""OAuth ์ค์ ์๋ฃ ์ฌ๋ถ"""
|
| 212 |
+
return bool(OAUTH_CLIENT_SECRET)
|
| 213 |
+
|
| 214 |
+
async def _get_client(self) -> httpx.AsyncClient:
|
| 215 |
+
"""HTTP ํด๋ผ์ด์ธํธ (์ฌ์ฌ์ฉ)"""
|
| 216 |
+
if self._http_client is None:
|
| 217 |
+
self._http_client = httpx.AsyncClient(timeout=30)
|
| 218 |
+
return self._http_client
|
| 219 |
+
|
| 220 |
+
def get_authorization_url(
|
| 221 |
+
self,
|
| 222 |
+
redirect_uri: str,
|
| 223 |
+
scope: str = "profile"
|
| 224 |
+
) -> Tuple[str, str]:
|
| 225 |
+
"""
|
| 226 |
+
๋ด๋ถ ๋ก๊ทธ์ธ ํ์ด์ง URL ์์ฑ.
|
| 227 |
+
|
| 228 |
+
Args:
|
| 229 |
+
redirect_uri: GPTs ์ฝ๋ฐฑ URL
|
| 230 |
+
scope: ์์ฒญ ์ค์ฝํ
|
| 231 |
+
|
| 232 |
+
Returns:
|
| 233 |
+
(authorization_url, state)
|
| 234 |
+
"""
|
| 235 |
+
state = self.state_manager.create_state(redirect_uri, scope)
|
| 236 |
+
|
| 237 |
+
# ์์ฒด ๋ก๊ทธ์ธ ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ
|
| 238 |
+
params = {
|
| 239 |
+
"redirect_uri": redirect_uri,
|
| 240 |
+
"state": state,
|
| 241 |
+
"scope": scope,
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
auth_url = f"{SERVER_BASE_URL}/auth/login?{urlencode(params)}"
|
| 245 |
+
return auth_url, state
|
| 246 |
+
|
| 247 |
+
async def handle_supabase_callback(
|
| 248 |
+
self,
|
| 249 |
+
access_token: str,
|
| 250 |
+
refresh_token: str,
|
| 251 |
+
state: str
|
| 252 |
+
) -> Tuple[bool, Optional[str], Optional[str], Optional[str]]:
|
| 253 |
+
"""
|
| 254 |
+
Supabase ๋ก๊ทธ์ธ ์๋ฃ ํ ์ฒ๋ฆฌ.
|
| 255 |
+
|
| 256 |
+
Args:
|
| 257 |
+
access_token: Supabase access_token
|
| 258 |
+
refresh_token: Supabase refresh_token
|
| 259 |
+
state: OAuth state
|
| 260 |
+
|
| 261 |
+
Returns:
|
| 262 |
+
(์ฑ๊ณต์ฌ๋ถ, authorization_code, redirect_uri, error_message)
|
| 263 |
+
"""
|
| 264 |
+
# state ๊ฒ์ฆ
|
| 265 |
+
is_valid, state_data = self.state_manager.validate_and_consume(state)
|
| 266 |
+
if not is_valid:
|
| 267 |
+
return False, None, None, "Invalid or expired state"
|
| 268 |
+
|
| 269 |
+
redirect_uri = state_data["redirect_uri"]
|
| 270 |
+
|
| 271 |
+
# ํ ํฐ์์ ์ฌ์ฉ์ ์ ๋ณด ์ถ์ถ
|
| 272 |
+
try:
|
| 273 |
+
client = await self._get_client()
|
| 274 |
+
response = await client.get(
|
| 275 |
+
f"{SUPABASE_URL}/auth/v1/user",
|
| 276 |
+
headers={"Authorization": f"Bearer {access_token}"}
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
if response.status_code != 200:
|
| 280 |
+
return False, None, None, "Failed to get user info"
|
| 281 |
+
|
| 282 |
+
user_data = response.json()
|
| 283 |
+
user_id = user_data.get("id")
|
| 284 |
+
email = user_data.get("email")
|
| 285 |
+
|
| 286 |
+
except Exception as e:
|
| 287 |
+
logger.error(f"User info ์กฐํ ์คํจ: {e}")
|
| 288 |
+
return False, None, None, str(e)
|
| 289 |
+
|
| 290 |
+
# Authorization code ์์ฑ
|
| 291 |
+
code = self.code_store.create_code(
|
| 292 |
+
user_id=user_id,
|
| 293 |
+
email=email,
|
| 294 |
+
access_token=access_token,
|
| 295 |
+
refresh_token=refresh_token,
|
| 296 |
+
redirect_uri=redirect_uri
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
return True, code, redirect_uri, None
|
| 300 |
+
|
| 301 |
+
async def exchange_code_for_token(
|
| 302 |
+
self,
|
| 303 |
+
code: str,
|
| 304 |
+
redirect_uri: str,
|
| 305 |
+
client_id: str,
|
| 306 |
+
client_secret: str
|
| 307 |
+
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
| 308 |
+
"""
|
| 309 |
+
Authorization code๋ฅผ access_token์ผ๋ก ๊ตํ.
|
| 310 |
+
|
| 311 |
+
Args:
|
| 312 |
+
code: Authorization code
|
| 313 |
+
redirect_uri: ์๋ redirect_uri
|
| 314 |
+
client_id: OAuth ํด๋ผ์ด์ธํธ ID
|
| 315 |
+
client_secret: OAuth ํด๋ผ์ด์ธํธ Secret
|
| 316 |
+
|
| 317 |
+
Returns:
|
| 318 |
+
(์ฑ๊ณต์ฌ๋ถ, token_response, error_message)
|
| 319 |
+
"""
|
| 320 |
+
# ํด๋ผ์ด์ธํธ ์ธ์ฆ
|
| 321 |
+
if client_id != OAUTH_CLIENT_ID:
|
| 322 |
+
return False, None, "Invalid client_id"
|
| 323 |
+
if client_secret != OAUTH_CLIENT_SECRET:
|
| 324 |
+
return False, None, "Invalid client_secret"
|
| 325 |
+
|
| 326 |
+
# code ๊ฒ์ฆ ๋ฐ ์๋น
|
| 327 |
+
is_valid, code_data = self.code_store.consume_code(code, redirect_uri)
|
| 328 |
+
if not is_valid:
|
| 329 |
+
return False, None, "Invalid or expired code"
|
| 330 |
+
|
| 331 |
+
# ์ธ์
์์ฑ
|
| 332 |
+
from .session_manager import get_user_session_manager
|
| 333 |
+
|
| 334 |
+
session_manager = get_user_session_manager()
|
| 335 |
+
session_token = session_manager.create_session(
|
| 336 |
+
user_id=code_data["user_id"],
|
| 337 |
+
email=code_data["email"],
|
| 338 |
+
access_token=code_data["access_token"],
|
| 339 |
+
refresh_token=code_data["refresh_token"],
|
| 340 |
+
expires_in=3600
|
| 341 |
+
)
|
| 342 |
+
|
| 343 |
+
# GPTs์ ๋ฐํํ ํ ํฐ ์๋ต
|
| 344 |
+
# ์ฃผ์: ์ฌ๊ธฐ์ ๋ฐํํ๋ access_token์ ์ฐ๋ฆฌ์ session_token
|
| 345 |
+
token_response = {
|
| 346 |
+
"access_token": session_token,
|
| 347 |
+
"token_type": "Bearer",
|
| 348 |
+
"expires_in": 3600,
|
| 349 |
+
"refresh_token": secrets.token_urlsafe(32), # ๋๋ฏธ
|
| 350 |
+
"scope": "profile"
|
| 351 |
+
}
|
| 352 |
+
|
| 353 |
+
return True, token_response, None
|
| 354 |
+
|
| 355 |
+
async def refresh_token(
|
| 356 |
+
self,
|
| 357 |
+
refresh_token: str,
|
| 358 |
+
client_id: str,
|
| 359 |
+
client_secret: str
|
| 360 |
+
) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
| 361 |
+
"""
|
| 362 |
+
Refresh token์ผ๋ก ์ access_token ๋ฐ๊ธ.
|
| 363 |
+
|
| 364 |
+
ํ์ฌ ๊ตฌํ์์๋ ๋ฏธ์ง์ (MVP).
|
| 365 |
+
"""
|
| 366 |
+
# TODO: Supabase refresh token์ผ๋ก ์ ํ ํฐ ๋ฐ๊ธ ๊ตฌํ
|
| 367 |
+
return False, None, "Token refresh not implemented yet"
|
| 368 |
+
|
| 369 |
+
|
| 370 |
+
# ์ ์ญ ์ธ์คํด์ค (Lazy loading)
|
| 371 |
+
_oauth_provider = None
|
| 372 |
+
|
| 373 |
+
|
| 374 |
+
def get_oauth_provider() -> OAuthProvider:
|
| 375 |
+
"""OAuthProvider ์ฑ๊ธํค ์ธ์คํด์ค ๋ฐํ"""
|
| 376 |
+
global _oauth_provider
|
| 377 |
+
if _oauth_provider is None:
|
| 378 |
+
_oauth_provider = OAuthProvider()
|
| 379 |
+
return _oauth_provider
|
src/auth/routes.py
ADDED
|
@@ -0,0 +1,568 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication HTTP Routes
|
| 3 |
+
==========================
|
| 4 |
+
|
| 5 |
+
OAuth2 ๋ฐ Magic Link ์ธ์ฆ ์๋ํฌ์ธํธ.
|
| 6 |
+
server_streamable.py์ routes์ ํ์ฅ๋ฉ๋๋ค.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from urllib.parse import urlencode
|
| 11 |
+
|
| 12 |
+
from starlette.routing import Route
|
| 13 |
+
from starlette.requests import Request
|
| 14 |
+
from starlette.responses import JSONResponse, RedirectResponse, HTMLResponse
|
| 15 |
+
|
| 16 |
+
from .oauth_provider import get_oauth_provider
|
| 17 |
+
from .magic_link import get_magic_link_auth
|
| 18 |
+
from .config import SERVER_BASE_URL, SUPABASE_URL
|
| 19 |
+
|
| 20 |
+
logger = logging.getLogger("eodi.auth.routes")
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
# =============================================================================
|
| 24 |
+
# OAuth2 ์๋ํฌ์ธํธ (GPTs์ฉ)
|
| 25 |
+
# =============================================================================
|
| 26 |
+
|
| 27 |
+
async def oauth_authorize(request: Request) -> RedirectResponse:
|
| 28 |
+
"""
|
| 29 |
+
GET /oauth/authorize
|
| 30 |
+
|
| 31 |
+
GPTs OAuth ์์์ . ๋ก๊ทธ์ธ ํ์ด์ง๋ก ๋ฆฌ๋ค์ด๋ ํธ.
|
| 32 |
+
"""
|
| 33 |
+
oauth_provider = get_oauth_provider()
|
| 34 |
+
|
| 35 |
+
if not oauth_provider.is_configured:
|
| 36 |
+
return JSONResponse({
|
| 37 |
+
"error": "OAuth not configured",
|
| 38 |
+
"message": "OAUTH_CLIENT_SECRET ํ๊ฒฝ๋ณ์๊ฐ ์ค์ ๋์ง ์์์ต๋๋ค."
|
| 39 |
+
}, status_code=503)
|
| 40 |
+
|
| 41 |
+
redirect_uri = request.query_params.get("redirect_uri")
|
| 42 |
+
scope = request.query_params.get("scope", "profile")
|
| 43 |
+
gpts_state = request.query_params.get("state", "")
|
| 44 |
+
|
| 45 |
+
if not redirect_uri:
|
| 46 |
+
return JSONResponse({"error": "redirect_uri is required"}, status_code=400)
|
| 47 |
+
|
| 48 |
+
# ๋ด๋ถ state ์์ฑ (redirect_uri ์ ์ฅ์ฉ)
|
| 49 |
+
auth_url, internal_state = oauth_provider.get_authorization_url(redirect_uri, scope)
|
| 50 |
+
|
| 51 |
+
# GPTs state๋ ํจ๊ป ์ ๋ฌ
|
| 52 |
+
if gpts_state:
|
| 53 |
+
auth_url += f"&gpts_state={gpts_state}"
|
| 54 |
+
|
| 55 |
+
logger.info("OAuth authorize: redirect to login page")
|
| 56 |
+
return RedirectResponse(auth_url, status_code=302)
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def oauth_callback(request: Request):
|
| 60 |
+
"""
|
| 61 |
+
GET /oauth/callback
|
| 62 |
+
|
| 63 |
+
(๋ด๋ถ ์๋ํฌ์ธํธ - ์ง์ ์ฌ์ฉ๋์ง ์์)
|
| 64 |
+
"""
|
| 65 |
+
return JSONResponse({"message": "Use /auth/callback instead"})
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
async def oauth_token(request: Request) -> JSONResponse:
|
| 69 |
+
"""
|
| 70 |
+
POST /oauth/token
|
| 71 |
+
|
| 72 |
+
Authorization code โ Access token ๊ตํ.
|
| 73 |
+
"""
|
| 74 |
+
oauth_provider = get_oauth_provider()
|
| 75 |
+
|
| 76 |
+
if not oauth_provider.is_configured:
|
| 77 |
+
return JSONResponse({"error": "OAuth not configured"}, status_code=503)
|
| 78 |
+
|
| 79 |
+
# form-urlencoded ๋๋ JSON ๋ฐ๋ ์ฒ๋ฆฌ
|
| 80 |
+
content_type = request.headers.get("content-type", "")
|
| 81 |
+
|
| 82 |
+
if "application/x-www-form-urlencoded" in content_type:
|
| 83 |
+
form = await request.form()
|
| 84 |
+
grant_type = form.get("grant_type")
|
| 85 |
+
code = form.get("code")
|
| 86 |
+
redirect_uri = form.get("redirect_uri")
|
| 87 |
+
client_id = form.get("client_id")
|
| 88 |
+
client_secret = form.get("client_secret")
|
| 89 |
+
refresh_token = form.get("refresh_token")
|
| 90 |
+
else:
|
| 91 |
+
try:
|
| 92 |
+
body = await request.json()
|
| 93 |
+
grant_type = body.get("grant_type")
|
| 94 |
+
code = body.get("code")
|
| 95 |
+
redirect_uri = body.get("redirect_uri")
|
| 96 |
+
client_id = body.get("client_id")
|
| 97 |
+
client_secret = body.get("client_secret")
|
| 98 |
+
refresh_token = body.get("refresh_token")
|
| 99 |
+
except Exception:
|
| 100 |
+
return JSONResponse({"error": "invalid_request"}, status_code=400)
|
| 101 |
+
|
| 102 |
+
if grant_type == "authorization_code":
|
| 103 |
+
if not code or not redirect_uri:
|
| 104 |
+
return JSONResponse({"error": "invalid_request"}, status_code=400)
|
| 105 |
+
|
| 106 |
+
success, token_response, error = await oauth_provider.exchange_code_for_token(
|
| 107 |
+
code=code,
|
| 108 |
+
redirect_uri=redirect_uri,
|
| 109 |
+
client_id=client_id,
|
| 110 |
+
client_secret=client_secret
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
if success:
|
| 114 |
+
return JSONResponse(token_response)
|
| 115 |
+
else:
|
| 116 |
+
return JSONResponse(
|
| 117 |
+
{"error": "invalid_grant", "error_description": error},
|
| 118 |
+
status_code=400
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
elif grant_type == "refresh_token":
|
| 122 |
+
success, token_response, error = await oauth_provider.refresh_token(
|
| 123 |
+
refresh_token=refresh_token,
|
| 124 |
+
client_id=client_id,
|
| 125 |
+
client_secret=client_secret
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
if success:
|
| 129 |
+
return JSONResponse(token_response)
|
| 130 |
+
else:
|
| 131 |
+
return JSONResponse(
|
| 132 |
+
{"error": "invalid_grant", "error_description": error},
|
| 133 |
+
status_code=400
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
else:
|
| 137 |
+
return JSONResponse({"error": "unsupported_grant_type"}, status_code=400)
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# =============================================================================
|
| 141 |
+
# Magic Link ์๋ํฌ์ธํธ (MCP ํด๋ผ์ด์ธํธ์ฉ)
|
| 142 |
+
# =============================================================================
|
| 143 |
+
|
| 144 |
+
async def auth_login_page(request: Request) -> HTMLResponse:
|
| 145 |
+
"""
|
| 146 |
+
GET /auth/login
|
| 147 |
+
|
| 148 |
+
๋ก๊ทธ์ธ ํ์ด์ง. ์ด๋ฉ์ผ ์
๋ ฅ โ Magic Link ๋ฐ๏ฟฝ๏ฟฝ.
|
| 149 |
+
"""
|
| 150 |
+
redirect_uri = request.query_params.get("redirect_uri", "")
|
| 151 |
+
state = request.query_params.get("state", "")
|
| 152 |
+
gpts_state = request.query_params.get("gpts_state", "")
|
| 153 |
+
|
| 154 |
+
html = f"""
|
| 155 |
+
<!DOCTYPE html>
|
| 156 |
+
<html>
|
| 157 |
+
<head>
|
| 158 |
+
<title>Eodi ๋ก๊ทธ์ธ</title>
|
| 159 |
+
<meta charset="utf-8">
|
| 160 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 161 |
+
<style>
|
| 162 |
+
body {{
|
| 163 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 164 |
+
max-width: 400px;
|
| 165 |
+
margin: 50px auto;
|
| 166 |
+
padding: 20px;
|
| 167 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 168 |
+
min-height: 100vh;
|
| 169 |
+
box-sizing: border-box;
|
| 170 |
+
}}
|
| 171 |
+
.container {{
|
| 172 |
+
background: white;
|
| 173 |
+
padding: 40px 30px;
|
| 174 |
+
border-radius: 16px;
|
| 175 |
+
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
| 176 |
+
}}
|
| 177 |
+
h1 {{
|
| 178 |
+
color: #333;
|
| 179 |
+
font-size: 28px;
|
| 180 |
+
margin-bottom: 8px;
|
| 181 |
+
text-align: center;
|
| 182 |
+
}}
|
| 183 |
+
.subtitle {{
|
| 184 |
+
color: #666;
|
| 185 |
+
text-align: center;
|
| 186 |
+
margin-bottom: 30px;
|
| 187 |
+
}}
|
| 188 |
+
input[type="email"] {{
|
| 189 |
+
width: 100%;
|
| 190 |
+
padding: 14px;
|
| 191 |
+
border: 2px solid #e0e0e0;
|
| 192 |
+
border-radius: 10px;
|
| 193 |
+
font-size: 16px;
|
| 194 |
+
margin-bottom: 16px;
|
| 195 |
+
box-sizing: border-box;
|
| 196 |
+
transition: border-color 0.2s;
|
| 197 |
+
}}
|
| 198 |
+
input[type="email"]:focus {{
|
| 199 |
+
outline: none;
|
| 200 |
+
border-color: #667eea;
|
| 201 |
+
}}
|
| 202 |
+
button {{
|
| 203 |
+
width: 100%;
|
| 204 |
+
padding: 14px;
|
| 205 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 206 |
+
color: white;
|
| 207 |
+
border: none;
|
| 208 |
+
border-radius: 10px;
|
| 209 |
+
font-size: 16px;
|
| 210 |
+
font-weight: 600;
|
| 211 |
+
cursor: pointer;
|
| 212 |
+
transition: transform 0.2s, box-shadow 0.2s;
|
| 213 |
+
}}
|
| 214 |
+
button:hover {{
|
| 215 |
+
transform: translateY(-2px);
|
| 216 |
+
box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4);
|
| 217 |
+
}}
|
| 218 |
+
button:disabled {{
|
| 219 |
+
background: #ccc;
|
| 220 |
+
transform: none;
|
| 221 |
+
box-shadow: none;
|
| 222 |
+
}}
|
| 223 |
+
.message {{ margin-top: 16px; text-align: center; }}
|
| 224 |
+
.success {{ color: #10b981; }}
|
| 225 |
+
.error {{ color: #ef4444; }}
|
| 226 |
+
.info {{
|
| 227 |
+
color: #6b7280;
|
| 228 |
+
font-size: 13px;
|
| 229 |
+
margin-top: 20px;
|
| 230 |
+
text-align: center;
|
| 231 |
+
}}
|
| 232 |
+
</style>
|
| 233 |
+
</head>
|
| 234 |
+
<body>
|
| 235 |
+
<div class="container">
|
| 236 |
+
<h1>๐จ Eodi</h1>
|
| 237 |
+
<p class="subtitle">์ฌํ ํํ์ ๋ชจ๋ ๊ฒ</p>
|
| 238 |
+
|
| 239 |
+
<form id="loginForm">
|
| 240 |
+
<input type="hidden" name="redirect_uri" value="{redirect_uri}">
|
| 241 |
+
<input type="hidden" name="state" value="{state}">
|
| 242 |
+
<input type="hidden" name="gpts_state" value="{gpts_state}">
|
| 243 |
+
<input type="email" name="email" placeholder="์ด๋ฉ์ผ ์ฃผ์" required>
|
| 244 |
+
<button type="submit" id="submitBtn">๋ก๊ทธ์ธ ๋งํฌ ๋ฐ๊ธฐ</button>
|
| 245 |
+
</form>
|
| 246 |
+
|
| 247 |
+
<div id="message"></div>
|
| 248 |
+
<p class="info">๋ก๊ทธ์ธ ๋งํฌ๋ 10๋ถ๊ฐ ์ ํจํฉ๋๋ค.</p>
|
| 249 |
+
</div>
|
| 250 |
+
|
| 251 |
+
<script>
|
| 252 |
+
document.getElementById('loginForm').addEventListener('submit', async (e) => {{
|
| 253 |
+
e.preventDefault();
|
| 254 |
+
const form = e.target;
|
| 255 |
+
const email = form.email.value;
|
| 256 |
+
const button = document.getElementById('submitBtn');
|
| 257 |
+
const messageDiv = document.getElementById('message');
|
| 258 |
+
|
| 259 |
+
button.disabled = true;
|
| 260 |
+
button.textContent = '์ ์ก ์ค...';
|
| 261 |
+
messageDiv.innerHTML = '';
|
| 262 |
+
|
| 263 |
+
try {{
|
| 264 |
+
const response = await fetch('/auth/send-link', {{
|
| 265 |
+
method: 'POST',
|
| 266 |
+
headers: {{'Content-Type': 'application/json'}},
|
| 267 |
+
body: JSON.stringify({{
|
| 268 |
+
email: email,
|
| 269 |
+
redirect_uri: form.redirect_uri.value,
|
| 270 |
+
state: form.state.value,
|
| 271 |
+
gpts_state: form.gpts_state.value
|
| 272 |
+
}})
|
| 273 |
+
}});
|
| 274 |
+
|
| 275 |
+
const data = await response.json();
|
| 276 |
+
|
| 277 |
+
if (data.success) {{
|
| 278 |
+
messageDiv.innerHTML = '<p class="message success">โ
์ด๋ฉ์ผ์ ํ์ธํด์ฃผ์ธ์!</p>';
|
| 279 |
+
button.textContent = '์ ์ก ์๋ฃ';
|
| 280 |
+
}} else {{
|
| 281 |
+
messageDiv.innerHTML = '<p class="message error">โ ' + data.error + '</p>';
|
| 282 |
+
button.disabled = false;
|
| 283 |
+
button.textContent = '๋ก๊ทธ์ธ ๋งํฌ ๋ฐ๊ธฐ';
|
| 284 |
+
}}
|
| 285 |
+
}} catch (err) {{
|
| 286 |
+
messageDiv.innerHTML = '<p class="message error">โ ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค.</p>';
|
| 287 |
+
button.disabled = false;
|
| 288 |
+
button.textContent = '๋ก๊ทธ์ธ ๋งํฌ ๋ฐ๊ธฐ';
|
| 289 |
+
}}
|
| 290 |
+
}});
|
| 291 |
+
</script>
|
| 292 |
+
</body>
|
| 293 |
+
</html>
|
| 294 |
+
"""
|
| 295 |
+
return HTMLResponse(html)
|
| 296 |
+
|
| 297 |
+
|
| 298 |
+
async def auth_send_link(request: Request) -> JSONResponse:
|
| 299 |
+
"""
|
| 300 |
+
POST /auth/send-link
|
| 301 |
+
|
| 302 |
+
Magic Link ๋ฐ์ก API.
|
| 303 |
+
"""
|
| 304 |
+
magic_link_auth = get_magic_link_auth()
|
| 305 |
+
|
| 306 |
+
try:
|
| 307 |
+
body = await request.json()
|
| 308 |
+
email = body.get("email")
|
| 309 |
+
|
| 310 |
+
if not email:
|
| 311 |
+
return JSONResponse(
|
| 312 |
+
{"success": False, "error": "์ด๋ฉ์ผ์ ์
๋ ฅํด์ฃผ์ธ์."},
|
| 313 |
+
status_code=400
|
| 314 |
+
)
|
| 315 |
+
|
| 316 |
+
success, error = await magic_link_auth.send_magic_link(email)
|
| 317 |
+
|
| 318 |
+
if success:
|
| 319 |
+
return JSONResponse({"success": True})
|
| 320 |
+
else:
|
| 321 |
+
return JSONResponse({"success": False, "error": error}, status_code=400)
|
| 322 |
+
|
| 323 |
+
except Exception as e:
|
| 324 |
+
logger.error(f"Magic Link ๋ฐ์ก ์ค๋ฅ: {e}")
|
| 325 |
+
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
async def auth_callback_page(request: Request) -> HTMLResponse:
|
| 329 |
+
"""
|
| 330 |
+
GET /auth/callback
|
| 331 |
+
|
| 332 |
+
Magic Link ํด๋ฆญ ํ ๋๋ฉ ํ์ด์ง.
|
| 333 |
+
Supabase๊ฐ URL fragment์ ํ ํฐ์ ํฌํจ.
|
| 334 |
+
"""
|
| 335 |
+
html = """
|
| 336 |
+
<!DOCTYPE html>
|
| 337 |
+
<html>
|
| 338 |
+
<head>
|
| 339 |
+
<title>Eodi ์ธ์ฆ ์๋ฃ</title>
|
| 340 |
+
<meta charset="utf-8">
|
| 341 |
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
| 342 |
+
<style>
|
| 343 |
+
body {
|
| 344 |
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 345 |
+
max-width: 400px;
|
| 346 |
+
margin: 50px auto;
|
| 347 |
+
padding: 20px;
|
| 348 |
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
| 349 |
+
min-height: 100vh;
|
| 350 |
+
box-sizing: border-box;
|
| 351 |
+
}
|
| 352 |
+
.container {
|
| 353 |
+
background: white;
|
| 354 |
+
padding: 40px 30px;
|
| 355 |
+
border-radius: 16px;
|
| 356 |
+
box-shadow: 0 10px 40px rgba(0,0,0,0.2);
|
| 357 |
+
text-align: center;
|
| 358 |
+
}
|
| 359 |
+
h1 { color: #333; font-size: 28px; margin-bottom: 10px; }
|
| 360 |
+
h2 { color: #333; font-size: 22px; margin-bottom: 10px; }
|
| 361 |
+
.code {
|
| 362 |
+
font-size: 42px;
|
| 363 |
+
font-weight: bold;
|
| 364 |
+
letter-spacing: 10px;
|
| 365 |
+
color: #667eea;
|
| 366 |
+
margin: 24px 0;
|
| 367 |
+
padding: 20px;
|
| 368 |
+
background: linear-gradient(135deg, #f0f4ff 0%, #e8ecff 100%);
|
| 369 |
+
border-radius: 12px;
|
| 370 |
+
}
|
| 371 |
+
.instruction {
|
| 372 |
+
color: #4b5563;
|
| 373 |
+
margin: 16px 0;
|
| 374 |
+
line-height: 1.6;
|
| 375 |
+
}
|
| 376 |
+
.timer {
|
| 377 |
+
color: #9ca3af;
|
| 378 |
+
font-size: 14px;
|
| 379 |
+
margin-top: 16px;
|
| 380 |
+
}
|
| 381 |
+
.error { color: #ef4444; }
|
| 382 |
+
.loading { color: #6b7280; }
|
| 383 |
+
</style>
|
| 384 |
+
</head>
|
| 385 |
+
<body>
|
| 386 |
+
<div class="container">
|
| 387 |
+
<h1>๐จ Eodi</h1>
|
| 388 |
+
<div id="content">
|
| 389 |
+
<p class="loading">์ธ์ฆ ์ฒ๋ฆฌ ์ค...</p>
|
| 390 |
+
</div>
|
| 391 |
+
</div>
|
| 392 |
+
|
| 393 |
+
<script>
|
| 394 |
+
async function processAuth() {
|
| 395 |
+
const contentDiv = document.getElementById('content');
|
| 396 |
+
|
| 397 |
+
// URL fragment์์ ํ ํฐ ์ถ์ถ
|
| 398 |
+
const hash = window.location.hash.substring(1);
|
| 399 |
+
const params = new URLSearchParams(hash);
|
| 400 |
+
|
| 401 |
+
const accessToken = params.get('access_token');
|
| 402 |
+
const refreshToken = params.get('refresh_token') || '';
|
| 403 |
+
|
| 404 |
+
if (!accessToken) {
|
| 405 |
+
contentDiv.innerHTML = `
|
| 406 |
+
<h2 class="error">โ ์ธ์ฆ ์คํจ</h2>
|
| 407 |
+
<p class="instruction">๋งํฌ๊ฐ ๋ง๋ฃ๋์๊ฑฐ๋ ์๋ชป๋์์ต๋๋ค.<br>๋ค์ ์๋ํด์ฃผ์ธ์.</p>
|
| 408 |
+
`;
|
| 409 |
+
return;
|
| 410 |
+
}
|
| 411 |
+
|
| 412 |
+
try {
|
| 413 |
+
const response = await fetch('/auth/process-token', {
|
| 414 |
+
method: 'POST',
|
| 415 |
+
headers: {'Content-Type': 'application/json'},
|
| 416 |
+
body: JSON.stringify({
|
| 417 |
+
access_token: accessToken,
|
| 418 |
+
refresh_token: refreshToken
|
| 419 |
+
})
|
| 420 |
+
});
|
| 421 |
+
|
| 422 |
+
const data = await response.json();
|
| 423 |
+
|
| 424 |
+
if (data.success) {
|
| 425 |
+
contentDiv.innerHTML = `
|
| 426 |
+
<h2>โ
์ธ์ฆ ์๋ฃ!</h2>
|
| 427 |
+
<p class="instruction">์๋ ์ฝ๋๋ฅผ ์
๋ ฅํด์ฃผ์ธ์</p>
|
| 428 |
+
<div class="code">${data.code}</div>
|
| 429 |
+
<p class="instruction">GPT ๋๋ Claude๋ก ๋์๊ฐ์<br>์ด ์ฝ๋๋ฅผ ์
๋ ฅํ์ธ์.</p>
|
| 430 |
+
<p class="timer">โฑ๏ธ ์ฝ๋๋ 3๋ถ๊ฐ ์ ํจํฉ๋๋ค</p>
|
| 431 |
+
`;
|
| 432 |
+
} else {
|
| 433 |
+
contentDiv.innerHTML = `
|
| 434 |
+
<h2 class="error">โ ์ค๋ฅ ๋ฐ์</h2>
|
| 435 |
+
<p class="instruction">${data.error}</p>
|
| 436 |
+
`;
|
| 437 |
+
}
|
| 438 |
+
} catch (err) {
|
| 439 |
+
contentDiv.innerHTML = `
|
| 440 |
+
<h2 class="error">โ ์ค๋ฅ ๋ฐ์</h2>
|
| 441 |
+
<p class="instruction">์ ์ ํ ๋ค์ ์๋ํด์ฃผ์ธ์.</p>
|
| 442 |
+
`;
|
| 443 |
+
}
|
| 444 |
+
}
|
| 445 |
+
|
| 446 |
+
processAuth();
|
| 447 |
+
</script>
|
| 448 |
+
</body>
|
| 449 |
+
</html>
|
| 450 |
+
"""
|
| 451 |
+
return HTMLResponse(html)
|
| 452 |
+
|
| 453 |
+
|
| 454 |
+
async def auth_process_token(request: Request) -> JSONResponse:
|
| 455 |
+
"""
|
| 456 |
+
POST /auth/process-token
|
| 457 |
+
|
| 458 |
+
Supabase ํ ํฐ์ผ๋ก ์ธ์ฆ ์ฝ๋ ์์ฑ.
|
| 459 |
+
"""
|
| 460 |
+
magic_link_auth = get_magic_link_auth()
|
| 461 |
+
|
| 462 |
+
try:
|
| 463 |
+
body = await request.json()
|
| 464 |
+
access_token = body.get("access_token")
|
| 465 |
+
refresh_token = body.get("refresh_token", "")
|
| 466 |
+
|
| 467 |
+
if not access_token:
|
| 468 |
+
return JSONResponse(
|
| 469 |
+
{"success": False, "error": "ํ ํฐ์ด ์์ต๋๋ค."},
|
| 470 |
+
status_code=400
|
| 471 |
+
)
|
| 472 |
+
|
| 473 |
+
# ํ ํฐ์์ ์ฌ์ฉ์ ์ ๋ณด ์ถ์ถ
|
| 474 |
+
import httpx
|
| 475 |
+
|
| 476 |
+
async with httpx.AsyncClient(timeout=30) as client:
|
| 477 |
+
response = await client.get(
|
| 478 |
+
f"{SUPABASE_URL}/auth/v1/user",
|
| 479 |
+
headers={"Authorization": f"Bearer {access_token}"}
|
| 480 |
+
)
|
| 481 |
+
|
| 482 |
+
if response.status_code != 200:
|
| 483 |
+
return JSONResponse(
|
| 484 |
+
{"success": False, "error": "์ฌ์ฉ์ ์ ๋ณด๋ฅผ ๊ฐ์ ธ์ฌ ์ ์์ต๋๋ค."},
|
| 485 |
+
status_code=400
|
| 486 |
+
)
|
| 487 |
+
|
| 488 |
+
user_data = response.json()
|
| 489 |
+
user_id = user_data.get("id")
|
| 490 |
+
email = user_data.get("email")
|
| 491 |
+
|
| 492 |
+
# ์ธ์ฆ ์ฝ๋ ์์ฑ
|
| 493 |
+
code = magic_link_auth.generate_auth_code(
|
| 494 |
+
user_id=user_id,
|
| 495 |
+
email=email,
|
| 496 |
+
access_token=access_token,
|
| 497 |
+
refresh_token=refresh_token
|
| 498 |
+
)
|
| 499 |
+
|
| 500 |
+
return JSONResponse({"success": True, "code": code})
|
| 501 |
+
|
| 502 |
+
except Exception as e:
|
| 503 |
+
logger.error(f"ํ ํฐ ์ฒ๋ฆฌ ์ค๋ฅ: {e}")
|
| 504 |
+
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
|
| 505 |
+
|
| 506 |
+
|
| 507 |
+
async def auth_verify_code_api(request: Request) -> JSONResponse:
|
| 508 |
+
"""
|
| 509 |
+
POST /auth/verify-code
|
| 510 |
+
|
| 511 |
+
์ธ์ฆ ์ฝ๋ ๊ฒ์ฆ API (REST์ฉ).
|
| 512 |
+
"""
|
| 513 |
+
magic_link_auth = get_magic_link_auth()
|
| 514 |
+
|
| 515 |
+
try:
|
| 516 |
+
body = await request.json()
|
| 517 |
+
code = body.get("code")
|
| 518 |
+
|
| 519 |
+
if not code:
|
| 520 |
+
return JSONResponse(
|
| 521 |
+
{"success": False, "error": "์ฝ๋๋ฅผ ์
๋ ฅํด์ฃผ์ธ์."},
|
| 522 |
+
status_code=400
|
| 523 |
+
)
|
| 524 |
+
|
| 525 |
+
# ํด๋ผ์ด์ธํธ IP
|
| 526 |
+
client_ip = request.headers.get(
|
| 527 |
+
"X-Forwarded-For",
|
| 528 |
+
request.client.host if request.client else "unknown"
|
| 529 |
+
)
|
| 530 |
+
if "," in client_ip:
|
| 531 |
+
client_ip = client_ip.split(",")[0].strip()
|
| 532 |
+
|
| 533 |
+
success, session_token, email_masked, error = magic_link_auth.verify_auth_code(
|
| 534 |
+
code, client_ip
|
| 535 |
+
)
|
| 536 |
+
|
| 537 |
+
if success:
|
| 538 |
+
return JSONResponse({
|
| 539 |
+
"success": True,
|
| 540 |
+
"session_token": session_token,
|
| 541 |
+
"email_masked": email_masked,
|
| 542 |
+
"message": f"์ธ์ฆ ์๋ฃ! {email_masked}"
|
| 543 |
+
})
|
| 544 |
+
else:
|
| 545 |
+
return JSONResponse({"success": False, "error": error}, status_code=400)
|
| 546 |
+
|
| 547 |
+
except Exception as e:
|
| 548 |
+
logger.error(f"์ฝ๋ ๊ฒ์ฆ ์ค๋ฅ: {e}")
|
| 549 |
+
return JSONResponse({"success": False, "error": str(e)}, status_code=500)
|
| 550 |
+
|
| 551 |
+
|
| 552 |
+
# =============================================================================
|
| 553 |
+
# ๋ผ์ฐํธ ๋ชฉ๋ก
|
| 554 |
+
# =============================================================================
|
| 555 |
+
|
| 556 |
+
auth_routes = [
|
| 557 |
+
# OAuth2 (GPTs์ฉ)
|
| 558 |
+
Route("/oauth/authorize", oauth_authorize, methods=["GET"]),
|
| 559 |
+
Route("/oauth/callback", oauth_callback, methods=["GET"]),
|
| 560 |
+
Route("/oauth/token", oauth_token, methods=["POST"]),
|
| 561 |
+
|
| 562 |
+
# Magic Link (MCP ํด๋ผ์ด์ธํธ์ฉ)
|
| 563 |
+
Route("/auth/login", auth_login_page, methods=["GET"]),
|
| 564 |
+
Route("/auth/send-link", auth_send_link, methods=["POST"]),
|
| 565 |
+
Route("/auth/callback", auth_callback_page, methods=["GET"]),
|
| 566 |
+
Route("/auth/process-token", auth_process_token, methods=["POST"]),
|
| 567 |
+
Route("/auth/verify-code", auth_verify_code_api, methods=["POST"]),
|
| 568 |
+
]
|
src/auth/session_manager.py
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User Session Manager
|
| 3 |
+
====================
|
| 4 |
+
|
| 5 |
+
์ฌ์ฉ์ ์ธ์ฆ ์ธ์
๊ด๋ฆฌ.
|
| 6 |
+
|
| 7 |
+
์ฃผ์: ์ด ๋ชจ๋์ MCP ํ๋กํ ์ฝ ์ธ์
(server_streamable.py์ SessionManager)๊ณผ ๋ณ๊ฐ์
๋๋ค.
|
| 8 |
+
- MCP SessionManager: MCP ํ๋กํ ์ฝ ํต์ ์ฉ ์ธ์
ID ๊ด๋ฆฌ
|
| 9 |
+
- UserSessionManager: ์ฌ์ฉ์ ์ธ์ฆ ์ํ ๋ฐ JWT ํ ํฐ ๊ด๋ฆฌ
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import secrets
|
| 13 |
+
import time
|
| 14 |
+
import logging
|
| 15 |
+
from typing import Dict, Any, Optional
|
| 16 |
+
from dataclasses import dataclass, field
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger("eodi.auth.session")
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@dataclass
|
| 22 |
+
class UserSession:
|
| 23 |
+
"""์ฌ์ฉ์ ์ธ์ฆ ์ธ์
์ ๋ณด"""
|
| 24 |
+
user_id: str
|
| 25 |
+
email: str
|
| 26 |
+
access_token: str # Supabase access_token
|
| 27 |
+
refresh_token: str # Supabase refresh_token
|
| 28 |
+
expires_at: float # Unix timestamp
|
| 29 |
+
created_at: float = field(default_factory=time.time)
|
| 30 |
+
|
| 31 |
+
@property
|
| 32 |
+
def is_expired(self) -> bool:
|
| 33 |
+
"""ํ ํฐ ๋ง๋ฃ ์ฌ๋ถ (30์ด ๋ฒํผ)"""
|
| 34 |
+
return time.time() > (self.expires_at - 30)
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def email_masked(self) -> str:
|
| 38 |
+
"""์ด๋ฉ์ผ ๋ง์คํน (u***@example.com)"""
|
| 39 |
+
if not self.email or "@" not in self.email:
|
| 40 |
+
return self.email or "unknown"
|
| 41 |
+
|
| 42 |
+
local, domain = self.email.split("@", 1)
|
| 43 |
+
if len(local) <= 2:
|
| 44 |
+
masked_local = local[0] + "*"
|
| 45 |
+
else:
|
| 46 |
+
masked_local = local[0] + "*" * (len(local) - 2) + local[-1]
|
| 47 |
+
return f"{masked_local}@{domain}"
|
| 48 |
+
|
| 49 |
+
def to_dict(self) -> Dict[str, Any]:
|
| 50 |
+
"""API ์๋ต์ฉ ๋์
๋๋ฆฌ (๋ฏผ๊ฐ์ ๋ณด ์ ์ธ)"""
|
| 51 |
+
return {
|
| 52 |
+
"user_id": self.user_id,
|
| 53 |
+
"email_masked": self.email_masked,
|
| 54 |
+
"is_expired": self.is_expired,
|
| 55 |
+
"created_at": self.created_at,
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
class UserSessionManager:
|
| 60 |
+
"""
|
| 61 |
+
์ฌ์ฉ์ ์ธ์ฆ ์ธ์
๊ด๋ฆฌ์.
|
| 62 |
+
|
| 63 |
+
MCP Tool ํธ์ถ์์ ์ฌ์ฉ์๋ฅผ ์๋ณํ๊ธฐ ์ํ ์ธ์
ํ ํฐ์ ๊ด๋ฆฌํฉ๋๋ค.
|
| 64 |
+
|
| 65 |
+
ํ๋ฆ:
|
| 66 |
+
1. ์ฌ์ฉ์๊ฐ OAuth/Magic Link๋ก ์ธ์ฆ
|
| 67 |
+
2. Supabase ํ ํฐ์ ๋ฐ์ ์ธ์
์์ฑ
|
| 68 |
+
3. ์ธ์
ํ ํฐ์ LLM์๊ฒ ๋ฐํ
|
| 69 |
+
4. ์ดํ Tool ํธ์ถ ์ ์ธ์
ํ ํฐ์ ํ๋ผ๋ฏธํฐ๋ก ์ ๋ฌ
|
| 70 |
+
5. ์ธ์
ํ ํฐ์ผ๋ก user_id ์กฐํ โ DB ์ ๊ทผ
|
| 71 |
+
|
| 72 |
+
์ฃผ์: ๋ฉ๋ชจ๋ฆฌ ๊ธฐ๋ฐ ์ ์ฅ์ด๋ฏ๋ก ์๋ฒ ์ฌ์์ ์ ์ธ์
์์ค๋ฉ๋๋ค.
|
| 73 |
+
"""
|
| 74 |
+
|
| 75 |
+
SESSION_TTL_SECONDS = 3600 # 1์๊ฐ
|
| 76 |
+
MAX_SESSIONS = 1000 # ๋ฉ๋ชจ๋ฆฌ ๋ณดํธ
|
| 77 |
+
|
| 78 |
+
def __init__(self):
|
| 79 |
+
self._sessions: Dict[str, UserSession] = {}
|
| 80 |
+
self._user_to_session: Dict[str, str] = {} # user_id -> session_token (์ต์ )
|
| 81 |
+
self._last_cleanup = time.time()
|
| 82 |
+
|
| 83 |
+
def _cleanup_expired(self):
|
| 84 |
+
"""๋ง๋ฃ๋ ์ธ์
์ ๋ฆฌ"""
|
| 85 |
+
now = time.time()
|
| 86 |
+
if now - self._last_cleanup < 300: # 5๋ถ๋ง๋ค
|
| 87 |
+
return
|
| 88 |
+
|
| 89 |
+
self._last_cleanup = now
|
| 90 |
+
expired = [
|
| 91 |
+
token for token, session in self._sessions.items()
|
| 92 |
+
if session.is_expired
|
| 93 |
+
]
|
| 94 |
+
|
| 95 |
+
for token in expired:
|
| 96 |
+
session = self._sessions.pop(token, None)
|
| 97 |
+
if session:
|
| 98 |
+
# user_to_session ๋งคํ๋ ์ ๋ฆฌ
|
| 99 |
+
if self._user_to_session.get(session.user_id) == token:
|
| 100 |
+
del self._user_to_session[session.user_id]
|
| 101 |
+
|
| 102 |
+
if expired:
|
| 103 |
+
logger.debug(f"๋ง๋ฃ๋ ์ธ์
{len(expired)}๊ฐ ์ ๋ฆฌ๋จ")
|
| 104 |
+
|
| 105 |
+
def create_session(
|
| 106 |
+
self,
|
| 107 |
+
user_id: str,
|
| 108 |
+
email: str,
|
| 109 |
+
access_token: str,
|
| 110 |
+
refresh_token: str,
|
| 111 |
+
expires_in: int = 3600
|
| 112 |
+
) -> str:
|
| 113 |
+
"""
|
| 114 |
+
์ ์ฌ์ฉ์ ์ธ์
์์ฑ.
|
| 115 |
+
|
| 116 |
+
Args:
|
| 117 |
+
user_id: Supabase Auth UUID
|
| 118 |
+
email: ์ฌ์ฉ์ ์ด๋ฉ์ผ
|
| 119 |
+
access_token: Supabase access_token
|
| 120 |
+
refresh_token: Supabase refresh_token
|
| 121 |
+
expires_in: ํ ํฐ ๋ง๋ฃ๊น์ง ์ด
|
| 122 |
+
|
| 123 |
+
Returns:
|
| 124 |
+
session_token (LLM์ด ํ์ ํธ์ถ์์ ์ฌ์ฉ)
|
| 125 |
+
"""
|
| 126 |
+
self._cleanup_expired()
|
| 127 |
+
|
| 128 |
+
# ๋ฉ๋ชจ๋ฆฌ ๋ณดํธ
|
| 129 |
+
if len(self._sessions) >= self.MAX_SESSIONS:
|
| 130 |
+
# ๊ฐ์ฅ ์ค๋๋ ์ธ์
์ญ์
|
| 131 |
+
oldest_token = min(
|
| 132 |
+
self._sessions.keys(),
|
| 133 |
+
key=lambda t: self._sessions[t].created_at
|
| 134 |
+
)
|
| 135 |
+
old_session = self._sessions.pop(oldest_token)
|
| 136 |
+
if self._user_to_session.get(old_session.user_id) == oldest_token:
|
| 137 |
+
del self._user_to_session[old_session.user_id]
|
| 138 |
+
logger.warning("์ธ์
ํ๋ ์ด๊ณผ, ๊ฐ์ฅ ์ค๋๋ ์ธ์
์ญ์ ")
|
| 139 |
+
|
| 140 |
+
# ๊ธฐ์กด ์ธ์
์ด ์์ผ๋ฉด ๊ฐฑ์
|
| 141 |
+
existing_token = self._user_to_session.get(user_id)
|
| 142 |
+
if existing_token and existing_token in self._sessions:
|
| 143 |
+
session = self._sessions[existing_token]
|
| 144 |
+
session.access_token = access_token
|
| 145 |
+
session.refresh_token = refresh_token
|
| 146 |
+
session.expires_at = time.time() + expires_in
|
| 147 |
+
logger.info(f"์ธ์
๊ฐฑ์ : {session.email_masked}")
|
| 148 |
+
return existing_token
|
| 149 |
+
|
| 150 |
+
# ์ ์ธ์
์์ฑ
|
| 151 |
+
session_token = secrets.token_urlsafe(32)
|
| 152 |
+
session = UserSession(
|
| 153 |
+
user_id=user_id,
|
| 154 |
+
email=email,
|
| 155 |
+
access_token=access_token,
|
| 156 |
+
refresh_token=refresh_token,
|
| 157 |
+
expires_at=time.time() + expires_in
|
| 158 |
+
)
|
| 159 |
+
|
| 160 |
+
self._sessions[session_token] = session
|
| 161 |
+
self._user_to_session[user_id] = session_token
|
| 162 |
+
|
| 163 |
+
logger.info(f"์ ์ธ์
์์ฑ: {session.email_masked}")
|
| 164 |
+
return session_token
|
| 165 |
+
|
| 166 |
+
def get_session(self, session_token: str) -> Optional[UserSession]:
|
| 167 |
+
"""
|
| 168 |
+
์ธ์
ํ ํฐ์ผ๋ก ์ธ์
์กฐํ.
|
| 169 |
+
|
| 170 |
+
Args:
|
| 171 |
+
session_token: ์ธ์
ํ ํฐ
|
| 172 |
+
|
| 173 |
+
Returns:
|
| 174 |
+
UserSession ๋๋ None (๋ง๋ฃ/๋ฏธ์กด์ฌ)
|
| 175 |
+
"""
|
| 176 |
+
self._cleanup_expired()
|
| 177 |
+
|
| 178 |
+
session = self._sessions.get(session_token)
|
| 179 |
+
if session and not session.is_expired:
|
| 180 |
+
return session
|
| 181 |
+
return None
|
| 182 |
+
|
| 183 |
+
def get_user_id(self, session_token: str) -> Optional[str]:
|
| 184 |
+
"""
|
| 185 |
+
์ธ์
ํ ํฐ์์ user_id ์ถ์ถ.
|
| 186 |
+
|
| 187 |
+
Args:
|
| 188 |
+
session_token: ์ธ์
ํ ํฐ
|
| 189 |
+
|
| 190 |
+
Returns:
|
| 191 |
+
user_id ๋๋ None
|
| 192 |
+
"""
|
| 193 |
+
session = self.get_session(session_token)
|
| 194 |
+
return session.user_id if session else None
|
| 195 |
+
|
| 196 |
+
def invalidate_session(self, session_token: str) -> bool:
|
| 197 |
+
"""
|
| 198 |
+
์ธ์
๋ฌดํจํ (๋ก๊ทธ์์).
|
| 199 |
+
|
| 200 |
+
Args:
|
| 201 |
+
session_token: ์ธ์
ํ ํฐ
|
| 202 |
+
|
| 203 |
+
Returns:
|
| 204 |
+
์ฑ๊ณต ์ฌ๋ถ
|
| 205 |
+
"""
|
| 206 |
+
session = self._sessions.pop(session_token, None)
|
| 207 |
+
if session:
|
| 208 |
+
if self._user_to_session.get(session.user_id) == session_token:
|
| 209 |
+
del self._user_to_session[session.user_id]
|
| 210 |
+
logger.info(f"์ธ์
๋ฌดํจํ: {session.email_masked}")
|
| 211 |
+
return True
|
| 212 |
+
return False
|
| 213 |
+
|
| 214 |
+
def get_stats(self) -> Dict[str, Any]:
|
| 215 |
+
"""์ธ์
ํต๊ณ"""
|
| 216 |
+
self._cleanup_expired()
|
| 217 |
+
return {
|
| 218 |
+
"total_sessions": len(self._sessions),
|
| 219 |
+
"unique_users": len(self._user_to_session),
|
| 220 |
+
}
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
# ์ ์ญ ์ธ์คํด์ค (Lazy loading)
|
| 224 |
+
_user_session_manager = None
|
| 225 |
+
|
| 226 |
+
|
| 227 |
+
def get_user_session_manager() -> UserSessionManager:
|
| 228 |
+
"""UserSessionManager ์ฑ๊ธํค ์ธ์คํด์ค ๋ฐํ"""
|
| 229 |
+
global _user_session_manager
|
| 230 |
+
if _user_session_manager is None:
|
| 231 |
+
_user_session_manager = UserSessionManager()
|
| 232 |
+
return _user_session_manager
|
src/auth/token_validator.py
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
JWT Token Validator
|
| 3 |
+
==================
|
| 4 |
+
|
| 5 |
+
Supabase Auth๊ฐ ๋ฐ๊ธํ JWT ํ ํฐ ๊ฒ์ฆ.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import logging
|
| 9 |
+
from typing import Dict, Any, Optional, Tuple
|
| 10 |
+
from datetime import datetime, timezone
|
| 11 |
+
|
| 12 |
+
try:
|
| 13 |
+
import jwt
|
| 14 |
+
except ImportError:
|
| 15 |
+
jwt = None # PyJWT ๋ฏธ์ค์น ์ graceful ์ฒ๋ฆฌ
|
| 16 |
+
|
| 17 |
+
from .config import SUPABASE_JWT_SECRET
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger("eodi.auth.token_validator")
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class TokenValidator:
|
| 23 |
+
"""
|
| 24 |
+
JWT ํ ํฐ ๊ฒ์ฆ๊ธฐ.
|
| 25 |
+
|
| 26 |
+
Supabase Auth๊ฐ ๋ฐ๊ธํ access_token์ ์๋ช
๋ฐ ๋ง๋ฃ ๊ฒ์ฆ.
|
| 27 |
+
์๋ฒ ์ธก์์ ํ ํฐ ์ ํจ์ฑ์ ํ์ธํ ๋ ์ฌ์ฉ.
|
| 28 |
+
"""
|
| 29 |
+
|
| 30 |
+
def __init__(self, jwt_secret: str = None):
|
| 31 |
+
"""
|
| 32 |
+
Args:
|
| 33 |
+
jwt_secret: Supabase JWT Secret (์์ผ๋ฉด ํ๊ฒฝ๋ณ์์์ ๋ก๋)
|
| 34 |
+
"""
|
| 35 |
+
self.jwt_secret = jwt_secret or SUPABASE_JWT_SECRET
|
| 36 |
+
self._enabled = bool(self.jwt_secret) and jwt is not None
|
| 37 |
+
|
| 38 |
+
if jwt is None:
|
| 39 |
+
logger.warning("PyJWT๊ฐ ์ค์น๋์ง ์์์ต๋๋ค. pip install PyJWT")
|
| 40 |
+
elif not self._enabled:
|
| 41 |
+
logger.warning("JWT Secret ๋ฏธ์ค์ - ํ ํฐ ๊ฒ์ฆ์ด ๋นํ์ฑํ๋ฉ๋๋ค")
|
| 42 |
+
|
| 43 |
+
@property
|
| 44 |
+
def is_enabled(self) -> bool:
|
| 45 |
+
"""ํ ํฐ ๊ฒ์ฆ ํ์ฑํ ์ฌ๋ถ"""
|
| 46 |
+
return self._enabled
|
| 47 |
+
|
| 48 |
+
def validate(self, token: str) -> Tuple[bool, Optional[Dict[str, Any]], Optional[str]]:
|
| 49 |
+
"""
|
| 50 |
+
JWT ํ ํฐ ๊ฒ์ฆ.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
token: JWT access_token
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
(์ ํจ์ฌ๋ถ, payload, error_message)
|
| 57 |
+
"""
|
| 58 |
+
if not self._enabled:
|
| 59 |
+
# JWT Secret ๋ฏธ์ค์ ์ ๊ฒ์ฆ ์คํต (๊ฐ๋ฐ ํ๊ฒฝ์ฉ)
|
| 60 |
+
logger.warning("JWT ๊ฒ์ฆ ์คํต (Secret ๋ฏธ์ค์ ๋๋ PyJWT ๋ฏธ์ค์น)")
|
| 61 |
+
return True, {"sub": "unknown"}, None
|
| 62 |
+
|
| 63 |
+
try:
|
| 64 |
+
payload = jwt.decode(
|
| 65 |
+
token,
|
| 66 |
+
self.jwt_secret,
|
| 67 |
+
algorithms=["HS256"],
|
| 68 |
+
options={"verify_aud": False} # Supabase๋ aud ํด๋ ์์ด ๋ค์ํจ
|
| 69 |
+
)
|
| 70 |
+
return True, payload, None
|
| 71 |
+
|
| 72 |
+
except jwt.ExpiredSignatureError:
|
| 73 |
+
return False, None, "ํ ํฐ์ด ๋ง๋ฃ๋์์ต๋๋ค"
|
| 74 |
+
except jwt.InvalidTokenError as e:
|
| 75 |
+
logger.warning(f"JWT ๊ฒ์ฆ ์คํจ: {e}")
|
| 76 |
+
return False, None, f"์ ํจํ์ง ์์ ํ ํฐ์
๋๋ค: {e}"
|
| 77 |
+
|
| 78 |
+
def extract_user_id(self, token: str) -> Optional[str]:
|
| 79 |
+
"""
|
| 80 |
+
ํ ํฐ์์ user_id(sub ํด๋ ์) ์ถ์ถ.
|
| 81 |
+
|
| 82 |
+
Args:
|
| 83 |
+
token: JWT access_token
|
| 84 |
+
|
| 85 |
+
Returns:
|
| 86 |
+
user_id ๋๋ None
|
| 87 |
+
"""
|
| 88 |
+
is_valid, payload, _ = self.validate(token)
|
| 89 |
+
if is_valid and payload:
|
| 90 |
+
return payload.get("sub")
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
def is_expired(self, token: str) -> bool:
|
| 94 |
+
"""
|
| 95 |
+
ํ ํฐ ๋ง๋ฃ ์ฌ๋ถ ํ์ธ.
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
token: JWT access_token
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
๋ง๋ฃ ์ฌ๋ถ
|
| 102 |
+
"""
|
| 103 |
+
is_valid, _, error = self.validate(token)
|
| 104 |
+
return not is_valid and error and "๋ง๋ฃ" in error
|
| 105 |
+
|
| 106 |
+
def get_expiry_time(self, token: str) -> Optional[datetime]:
|
| 107 |
+
"""
|
| 108 |
+
ํ ํฐ ๋ง๋ฃ ์๊ฐ ์ถ์ถ.
|
| 109 |
+
|
| 110 |
+
Args:
|
| 111 |
+
token: JWT access_token
|
| 112 |
+
|
| 113 |
+
Returns:
|
| 114 |
+
๋ง๋ฃ ์๊ฐ ๋๋ None
|
| 115 |
+
"""
|
| 116 |
+
if jwt is None:
|
| 117 |
+
return None
|
| 118 |
+
|
| 119 |
+
try:
|
| 120 |
+
# ๊ฒ์ฆ ์์ด ํ์ด๋ก๋๋ง ๋์ฝ๋ฉ
|
| 121 |
+
payload = jwt.decode(
|
| 122 |
+
token,
|
| 123 |
+
options={"verify_signature": False}
|
| 124 |
+
)
|
| 125 |
+
exp = payload.get("exp")
|
| 126 |
+
if exp:
|
| 127 |
+
return datetime.fromtimestamp(exp, tz=timezone.utc)
|
| 128 |
+
return None
|
| 129 |
+
except Exception:
|
| 130 |
+
return None
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
# ์ ์ญ ์ธ์คํด์ค (Lazy loading)
|
| 134 |
+
_token_validator = None
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def get_token_validator() -> TokenValidator:
|
| 138 |
+
"""TokenValidator ์ฑ๊ธํค ์ธ์คํด์ค ๋ฐํ"""
|
| 139 |
+
global _token_validator
|
| 140 |
+
if _token_validator is None:
|
| 141 |
+
_token_validator = TokenValidator()
|
| 142 |
+
return _token_validator
|
src/auth/tool_handlers.py
ADDED
|
@@ -0,0 +1,343 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
User Tool Handlers
|
| 3 |
+
==================
|
| 4 |
+
|
| 5 |
+
user_* MCP Tool์ ์ค์ ์คํ ๋ก์ง.
|
| 6 |
+
server_streamable.py์ execute_tool_async์์ ํธ์ถ๋ฉ๋๋ค.
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
import logging
|
| 10 |
+
from typing import Dict, Any
|
| 11 |
+
|
| 12 |
+
from .session_manager import get_user_session_manager
|
| 13 |
+
from .magic_link import get_magic_link_auth
|
| 14 |
+
from .config import SUPPORTED_CHAINS, TIER_ALIASES, SERVER_BASE_URL
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger("eodi.auth.tool_handlers")
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
async def execute_user_tool(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 20 |
+
"""
|
| 21 |
+
User Tool ์คํ ๋ผ์ฐํฐ.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
name: Tool ์ด๋ฆ (user_*)
|
| 25 |
+
arguments: Tool ์ธ์
|
| 26 |
+
|
| 27 |
+
Returns:
|
| 28 |
+
Tool ์คํ ๊ฒฐ๊ณผ
|
| 29 |
+
"""
|
| 30 |
+
if name == "user_get_profile":
|
| 31 |
+
return await handle_get_profile(arguments)
|
| 32 |
+
elif name == "user_update_membership":
|
| 33 |
+
return await handle_update_membership(arguments)
|
| 34 |
+
elif name == "user_delete_membership":
|
| 35 |
+
return await handle_delete_membership(arguments)
|
| 36 |
+
elif name == "user_request_auth":
|
| 37 |
+
return await handle_request_auth(arguments)
|
| 38 |
+
elif name == "user_verify_code":
|
| 39 |
+
return await handle_verify_code(arguments)
|
| 40 |
+
else:
|
| 41 |
+
return {"success": False, "error": f"Unknown user tool: {name}"}
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
async def handle_get_profile(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 45 |
+
"""
|
| 46 |
+
user_get_profile Tool ํธ๋ค๋ฌ.
|
| 47 |
+
|
| 48 |
+
์ธ์
ํ ํฐ์ด ์์ผ๋ฉด ํ๋กํ ๋ฐํ, ์์ผ๋ฉด ์ธ์ฆ URL ๋ฐํ.
|
| 49 |
+
"""
|
| 50 |
+
session_token = arguments.get("session_token")
|
| 51 |
+
user_session_manager = get_user_session_manager()
|
| 52 |
+
|
| 53 |
+
if not session_token:
|
| 54 |
+
# ์ธ์ฆ๋์ง ์์ โ ์ธ์ฆ URL ์๋ด
|
| 55 |
+
return {
|
| 56 |
+
"success": True,
|
| 57 |
+
"connected": False,
|
| 58 |
+
"auth_url": f"{SERVER_BASE_URL}/auth/login",
|
| 59 |
+
"message": "Eodi์ ์ฐ๊ฒฐ๋์ง ์์์ต๋๋ค. auth_url์์ ๋ก๊ทธ์ธํ๊ฑฐ๋, user_request_auth๋ก ์ด๋ฉ์ผ ์ธ์ฆ์ ์์ํ์ธ์."
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
# ์ธ์
๊ฒ์ฆ
|
| 63 |
+
session = user_session_manager.get_session(session_token)
|
| 64 |
+
if not session:
|
| 65 |
+
return {
|
| 66 |
+
"success": True,
|
| 67 |
+
"connected": False,
|
| 68 |
+
"auth_url": f"{SERVER_BASE_URL}/auth/login",
|
| 69 |
+
"message": "์ธ์
์ด ๋ง๋ฃ๋์์ต๋๋ค. ๋ค์ ๋ก๊ทธ์ธํด์ฃผ์ธ์."
|
| 70 |
+
}
|
| 71 |
+
|
| 72 |
+
# ๋ฉค๋ฒ์ญ ์กฐํ
|
| 73 |
+
try:
|
| 74 |
+
from src.db.supabase_adapter import SupabaseAdapter
|
| 75 |
+
adapter = SupabaseAdapter()
|
| 76 |
+
|
| 77 |
+
memberships = adapter.get_user_memberships(session.user_id)
|
| 78 |
+
profile = adapter.get_user_profile(session.user_id)
|
| 79 |
+
except Exception as e:
|
| 80 |
+
logger.error(f"ํ๋กํ ์กฐํ ์ค๋ฅ: {e}")
|
| 81 |
+
memberships = []
|
| 82 |
+
profile = None
|
| 83 |
+
|
| 84 |
+
return {
|
| 85 |
+
"success": True,
|
| 86 |
+
"connected": True,
|
| 87 |
+
"user_id": session.user_id,
|
| 88 |
+
"profile": {
|
| 89 |
+
"email_masked": session.email_masked,
|
| 90 |
+
"preferred_airports": profile.get("preferred_airports", []) if profile else [],
|
| 91 |
+
"created_at": profile.get("created_at") if profile else None,
|
| 92 |
+
},
|
| 93 |
+
"memberships": [
|
| 94 |
+
{
|
| 95 |
+
"chain": m["chain"],
|
| 96 |
+
"tier": m["tier"],
|
| 97 |
+
"expires_at": m.get("expires_at"),
|
| 98 |
+
}
|
| 99 |
+
for m in memberships
|
| 100 |
+
],
|
| 101 |
+
"memberships_count": len(memberships)
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
async def handle_update_membership(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 106 |
+
"""
|
| 107 |
+
user_update_membership Tool ํธ๋ค๋ฌ.
|
| 108 |
+
"""
|
| 109 |
+
session_token = arguments.get("session_token")
|
| 110 |
+
chain = arguments.get("chain", "").upper()
|
| 111 |
+
tier = arguments.get("tier", "")
|
| 112 |
+
user_session_manager = get_user_session_manager()
|
| 113 |
+
|
| 114 |
+
# ์ธ์ฆ ํ์ธ
|
| 115 |
+
if not session_token:
|
| 116 |
+
return {
|
| 117 |
+
"success": False,
|
| 118 |
+
"error": "session_token์ด ํ์ํฉ๋๋ค. ๋จผ์ user_get_profile์ ํธ์ถํ์ฌ ์ธ์ฆ ์ํ๋ฅผ ํ์ธํ์ธ์."
|
| 119 |
+
}
|
| 120 |
+
|
| 121 |
+
session = user_session_manager.get_session(session_token)
|
| 122 |
+
if not session:
|
| 123 |
+
return {
|
| 124 |
+
"success": False,
|
| 125 |
+
"error": "์ธ์
์ด ๋ง๋ฃ๋์์ต๋๋ค. ๋ค์ ๋ก๊ทธ์ธํด์ฃผ์ธ์.",
|
| 126 |
+
"auth_url": f"{SERVER_BASE_URL}/auth/login"
|
| 127 |
+
}
|
| 128 |
+
|
| 129 |
+
# ์ฒด์ธ ์ ํจ์ฑ
|
| 130 |
+
if chain not in SUPPORTED_CHAINS:
|
| 131 |
+
return {
|
| 132 |
+
"success": False,
|
| 133 |
+
"error": f"์ง์ํ์ง ์๋ ์ฒด์ธ์
๋๋ค: {chain}",
|
| 134 |
+
"supported_chains": list(SUPPORTED_CHAINS.keys())
|
| 135 |
+
}
|
| 136 |
+
|
| 137 |
+
# ๋ฑ๊ธ ์ ๊ทํ
|
| 138 |
+
tier_normalized = TIER_ALIASES.get(tier, tier)
|
| 139 |
+
|
| 140 |
+
# ๋ฑ๊ธ ์ ํจ์ฑ
|
| 141 |
+
valid_tiers = SUPPORTED_CHAINS[chain]
|
| 142 |
+
tier_lower_map = {t.lower(): t for t in valid_tiers}
|
| 143 |
+
|
| 144 |
+
if tier_normalized.lower() not in tier_lower_map:
|
| 145 |
+
return {
|
| 146 |
+
"success": False,
|
| 147 |
+
"error": f"{chain}์ ์ ํจํ์ง ์์ ๋ฑ๊ธ์
๋๋ค: {tier}",
|
| 148 |
+
"valid_tiers": valid_tiers
|
| 149 |
+
}
|
| 150 |
+
|
| 151 |
+
tier_final = tier_lower_map[tier_normalized.lower()]
|
| 152 |
+
|
| 153 |
+
# ๋ฉค๋ฒ์ญ ์ ์ฅ
|
| 154 |
+
try:
|
| 155 |
+
from src.db.supabase_adapter import SupabaseAdapter
|
| 156 |
+
adapter = SupabaseAdapter()
|
| 157 |
+
|
| 158 |
+
# ๊ธฐ์กด ๋ฉค๋ฒ์ญ ํ์ธ
|
| 159 |
+
existing = adapter.get_user_memberships(session.user_id)
|
| 160 |
+
is_update = any(m["chain"] == chain for m in existing)
|
| 161 |
+
|
| 162 |
+
result = adapter.upsert_membership(session.user_id, chain, tier_final)
|
| 163 |
+
|
| 164 |
+
if result:
|
| 165 |
+
action = "updated" if is_update else "created"
|
| 166 |
+
chain_names = {
|
| 167 |
+
"HILTON": "ํํผ",
|
| 168 |
+
"MARRIOTT": "๋ฉ๋ฆฌ์ดํธ",
|
| 169 |
+
"IHG": "IHG",
|
| 170 |
+
"ACCOR": "์์ฝ๋ฅด",
|
| 171 |
+
"HYATT": "ํ์ํธ",
|
| 172 |
+
}
|
| 173 |
+
chain_name = chain_names.get(chain, chain)
|
| 174 |
+
|
| 175 |
+
return {
|
| 176 |
+
"success": True,
|
| 177 |
+
"action": action,
|
| 178 |
+
"membership": {
|
| 179 |
+
"chain": chain,
|
| 180 |
+
"tier": tier_final,
|
| 181 |
+
},
|
| 182 |
+
"message": f"{chain_name} {tier_final} ๋ฑ๊ธ์ด {'์์ ' if is_update else '๋ฑ๋ก'}๋์์ต๋๋ค."
|
| 183 |
+
}
|
| 184 |
+
else:
|
| 185 |
+
return {
|
| 186 |
+
"success": False,
|
| 187 |
+
"error": "๋ฉค๋ฒ์ญ ์ ์ฅ์ ์คํจํ์ต๋๋ค. ์ ์ ํ ๋ค์ ์๋ํด์ฃผ์ธ์."
|
| 188 |
+
}
|
| 189 |
+
except Exception as e:
|
| 190 |
+
logger.error(f"๋ฉค๋ฒ์ญ ์ ์ฅ ์ค๋ฅ: {e}")
|
| 191 |
+
return {
|
| 192 |
+
"success": False,
|
| 193 |
+
"error": f"๋ฉค๋ฒ์ญ ์ ์ฅ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: {e}"
|
| 194 |
+
}
|
| 195 |
+
|
| 196 |
+
|
| 197 |
+
async def handle_delete_membership(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 198 |
+
"""
|
| 199 |
+
user_delete_membership Tool ํธ๋ค๋ฌ.
|
| 200 |
+
"""
|
| 201 |
+
session_token = arguments.get("session_token")
|
| 202 |
+
chain = arguments.get("chain", "").upper()
|
| 203 |
+
user_session_manager = get_user_session_manager()
|
| 204 |
+
|
| 205 |
+
# ์ธ์ฆ ํ์ธ
|
| 206 |
+
if not session_token:
|
| 207 |
+
return {
|
| 208 |
+
"success": False,
|
| 209 |
+
"error": "session_token์ด ํ์ํฉ๋๋ค."
|
| 210 |
+
}
|
| 211 |
+
|
| 212 |
+
session = user_session_manager.get_session(session_token)
|
| 213 |
+
if not session:
|
| 214 |
+
return {
|
| 215 |
+
"success": False,
|
| 216 |
+
"error": "์ธ์
์ด ๋ง๋ฃ๋์์ต๋๋ค. ๋ค์ ๋ก๊ทธ์ธํด์ฃผ์ธ์."
|
| 217 |
+
}
|
| 218 |
+
|
| 219 |
+
# ์ฒด์ธ ์ ํจ์ฑ
|
| 220 |
+
if chain not in SUPPORTED_CHAINS:
|
| 221 |
+
return {
|
| 222 |
+
"success": False,
|
| 223 |
+
"error": f"์ง์ํ์ง ์๋ ์ฒด์ธ์
๋๋ค: {chain}"
|
| 224 |
+
}
|
| 225 |
+
|
| 226 |
+
# ๋ฉค๋ฒ์ญ ์ญ์
|
| 227 |
+
try:
|
| 228 |
+
from src.db.supabase_adapter import SupabaseAdapter
|
| 229 |
+
adapter = SupabaseAdapter()
|
| 230 |
+
|
| 231 |
+
if adapter.delete_membership(session.user_id, chain):
|
| 232 |
+
chain_names = {
|
| 233 |
+
"HILTON": "ํํผ",
|
| 234 |
+
"MARRIOTT": "๋ฉ๋ฆฌ์ดํธ",
|
| 235 |
+
"IHG": "IHG",
|
| 236 |
+
"ACCOR": "์์ฝ๋ฅด",
|
| 237 |
+
"HYATT": "ํ์ํธ",
|
| 238 |
+
}
|
| 239 |
+
chain_name = chain_names.get(chain, chain)
|
| 240 |
+
|
| 241 |
+
return {
|
| 242 |
+
"success": True,
|
| 243 |
+
"message": f"{chain_name} ๋ฉค๋ฒ์ญ์ด ์ญ์ ๋์์ต๋๋ค."
|
| 244 |
+
}
|
| 245 |
+
else:
|
| 246 |
+
return {
|
| 247 |
+
"success": False,
|
| 248 |
+
"error": "๋ฉค๋ฒ์ญ ์ญ์ ์ ์คํจํ์ต๋๋ค."
|
| 249 |
+
}
|
| 250 |
+
except Exception as e:
|
| 251 |
+
logger.error(f"๋ฉค๋ฒ์ญ ์ญ์ ์ค๋ฅ: {e}")
|
| 252 |
+
return {
|
| 253 |
+
"success": False,
|
| 254 |
+
"error": f"๋ฉค๋ฒ์ญ ์ญ์ ์ค ์ค๋ฅ๊ฐ ๋ฐ์ํ์ต๋๋ค: {e}"
|
| 255 |
+
}
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
async def handle_request_auth(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 259 |
+
"""
|
| 260 |
+
user_request_auth Tool ํธ๋ค๋ฌ.
|
| 261 |
+
|
| 262 |
+
์ด๋ฉ์ผ๋ก Magic Link ๋ฐ์ก.
|
| 263 |
+
"""
|
| 264 |
+
email = arguments.get("email", "").strip().lower()
|
| 265 |
+
magic_link_auth = get_magic_link_auth()
|
| 266 |
+
|
| 267 |
+
if not email:
|
| 268 |
+
return {
|
| 269 |
+
"success": False,
|
| 270 |
+
"error": "์ด๋ฉ์ผ ์ฃผ์๋ฅผ ์
๋ ฅํด์ฃผ์ธ์."
|
| 271 |
+
}
|
| 272 |
+
|
| 273 |
+
# ๊ฐ๋จํ ์ด๋ฉ์ผ ํ์ ๊ฒ์ฆ
|
| 274 |
+
if "@" not in email or "." not in email:
|
| 275 |
+
return {
|
| 276 |
+
"success": False,
|
| 277 |
+
"error": "์ฌ๋ฐ๋ฅธ ์ด๋ฉ์ผ ํ์์ด ์๋๋๋ค."
|
| 278 |
+
}
|
| 279 |
+
|
| 280 |
+
success, error = await magic_link_auth.send_magic_link(email)
|
| 281 |
+
|
| 282 |
+
if success:
|
| 283 |
+
# ์ด๋ฉ์ผ ๋ง์คํน
|
| 284 |
+
local, domain = email.split("@", 1)
|
| 285 |
+
if len(local) <= 2:
|
| 286 |
+
masked = local[0] + "*"
|
| 287 |
+
else:
|
| 288 |
+
masked = local[0] + "*" * (len(local) - 2) + local[-1]
|
| 289 |
+
email_masked = f"{masked}@{domain}"
|
| 290 |
+
|
| 291 |
+
return {
|
| 292 |
+
"success": True,
|
| 293 |
+
"message": f"{email_masked}์ผ๋ก ๋ก๊ทธ์ธ ๋งํฌ๋ฅผ ๋ณด๋์ต๋๋ค.\n\n๐ง ์ด๋ฉ์ผ์์ ๋งํฌ๋ฅผ ํด๋ฆญํ ํ,\nํ๋ฉด์ ํ์๋๋ 6์๋ฆฌ ์ฝ๋๋ฅผ user_verify_code๋ก ์
๋ ฅํด์ฃผ์ธ์.",
|
| 294 |
+
"next_step": "user_verify_code"
|
| 295 |
+
}
|
| 296 |
+
else:
|
| 297 |
+
return {
|
| 298 |
+
"success": False,
|
| 299 |
+
"error": error or "์ด๋ฉ์ผ ๋ฐ์ก์ ์คํจํ์ต๋๋ค."
|
| 300 |
+
}
|
| 301 |
+
|
| 302 |
+
|
| 303 |
+
async def handle_verify_code(arguments: Dict[str, Any]) -> Dict[str, Any]:
|
| 304 |
+
"""
|
| 305 |
+
user_verify_code Tool ํธ๋ค๋ฌ.
|
| 306 |
+
|
| 307 |
+
6์๋ฆฌ ์ธ์ฆ ์ฝ๋ ๊ฒ์ฆ.
|
| 308 |
+
"""
|
| 309 |
+
code = arguments.get("code", "").strip()
|
| 310 |
+
magic_link_auth = get_magic_link_auth()
|
| 311 |
+
|
| 312 |
+
if not code:
|
| 313 |
+
return {
|
| 314 |
+
"success": False,
|
| 315 |
+
"error": "์ธ์ฆ ์ฝ๋๋ฅผ ์
๋ ฅํด์ฃผ์ธ์."
|
| 316 |
+
}
|
| 317 |
+
|
| 318 |
+
# ์ฝ๋ ๊ธธ์ด ๊ฒ์ฆ
|
| 319 |
+
code_clean = code.replace(" ", "").replace("-", "")
|
| 320 |
+
if len(code_clean) != 6 or not code_clean.isdigit():
|
| 321 |
+
return {
|
| 322 |
+
"success": False,
|
| 323 |
+
"error": "6์๋ฆฌ ์ซ์ ์ฝ๋๋ฅผ ์
๋ ฅํด์ฃผ์ธ์."
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
# client_ip๋ MCP Tool์์ ์ง์ ์ ๊ทผ ๋ถ๊ฐ โ ๊ธฐ๋ณธ๊ฐ ์ฌ์ฉ
|
| 327 |
+
success, session_token, email_masked, error = magic_link_auth.verify_auth_code(
|
| 328 |
+
code_clean, "mcp_client"
|
| 329 |
+
)
|
| 330 |
+
|
| 331 |
+
if success:
|
| 332 |
+
return {
|
| 333 |
+
"success": True,
|
| 334 |
+
"connected": True,
|
| 335 |
+
"session_token": session_token,
|
| 336 |
+
"email_masked": email_masked,
|
| 337 |
+
"message": f"โ
์ธ์ฆ ์๋ฃ! {email_masked}\n\n์ด์ ๋ฉค๋ฒ์ญ์ ๋ฑ๋กํด๋ณผ๊น์?\n์ด๋ค ํธํ
์ฒด์ธ ๋ฉค๋ฒ์ญ์ ๊ฐ์ง๊ณ ๊ณ์ธ์?"
|
| 338 |
+
}
|
| 339 |
+
else:
|
| 340 |
+
return {
|
| 341 |
+
"success": False,
|
| 342 |
+
"error": error
|
| 343 |
+
}
|
src/db/supabase_adapter.py
CHANGED
|
@@ -303,6 +303,158 @@ class SupabaseAdapter:
|
|
| 303 |
}
|
| 304 |
except Exception as e:
|
| 305 |
return {"error": str(e)}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
|
| 308 |
# =============================================================================
|
|
|
|
| 303 |
}
|
| 304 |
except Exception as e:
|
| 305 |
return {"error": str(e)}
|
| 306 |
+
|
| 307 |
+
# =========================================================================
|
| 308 |
+
# ์ฌ์ฉ์ ํ๋กํ (User Gate)
|
| 309 |
+
# =========================================================================
|
| 310 |
+
|
| 311 |
+
def get_user_profile(self, user_id: str) -> Optional[Dict[str, Any]]:
|
| 312 |
+
"""
|
| 313 |
+
์ฌ์ฉ์ ํ๋กํ ์กฐํ.
|
| 314 |
+
|
| 315 |
+
Args:
|
| 316 |
+
user_id: Supabase Auth UUID
|
| 317 |
+
|
| 318 |
+
Returns:
|
| 319 |
+
ํ๋กํ ์ ๋ณด ๋๋ None
|
| 320 |
+
"""
|
| 321 |
+
try:
|
| 322 |
+
result = self.client.table("user_profiles")\
|
| 323 |
+
.select("*")\
|
| 324 |
+
.eq("user_id", user_id)\
|
| 325 |
+
.execute()
|
| 326 |
+
|
| 327 |
+
return result.data[0] if result.data else None
|
| 328 |
+
except Exception as e:
|
| 329 |
+
print(f"โ ๏ธ ํ๋กํ ์กฐํ ์คํจ ({user_id}): {e}")
|
| 330 |
+
return None
|
| 331 |
+
|
| 332 |
+
def upsert_user_profile(
|
| 333 |
+
self,
|
| 334 |
+
user_id: str,
|
| 335 |
+
preferred_airports: List[str] = None,
|
| 336 |
+
display_name: str = None
|
| 337 |
+
) -> Optional[Dict[str, Any]]:
|
| 338 |
+
"""
|
| 339 |
+
์ฌ์ฉ์ ํ๋กํ ์์ฑ/์์ .
|
| 340 |
+
|
| 341 |
+
Args:
|
| 342 |
+
user_id: Supabase Auth UUID
|
| 343 |
+
preferred_airports: ์ ํธ ๊ณตํญ ์ฝ๋ ๋ฆฌ์คํธ
|
| 344 |
+
display_name: ํ์ ์ด๋ฆ
|
| 345 |
+
|
| 346 |
+
Returns:
|
| 347 |
+
์ ์ฅ๋ ํ๋กํ ๋๋ None
|
| 348 |
+
"""
|
| 349 |
+
try:
|
| 350 |
+
data = {"user_id": user_id}
|
| 351 |
+
if preferred_airports is not None:
|
| 352 |
+
data["preferred_airports"] = preferred_airports
|
| 353 |
+
if display_name is not None:
|
| 354 |
+
data["display_name"] = display_name
|
| 355 |
+
|
| 356 |
+
result = self.client.table("user_profiles")\
|
| 357 |
+
.upsert(data, on_conflict="user_id")\
|
| 358 |
+
.execute()
|
| 359 |
+
|
| 360 |
+
return result.data[0] if result.data else None
|
| 361 |
+
except Exception as e:
|
| 362 |
+
print(f"โ ๏ธ ํ๋กํ ์ ์ฅ ์คํจ ({user_id}): {e}")
|
| 363 |
+
return None
|
| 364 |
+
|
| 365 |
+
# =========================================================================
|
| 366 |
+
# ๋ฉค๋ฒ์ญ (User Gate)
|
| 367 |
+
# =========================================================================
|
| 368 |
+
|
| 369 |
+
def get_user_memberships(self, user_id: str) -> List[Dict[str, Any]]:
|
| 370 |
+
"""
|
| 371 |
+
์ฌ์ฉ์์ ๋ชจ๋ ๋ฉค๋ฒ์ญ ์กฐํ.
|
| 372 |
+
|
| 373 |
+
Args:
|
| 374 |
+
user_id: Supabase Auth UUID
|
| 375 |
+
|
| 376 |
+
Returns:
|
| 377 |
+
๋ฉค๋ฒ์ญ ๋ฆฌ์คํธ
|
| 378 |
+
"""
|
| 379 |
+
try:
|
| 380 |
+
result = self.client.table("user_memberships")\
|
| 381 |
+
.select("*")\
|
| 382 |
+
.eq("user_id", user_id)\
|
| 383 |
+
.execute()
|
| 384 |
+
|
| 385 |
+
return result.data or []
|
| 386 |
+
except Exception as e:
|
| 387 |
+
print(f"โ ๏ธ ๋ฉค๋ฒ์ญ ์กฐํ ์คํจ ({user_id}): {e}")
|
| 388 |
+
return []
|
| 389 |
+
|
| 390 |
+
def upsert_membership(
|
| 391 |
+
self,
|
| 392 |
+
user_id: str,
|
| 393 |
+
chain: str,
|
| 394 |
+
tier: str,
|
| 395 |
+
expires_at: str = None
|
| 396 |
+
) -> Optional[Dict[str, Any]]:
|
| 397 |
+
"""
|
| 398 |
+
๋ฉค๋ฒ์ญ ๋ฑ๋ก/์์ .
|
| 399 |
+
|
| 400 |
+
Args:
|
| 401 |
+
user_id: Supabase Auth UUID
|
| 402 |
+
chain: ํธํ
์ฒด์ธ ์ฝ๋ (HILTON, MARRIOTT ๋ฑ)
|
| 403 |
+
tier: ๋ฑ๊ธ (Gold, Platinum ๋ฑ)
|
| 404 |
+
expires_at: ๋ง๋ฃ์ผ (์ ํ, YYYY-MM-DD)
|
| 405 |
+
|
| 406 |
+
Returns:
|
| 407 |
+
์ ์ฅ๋ ๋ฉค๋ฒ์ญ ๋๋ None
|
| 408 |
+
"""
|
| 409 |
+
try:
|
| 410 |
+
data = {
|
| 411 |
+
"user_id": user_id,
|
| 412 |
+
"chain": chain.upper(),
|
| 413 |
+
"tier": tier
|
| 414 |
+
}
|
| 415 |
+
if expires_at:
|
| 416 |
+
data["expires_at"] = expires_at
|
| 417 |
+
|
| 418 |
+
# user_id + chain ๋ณตํฉํค๋ก upsert
|
| 419 |
+
# Supabase๋ on_conflict์ ๋ณตํฉํค๋ฅผ ์ง์ ์ง์ํ์ง ์์ผ๋ฏ๋ก
|
| 420 |
+
# ๋จผ์ ์ญ์ ํ ์ฝ์
ํ๋ ๋ฐฉ์ ์ฌ์ฉ
|
| 421 |
+
self.client.table("user_memberships")\
|
| 422 |
+
.delete()\
|
| 423 |
+
.eq("user_id", user_id)\
|
| 424 |
+
.eq("chain", chain.upper())\
|
| 425 |
+
.execute()
|
| 426 |
+
|
| 427 |
+
result = self.client.table("user_memberships")\
|
| 428 |
+
.insert(data)\
|
| 429 |
+
.execute()
|
| 430 |
+
|
| 431 |
+
return result.data[0] if result.data else None
|
| 432 |
+
except Exception as e:
|
| 433 |
+
print(f"โ ๏ธ ๋ฉค๋ฒ์ญ ์ ์ฅ ์คํจ ({user_id}, {chain}): {e}")
|
| 434 |
+
return None
|
| 435 |
+
|
| 436 |
+
def delete_membership(self, user_id: str, chain: str) -> bool:
|
| 437 |
+
"""
|
| 438 |
+
๋ฉค๋ฒ์ญ ์ญ์ .
|
| 439 |
+
|
| 440 |
+
Args:
|
| 441 |
+
user_id: Supabase Auth UUID
|
| 442 |
+
chain: ํธํ
์ฒด์ธ ์ฝ๋
|
| 443 |
+
|
| 444 |
+
Returns:
|
| 445 |
+
์ญ์ ์ฑ๊ณต ์ฌ๋ถ
|
| 446 |
+
"""
|
| 447 |
+
try:
|
| 448 |
+
self.client.table("user_memberships")\
|
| 449 |
+
.delete()\
|
| 450 |
+
.eq("user_id", user_id)\
|
| 451 |
+
.eq("chain", chain.upper())\
|
| 452 |
+
.execute()
|
| 453 |
+
|
| 454 |
+
return True
|
| 455 |
+
except Exception as e:
|
| 456 |
+
print(f"โ ๏ธ ๋ฉค๋ฒ์ญ ์ญ์ ์คํจ ({user_id}, {chain}): {e}")
|
| 457 |
+
return False
|
| 458 |
|
| 459 |
|
| 460 |
# =============================================================================
|
src/mcp/server_streamable.py
CHANGED
|
@@ -405,6 +405,100 @@ TOOLS = [
|
|
| 405 |
},
|
| 406 |
"required": []
|
| 407 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 408 |
}
|
| 409 |
]
|
| 410 |
|
|
@@ -696,8 +790,13 @@ async def execute_tool_async(name: str, arguments: Dict[str, Any]) -> Dict[str,
|
|
| 696 |
return await convert_currency(**arguments)
|
| 697 |
elif name == "get_travel_info":
|
| 698 |
return await get_travel_info(**arguments)
|
| 699 |
-
|
| 700 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 701 |
|
| 702 |
|
| 703 |
# ============================================================
|
|
@@ -1408,6 +1507,14 @@ routes = [
|
|
| 1408 |
Route("/messages/", legacy_messages_endpoint, methods=["POST", "OPTIONS"]),
|
| 1409 |
]
|
| 1410 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1411 |
middleware = [
|
| 1412 |
Middleware(
|
| 1413 |
CORSMiddleware,
|
|
|
|
| 405 |
},
|
| 406 |
"required": []
|
| 407 |
}
|
| 408 |
+
},
|
| 409 |
+
# ============================================================
|
| 410 |
+
# User Tools (User Gate - ์ฌ์ฉ์ ์ธ์ฆ/๋ฉค๋ฒ์ญ)
|
| 411 |
+
# ============================================================
|
| 412 |
+
{
|
| 413 |
+
"name": "user_get_profile",
|
| 414 |
+
"description": (
|
| 415 |
+
"ํ์ฌ ์ฌ์ฉ์์ ํ๋กํ๊ณผ ๋ฉค๋ฒ์ญ ์ ๋ณด๋ฅผ ์กฐํํฉ๋๋ค. "
|
| 416 |
+
"์ฐ๊ฒฐ๋์ง ์์ ๊ฒฝ์ฐ auth_url์ ๋ฐํํฉ๋๋ค."
|
| 417 |
+
),
|
| 418 |
+
"inputSchema": {
|
| 419 |
+
"type": "object",
|
| 420 |
+
"properties": {
|
| 421 |
+
"session_token": {
|
| 422 |
+
"type": "string",
|
| 423 |
+
"description": "์ธ์ฆ ์ธ์
ํ ํฐ (์ด์ ์ธ์ฆ์์ ๋ฐ์ ๊ฐ)"
|
| 424 |
+
}
|
| 425 |
+
},
|
| 426 |
+
"required": []
|
| 427 |
+
}
|
| 428 |
+
},
|
| 429 |
+
{
|
| 430 |
+
"name": "user_update_membership",
|
| 431 |
+
"description": "ํธํ
์ฒด์ธ ๋ฉค๋ฒ์ญ์ ๋ฑ๋กํ๊ฑฐ๋ ์์ ํฉ๋๋ค.",
|
| 432 |
+
"inputSchema": {
|
| 433 |
+
"type": "object",
|
| 434 |
+
"properties": {
|
| 435 |
+
"chain": {
|
| 436 |
+
"type": "string",
|
| 437 |
+
"enum": ["HILTON", "MARRIOTT", "IHG", "ACCOR", "HYATT"],
|
| 438 |
+
"description": "ํธํ
์ฒด์ธ ์ฝ๋"
|
| 439 |
+
},
|
| 440 |
+
"tier": {
|
| 441 |
+
"type": "string",
|
| 442 |
+
"description": "๋ฉค๋ฒ์ญ ๋ฑ๊ธ (์: Gold, Platinum, Diamond)"
|
| 443 |
+
},
|
| 444 |
+
"session_token": {
|
| 445 |
+
"type": "string",
|
| 446 |
+
"description": "์ธ์ฆ ์ธ์
ํ ํฐ"
|
| 447 |
+
}
|
| 448 |
+
},
|
| 449 |
+
"required": ["chain", "tier", "session_token"]
|
| 450 |
+
}
|
| 451 |
+
},
|
| 452 |
+
{
|
| 453 |
+
"name": "user_delete_membership",
|
| 454 |
+
"description": "ํธํ
์ฒด์ธ ๋ฉค๋ฒ์ญ์ ์ญ์ ํฉ๋๋ค.",
|
| 455 |
+
"inputSchema": {
|
| 456 |
+
"type": "object",
|
| 457 |
+
"properties": {
|
| 458 |
+
"chain": {
|
| 459 |
+
"type": "string",
|
| 460 |
+
"enum": ["HILTON", "MARRIOTT", "IHG", "ACCOR", "HYATT"],
|
| 461 |
+
"description": "์ญ์ ํ ํธํ
์ฒด์ธ ์ฝ๋"
|
| 462 |
+
},
|
| 463 |
+
"session_token": {
|
| 464 |
+
"type": "string",
|
| 465 |
+
"description": "์ธ์ฆ ์ธ์
ํ ํฐ"
|
| 466 |
+
}
|
| 467 |
+
},
|
| 468 |
+
"required": ["chain", "session_token"]
|
| 469 |
+
}
|
| 470 |
+
},
|
| 471 |
+
{
|
| 472 |
+
"name": "user_request_auth",
|
| 473 |
+
"description": (
|
| 474 |
+
"Magic Link ์ธ์ฆ์ ์์ฒญํฉ๋๋ค. "
|
| 475 |
+
"์ด๋ฉ์ผ๋ก ๋ก๊ทธ์ธ ๋งํฌ๊ฐ ๋ฐ์ก๋ฉ๋๋ค."
|
| 476 |
+
),
|
| 477 |
+
"inputSchema": {
|
| 478 |
+
"type": "object",
|
| 479 |
+
"properties": {
|
| 480 |
+
"email": {
|
| 481 |
+
"type": "string",
|
| 482 |
+
"format": "email",
|
| 483 |
+
"description": "์ธ์ฆ์ ์ฌ์ฉํ ์ด๋ฉ์ผ ์ฃผ์"
|
| 484 |
+
}
|
| 485 |
+
},
|
| 486 |
+
"required": ["email"]
|
| 487 |
+
}
|
| 488 |
+
},
|
| 489 |
+
{
|
| 490 |
+
"name": "user_verify_code",
|
| 491 |
+
"description": "Magic Link ์ธ์ฆ ํ ๋ฐ์ 6์๋ฆฌ ์ฝ๋๋ฅผ ๊ฒ์ฆํฉ๋๋ค.",
|
| 492 |
+
"inputSchema": {
|
| 493 |
+
"type": "object",
|
| 494 |
+
"properties": {
|
| 495 |
+
"code": {
|
| 496 |
+
"type": "string",
|
| 497 |
+
"description": "6์๋ฆฌ ์ธ์ฆ ์ฝ๋"
|
| 498 |
+
}
|
| 499 |
+
},
|
| 500 |
+
"required": ["code"]
|
| 501 |
+
}
|
| 502 |
}
|
| 503 |
]
|
| 504 |
|
|
|
|
| 790 |
return await convert_currency(**arguments)
|
| 791 |
elif name == "get_travel_info":
|
| 792 |
return await get_travel_info(**arguments)
|
| 793 |
+
|
| 794 |
+
# User ๋๊ตฌ (User Gate)
|
| 795 |
+
if name.startswith("user_"):
|
| 796 |
+
from src.auth.tool_handlers import execute_user_tool
|
| 797 |
+
return await execute_user_tool(name, arguments)
|
| 798 |
+
|
| 799 |
+
return {"success": False, "error": f"Unknown tool: {name}"}
|
| 800 |
|
| 801 |
|
| 802 |
# ============================================================
|
|
|
|
| 1507 |
Route("/messages/", legacy_messages_endpoint, methods=["POST", "OPTIONS"]),
|
| 1508 |
]
|
| 1509 |
|
| 1510 |
+
# Auth routes ํ์ฅ (User Gate)
|
| 1511 |
+
try:
|
| 1512 |
+
from src.auth.routes import auth_routes
|
| 1513 |
+
routes.extend(auth_routes)
|
| 1514 |
+
logger.info(f"Auth routes loaded: {len(auth_routes)} endpoints")
|
| 1515 |
+
except ImportError as e:
|
| 1516 |
+
logger.warning(f"Auth routes not loaded: {e}")
|
| 1517 |
+
|
| 1518 |
middleware = [
|
| 1519 |
Middleware(
|
| 1520 |
CORSMiddleware,
|