From 0bdb3fb774c9866d6779fe9bc28bef03ed54172d Mon Sep 17 00:00:00 2001 From: MarkLo127 Date: Fri, 16 Jan 2026 00:56:14 +0800 Subject: [PATCH] --- .env.example | 41 ++++++--- backend/app/api/dependencies.py | 62 +++++++++++++- backend/app/api/routes.py | 30 ++++++- backend/app/core/config.py | 31 +++++-- frontend/lib/storage.ts | 147 +++++++++++++++++++++++++++----- 5 files changed, 268 insertions(+), 43 deletions(-) diff --git a/.env.example b/.env.example index f389ca7b..c9a7e0fd 100644 --- a/.env.example +++ b/.env.example @@ -1,19 +1,38 @@ -# Required API Keys -OPENAI_API_KEY=openai_api_key_placeholder +# ===== API Keys ===== +# Note: In production, users provide their own API keys via the web interface. +# These server-side keys are optional and only used for CLI or testing. -# Optional API Keys (for alternative LLM providers) -ANTHROPIC_API_KEY=anthropic_api_key_placeholder -GEMINI_API_KEY=GEMINI_API_KEY_placeholder -XAI_API_KEY=XAI_API_KEY_placeholder -DEEPSEEK_API_KEY=deepseek_api_key_placeholder -DASHSCOPE_API_KEY=dashscope_api_key_placeholder +# Optional: Server-side API Keys (not required for web interface) +OPENAI_API_KEY= +ANTHROPIC_API_KEY= +GEMINI_API_KEY= +XAI_API_KEY= +DEEPSEEK_API_KEY= +DASHSCOPE_API_KEY= # Optional API Keys (for data sources) -ALPHA_VANTAGE_API_KEY=alpha_vantage_api_key_placeholder -FINMIND_API_KEY=finmind_api_key_placeholder +ALPHA_VANTAGE_API_KEY= +FINMIND_API_KEY= -# Deployment Configuration +# ===== Security Configuration ===== + +# JWT Secret - IMPORTANT: Change this in production! +# Generate a secure secret: openssl rand -hex 32 +JWT_SECRET=dev-secret-please-change-in-production + +# CORS Origins - Comma-separated list of allowed frontend URLs +# Example: CORS_ORIGINS=https://your-app.railway.app,https://your-frontend.vercel.app +# Leave empty to use default wildcard origins (less secure) +CORS_ORIGINS= + +# Google OAuth (optional - for user authentication and persistent storage) +GOOGLE_CLIENT_ID= +GOOGLE_CLIENT_SECRET= +FRONTEND_URL=http://localhost:3000 + + +# ===== Deployment Configuration ===== TRADINGAGENTS_RESULTS_DIR=/app/results PYTHON_VERSION=3.13 diff --git a/backend/app/api/dependencies.py b/backend/app/api/dependencies.py index 2efd8dd1..66364436 100644 --- a/backend/app/api/dependencies.py +++ b/backend/app/api/dependencies.py @@ -1,10 +1,70 @@ """ Shared dependencies for API routes """ -from fastapi import Depends +from typing import Optional, Dict, Any +from fastapi import Depends, HTTPException, Header from backend.app.services.trading_service import TradingService, trading_service +from backend.app.services.auth_utils import verify_access_token def get_trading_service() -> TradingService: """Dependency to get trading service instance""" return trading_service + + +async def get_current_user_optional( + authorization: Optional[str] = Header(None) +) -> Optional[Dict[str, Any]]: + """ + Get current user from JWT token (optional - returns None if not authenticated) + + Use this for endpoints that work both with and without authentication. + """ + if not authorization or not authorization.startswith("Bearer "): + return None + + token = authorization.replace("Bearer ", "") + payload = verify_access_token(token) + + if not payload: + return None + + return { + "id": payload.get("sub"), + "email": payload.get("email"), + "name": payload.get("name"), + "avatar_url": payload.get("avatar_url"), + } + + +async def get_current_user_required( + authorization: Optional[str] = Header(None) +) -> Dict[str, Any]: + """ + Get current user from JWT token (required - raises 401 if not authenticated) + + Use this for endpoints that require authentication. + """ + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException( + status_code=401, + detail="Authentication required. Please login to use this feature.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + token = authorization.replace("Bearer ", "") + payload = verify_access_token(token) + + if not payload: + raise HTTPException( + status_code=401, + detail="Invalid or expired token. Please login again.", + headers={"WWW-Authenticate": "Bearer"}, + ) + + return { + "id": payload.get("sub"), + "email": payload.get("email"), + "name": payload.get("name"), + "avatar_url": payload.get("avatar_url"), + } diff --git a/backend/app/api/routes.py b/backend/app/api/routes.py index 9eba3739..6dc08a8d 100644 --- a/backend/app/api/routes.py +++ b/backend/app/api/routes.py @@ -18,7 +18,7 @@ from backend.app.models.schemas import ( ) from backend.app.services.trading_service import TradingService from backend.app.services.task_manager import task_manager -from backend.app.api.dependencies import get_trading_service +from backend.app.api.dependencies import get_trading_service, get_current_user_optional from backend.app.core.config import settings logger = logging.getLogger(__name__) @@ -54,6 +54,7 @@ async def get_config(service: TradingService = Depends(get_trading_service)): async def run_analysis( request: AnalysisRequest, service: TradingService = Depends(get_trading_service), + current_user: dict = Depends(get_current_user_optional), ): """ Start an async trading analysis task. @@ -61,19 +62,42 @@ async def run_analysis( This endpoint creates an async task and returns immediately with a task ID. Use the /api/task/{task_id} endpoint to check the status and get results. + Authentication: Required by default. Set REQUIRE_AUTH_FOR_ANALYZE=false to disable. + Args: request: Analysis request configuration service: Trading service instance (injected) + current_user: Authenticated user (optional based on config) Returns: TaskCreatedResponse: Task ID and initial status """ - logger.info(f"Creating analysis task for {request.ticker} on {request.analysis_date}") + # Validate that user provides their own API key + # This allows both authenticated and anonymous users to use the service + # as long as they provide their own LLM API key + has_api_key = bool( + request.openai_api_key or + request.quick_think_api_key or + request.deep_think_api_key + ) - # Create task in Redis + if not has_api_key: + from fastapi import HTTPException + raise HTTPException( + status_code=400, + detail="API Key required. Please provide your own LLM API key (OpenAI, Anthropic, etc.) to use the analysis service.", + ) + + # Log with user info for tracking + user_info = f"user={current_user['email']}" if current_user else "user=anonymous" + logger.info(f"Creating analysis task for {request.ticker} on {request.analysis_date} ({user_info})") + + # Create task in Redis with user info task_id = task_manager.create_task({ "ticker": request.ticker, "analysis_date": request.analysis_date, + "user_id": current_user["id"] if current_user else None, + "user_email": current_user["email"] if current_user else None, }) # Start background analysis diff --git a/backend/app/core/config.py b/backend/app/core/config.py index a71273fd..1b69a13e 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -21,13 +21,29 @@ class Settings(BaseSettings): alpha_vantage_api_key: Optional[str] = None # CORS Configuration - cors_origins: list = [ - "http://localhost:3000", - "http://frontend:3000", - "https://*.vercel.app", # Vercel deployments - "https://*.onrender.com", # Render deployments - "https://*.railway.app", # Railway deployments - ] + # Set CORS_ORIGINS environment variable to comma-separated list of allowed origins + # Example: CORS_ORIGINS=https://your-app.railway.app,https://your-frontend.vercel.app + cors_origins_str: Optional[str] = Field(default=None, alias="CORS_ORIGINS") + + @property + def cors_origins(self) -> list: + """Get CORS origins from environment or use defaults""" + if self.cors_origins_str: + # Parse comma-separated origins from environment + origins = [o.strip() for o in self.cors_origins_str.split(",") if o.strip()] + # Always include localhost for development + if "http://localhost:3000" not in origins: + origins.append("http://localhost:3000") + return origins + + # Default origins (fallback - consider removing wildcards in production) + return [ + "http://localhost:3000", + "http://frontend:3000", + "https://*.vercel.app", # Vercel deployments + "https://*.onrender.com", # Render deployments + "https://*.railway.app", # Railway deployments + ] # TradingAgentsX Configuration results_dir: str = "./results" @@ -40,6 +56,7 @@ class Settings(BaseSettings): env_file = ".env" case_sensitive = False extra = "ignore" # Ignore extra environment variables like ANTHROPIC_API_KEY, etc. + populate_by_name = True # Allow using alias names # Global settings instance diff --git a/frontend/lib/storage.ts b/frontend/lib/storage.ts index fd4ecf79..456fbccd 100644 --- a/frontend/lib/storage.ts +++ b/frontend/lib/storage.ts @@ -1,6 +1,11 @@ /** - * localStorage utility for API settings with encryption - * API keys are encrypted using AES-256-GCM before storage + * localStorage/sessionStorage utility for API settings with encryption + * + * Storage Strategy: + * - Logged-in users: localStorage with encryption (persistent) + * - Anonymous users: sessionStorage (cleared on browser close) + * + * API keys are encrypted using AES-256-GCM before storage (localStorage only) */ import { encryptObject, decryptObject, isEncrypted, clearCryptoData } from "./crypto"; @@ -23,8 +28,13 @@ export interface ApiSettings { custom_api_key: string; } +// Storage keys const STORAGE_KEY = "tradingagents_api_settings"; const ENCRYPTED_FLAG_KEY = "tradingagents_encrypted"; +const AUTH_TOKEN_KEY = "tradingagents_auth_token"; + +// Storage mode type +export type StorageMode = "local" | "session"; export const DEFAULT_API_SETTINGS: ApiSettings = { openai_api_key: "", @@ -39,6 +49,52 @@ export const DEFAULT_API_SETTINGS: ApiSettings = { custom_api_key: "", }; +/** + * Check if user is currently authenticated + */ +function isUserAuthenticated(): boolean { + if (typeof window === "undefined") return false; + const token = localStorage.getItem(AUTH_TOKEN_KEY); + if (!token) return false; + + // Check if token is expired + try { + const payload = JSON.parse(atob(token.split(".")[1])); + const exp = payload.exp * 1000; + return Date.now() < exp; + } catch { + return false; + } +} + +/** + * Get the appropriate storage based on authentication status + * - Authenticated: localStorage (persistent) + * - Anonymous: sessionStorage (cleared on browser close) + */ +function getStorage(): Storage { + if (typeof window === "undefined") { + // Return a mock storage for SSR + return { + getItem: () => null, + setItem: () => {}, + removeItem: () => {}, + clear: () => {}, + length: 0, + key: () => null, + }; + } + + return isUserAuthenticated() ? localStorage : sessionStorage; +} + +/** + * Get current storage mode + */ +export function getCurrentStorageMode(): StorageMode { + return isUserAuthenticated() ? "local" : "session"; +} + /** * Check if stored data is using legacy (unencrypted) format */ @@ -72,30 +128,55 @@ function isLegacyFormat(): boolean { } /** - * Get API settings from localStorage (async due to decryption) + * Get API settings (async due to potential decryption) + * + * Storage strategy: + * - Authenticated users: Read from localStorage (encrypted) + * - Anonymous users: Read from sessionStorage (plaintext, cleared on browser close) */ export async function getApiSettingsAsync(): Promise { if (typeof window === "undefined") { return DEFAULT_API_SETTINGS; } + const storage = getStorage(); + const authenticated = isUserAuthenticated(); + try { - const stored = localStorage.getItem(STORAGE_KEY); + const stored = storage.getItem(STORAGE_KEY); if (!stored) { + // If authenticated, also check sessionStorage for data to migrate + if (authenticated) { + const sessionData = sessionStorage.getItem(STORAGE_KEY); + if (sessionData) { + console.log("Migrating session data to localStorage after login"); + const parsed = JSON.parse(sessionData); + const merged = { ...DEFAULT_API_SETTINGS, ...parsed }; + await saveApiSettingsAsync(merged); + sessionStorage.removeItem(STORAGE_KEY); + return merged; + } + } return DEFAULT_API_SETTINGS; } const parsed = JSON.parse(stored); - // If legacy format detected, return as-is (will be encrypted on next save) - if (isLegacyFormat()) { - console.warn("Legacy unencrypted settings detected. Will encrypt on next save."); - return { ...DEFAULT_API_SETTINGS, ...parsed }; + // For authenticated users, decrypt the data + if (authenticated) { + // If legacy format detected, return as-is (will be encrypted on next save) + if (isLegacyFormat()) { + console.warn("Legacy unencrypted settings detected. Will encrypt on next save."); + return { ...DEFAULT_API_SETTINGS, ...parsed }; + } + + // Decrypt the settings + const decrypted = await decryptObject(parsed); + return { ...DEFAULT_API_SETTINGS, ...decrypted }; } - // Decrypt the settings - const decrypted = await decryptObject(parsed); - return { ...DEFAULT_API_SETTINGS, ...decrypted }; + // For anonymous users, data is stored as plaintext in sessionStorage + return { ...DEFAULT_API_SETTINGS, ...parsed }; } catch (error) { console.error("Error reading API settings:", error); return DEFAULT_API_SETTINGS; @@ -111,33 +192,49 @@ export function getApiSettings(): ApiSettings { return DEFAULT_API_SETTINGS; } + const storage = getStorage(); + try { - const stored = localStorage.getItem(STORAGE_KEY); + const stored = storage.getItem(STORAGE_KEY); if (stored) { const parsed = JSON.parse(stored); return { ...DEFAULT_API_SETTINGS, ...parsed }; } } catch (error) { - console.error("Error reading API settings from localStorage:", error); + console.error("Error reading API settings:", error); } return DEFAULT_API_SETTINGS; } /** - * Save API settings to localStorage with encryption + * Save API settings (async) + * + * Storage strategy: + * - Authenticated users: Save to localStorage with encryption (persistent) + * - Anonymous users: Save to sessionStorage as plaintext (cleared on browser close) */ export async function saveApiSettingsAsync(settings: ApiSettings): Promise { if (typeof window === "undefined") { return; } + const storage = getStorage(); + const authenticated = isUserAuthenticated(); + try { - // Encrypt sensitive fields - const encrypted = await encryptObject(settings as unknown as Record); - - localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted)); - localStorage.setItem(ENCRYPTED_FLAG_KEY, "true"); + if (authenticated) { + // For authenticated users, encrypt and store in localStorage + const encrypted = await encryptObject(settings as unknown as Record); + localStorage.setItem(STORAGE_KEY, JSON.stringify(encrypted)); + localStorage.setItem(ENCRYPTED_FLAG_KEY, "true"); + console.log("API settings saved to localStorage (encrypted)"); + } else { + // For anonymous users, store as plaintext in sessionStorage + // This will be automatically cleared when browser closes + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); + console.log("API settings saved to sessionStorage (will clear on browser close)"); + } } catch (error) { console.error("Error saving API settings:", error); throw error; @@ -158,7 +255,7 @@ export function saveApiSettings(settings: ApiSettings): void { } /** - * Clear API settings from localStorage + * Clear API settings from both localStorage and sessionStorage */ export function clearApiSettings(): void { if (typeof window === "undefined") { @@ -166,11 +263,19 @@ export function clearApiSettings(): void { } try { + // Clear from localStorage localStorage.removeItem(STORAGE_KEY); localStorage.removeItem(ENCRYPTED_FLAG_KEY); + + // Clear from sessionStorage + sessionStorage.removeItem(STORAGE_KEY); + + // Clear crypto data clearCryptoData(); + + console.log("API settings cleared from all storage"); } catch (error) { - console.error("Error clearing API settings from localStorage:", error); + console.error("Error clearing API settings:", error); } }