## Database Migration (SQLite → MySQL) - Add Alembic migration framework - Add 'tr_' prefix to all tables to avoid conflicts in shared database - Remove SQLite support, use MySQL exclusively - Add pymysql driver dependency - Change ad_token column to Text type for long JWT tokens ## Unified Environment Configuration - Centralize all hardcoded settings to environment variables - Backend: Extend Settings class in app/core/config.py - Frontend: Use Vite environment variables (import.meta.env) - Docker: Move credentials to environment variables - Update .env.example files with comprehensive documentation ## Test Organization - Move root-level test files to tests/ directory: - test_chat_room.py → tests/test_chat_room.py - test_websocket.py → tests/test_websocket.py - test_realtime_implementation.py → tests/test_realtime_implementation.py - Fix path references in test_realtime_implementation.py Breaking Changes: - CORS now requires explicit origins (no more wildcard) - All database tables renamed with 'tr_' prefix - SQLite no longer supported 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
112 lines
3.9 KiB
Python
112 lines
3.9 KiB
Python
"""AD API client service for authentication
|
|
|
|
與 Panjit AD API 整合,負責:
|
|
- 驗證使用者憑證
|
|
- 取得 AD token 和使用者名稱
|
|
- 處理 API 連線錯誤
|
|
"""
|
|
from datetime import datetime, timedelta
|
|
import httpx
|
|
from typing import Dict
|
|
from app.core.config import get_settings
|
|
|
|
settings = get_settings()
|
|
|
|
|
|
class ADAuthService:
|
|
"""Active Directory authentication service"""
|
|
|
|
def __init__(self):
|
|
self.ad_api_url = settings.AD_API_URL
|
|
self._client = httpx.AsyncClient(timeout=float(settings.AD_API_TIMEOUT_SECONDS))
|
|
|
|
async def authenticate(self, username: str, password: str) -> Dict[str, any]:
|
|
"""Authenticate user with AD API
|
|
|
|
Args:
|
|
username: User email (e.g., ymirliu@panjit.com.tw)
|
|
password: User password
|
|
|
|
Returns:
|
|
Dict containing:
|
|
- token: AD authentication token
|
|
- username: Display name from AD
|
|
- email: User email address
|
|
- office_location: Office location (optional)
|
|
- job_title: Job title (optional)
|
|
- expires_at: Estimated token expiry datetime
|
|
|
|
Raises:
|
|
httpx.HTTPStatusError: If authentication fails (401, 403)
|
|
httpx.RequestError: If AD API is unreachable
|
|
"""
|
|
payload = {"username": username, "password": password}
|
|
|
|
try:
|
|
response = await self._client.post(
|
|
self.ad_api_url, json=payload, headers={"Content-Type": "application/json"}
|
|
)
|
|
|
|
# Raise exception for 4xx/5xx status codes
|
|
response.raise_for_status()
|
|
|
|
data = response.json()
|
|
|
|
# Extract token and username from response
|
|
# Response structure: {"success": true, "data": {"access_token": "...", "userInfo": {"name": "...", "email": "..."}}}
|
|
if not data.get("success"):
|
|
raise ValueError("Authentication failed")
|
|
|
|
token_data = data.get("data", {})
|
|
ad_token = token_data.get("access_token")
|
|
user_info = token_data.get("userInfo", {})
|
|
display_name = user_info.get("name") or username
|
|
email = user_info.get("email") or username
|
|
office_location = user_info.get("officeLocation")
|
|
job_title = user_info.get("jobTitle")
|
|
|
|
if not ad_token:
|
|
raise ValueError("No token received from AD API")
|
|
|
|
# Parse expiry time from response (expiresAt field)
|
|
expires_at_str = token_data.get("expiresAt")
|
|
if expires_at_str:
|
|
# Parse ISO format: "2025-11-16T14:38:37.912Z"
|
|
try:
|
|
expires_at = datetime.fromisoformat(expires_at_str.replace("Z", "+00:00"))
|
|
except:
|
|
expires_at = datetime.utcnow() + timedelta(hours=1)
|
|
else:
|
|
# Fallback: assume 1 hour if not provided
|
|
expires_at = datetime.utcnow() + timedelta(hours=1)
|
|
|
|
return {
|
|
"token": ad_token,
|
|
"username": display_name,
|
|
"email": email,
|
|
"office_location": office_location,
|
|
"job_title": job_title,
|
|
"expires_at": expires_at,
|
|
}
|
|
|
|
except httpx.HTTPStatusError as e:
|
|
# Authentication failed (401) or other HTTP errors
|
|
if e.response.status_code == 401:
|
|
raise ValueError("Invalid credentials") from e
|
|
elif e.response.status_code >= 500:
|
|
raise ConnectionError("Authentication service error") from e
|
|
else:
|
|
raise
|
|
|
|
except httpx.RequestError as e:
|
|
# Network error, timeout, etc.
|
|
raise ConnectionError("Authentication service unavailable") from e
|
|
|
|
async def close(self):
|
|
"""Close HTTP client"""
|
|
await self._client.aclose()
|
|
|
|
|
|
# Singleton instance
|
|
ad_auth_service = ADAuthService()
|