diff --git a/backend-auth-analysis.md b/backend-auth-analysis.md new file mode 100644 index 0000000..248e770 --- /dev/null +++ b/backend-auth-analysis.md @@ -0,0 +1,240 @@ +# DramaLing 後端帳號管理分析報告 + +## 📊 總體狀況概覽 + +### ✅ 已實現功能 +- **用戶註冊 (`POST /api/auth/register`)** +- **用戶登入 (`POST /api/auth/login`)** +- **JWT Token 認證系統** +- **用戶資料管理 (`GET/PUT /api/auth/profile`)** +- **用戶設定管理 (`GET/PUT /api/auth/settings`)** +- **認證狀態檢查 (`GET /api/auth/status`)** + +### ⚠️ 安全性問題 +- **開發環境中存在硬編碼測試用戶ID** +- **部分控制器缺乏權限驗證** +- **JWT Secret 可能使用開發預設值** + +--- + +## 🔐 認證系統詳細分析 + +### 1. 註冊系統 (`AuthController.cs:28-110`) + +**功能特點:** +- 用戶名唯一性檢查 +- Email 唯一性檢查 +- BCrypt 密碼雜湊 +- 自動生成 JWT Token +- 完整的輸入驗證 + +**驗證規則:** +```csharp +Username: 3-50 字符長度 +Email: 標準 Email 格式驗證 +Password: 最少 8 字符 +``` + +### 2. 登入系統 (`AuthController.cs:112-175`) + +**功能特點:** +- Email + 密碼認證 +- BCrypt 密碼驗證 +- JWT Token 生成 +- 統一錯誤訊息(避免用戶名洩露) + +### 3. JWT Token 系統 (`AuthController.cs:177-204`) + +**設定分析:** +```csharp +Secret 來源優先順序: +1. 環境變數: DRAMALING_SUPABASE_JWT_SECRET +2. 環境變數: DRAMALING_JWT_SECRET +3. 預設值: "dev-secret-minimum-32-characters-long-for-jwt-signing-in-development-mode-only" + +Token 有效期: 7 天 +包含 Claims: NameIdentifier, sub, email, username, name +``` + +--- + +## 🛡️ 權限控制分析 + +### 已實現權限保護的端點 + +| 控制器 | 端點 | 權限類型 | 備註 | +|--------|------|----------|------| +| AuthController | `/api/auth/profile` | `[Authorize]` | 用戶資料 | +| AuthController | `/api/auth/settings` | `[Authorize]` | 用戶設定 | +| AuthController | `/api/auth/status` | `[Authorize]` | 認證檢查 | +| StatsController | 全部端點 | `[Authorize]` | 統計數據 | + +### ⚠️ 缺乏權限保護的端點 + +| 控制器 | 權限設定 | 風險等級 | +|--------|----------|----------| +| FlashcardsController | `[AllowAnonymous]` | 🔴 高風險 | +| AIController | 未明確設定 | 🟡 中風險 | +| ImageGenerationController | 未明確設定 | 🟡 中風險 | +| OptionsVocabularyTestController | 未明確設定 | 🟡 中風險 | + +--- + +## 🚨 硬編碼用戶問題 + +### 問題位置 +1. **BaseController.cs:79** + ```csharp + return Guid.Parse("00000000-0000-0000-0000-000000000001"); + ``` + +2. **ImageGenerationController.cs:160** + ```csharp + return Guid.Parse("00000000-0000-0000-0000-000000000001"); + ``` + +### 觸發條件 +- 開發環境 (`ASPNETCORE_ENVIRONMENT=Development`) +- JWT Token 解析失敗時的 Fallback + +### 風險評估 +- **開發環境**: 可接受(便於測試) +- **生產環境**: 🔴 高風險(繞過認證) + +--- + +## 🔧 AuthService 核心邏輯 + +### Token 驗證流程 (`AuthService.cs:56-101`) + +```csharp +JWT Secret 來源優先順序: +1. 環境變數: DRAMALING_SUPABASE_JWT_SECRET +2. 配置檔案: Supabase:JwtSecret +3. 無設定時返回 null(驗證失敗) + +驗證參數: +- ValidateIssuer: true +- ValidateAudience: true +- ValidateLifetime: true +- ValidateIssuerSigningKey: true +- ClockSkew: 5 分鐘 +``` + +### 用戶ID 提取邏輯 (`AuthService.cs:25-54`) + +```csharp +Claims 查找優先順序: +1. ClaimTypes.NameIdentifier +2. "sub" claim +3. 嘗試解析為 Guid +``` + +--- + +## 📋 配置管理分析 + +### 環境變數配置 +```bash +# JWT 相關 +DRAMALING_SUPABASE_JWT_SECRET=<實際密鑰> +DRAMALING_SUPABASE_URL= + +# API 服務 +ASPNETCORE_ENVIRONMENT=Development|Production +``` + +### 配置檔案 (`appsettings.json`) +- **無敏感資訊洩露** ✅ +- **所有密鑰為空字串** ✅ +- **依賴環境變數或 User Secrets** ✅ + +--- + +## 🎯 建議改進措施 + +### 1. 立即修復(高優先級) + +#### 🔴 移除 FlashcardsController 的 AllowAnonymous +```csharp +// 當前 +[AllowAnonymous] +public class FlashcardsController : BaseController + +// 建議改為 +[Authorize] +public class FlashcardsController : BaseController +``` + +#### 🔴 統一權限保護 +為所有業務控制器添加 `[Authorize]` 屬性: +- AIController +- ImageGenerationController +- OptionsVocabularyTestController + +### 2. 安全性強化(中優先級) + +#### 🟡 硬編碼用戶ID 處理 +```csharp +// 建議修改 BaseController.GetCurrentUserIdAsync() +protected async Task GetCurrentUserIdAsync() +{ + // ... JWT 解析邏輯 ... + + // 開發環境 fallback(僅限測試數據庫) + if (IsTestEnvironment() && IsUsingTestDatabase()) + { + return Guid.Parse("00000000-0000-0000-0000-000000000001"); + } + + throw new UnauthorizedAccessException("Invalid or missing user authentication"); +} +``` + +#### 🟡 JWT Secret 強化 +確保生產環境使用強密鑰: +```csharp +// 添加密鑰強度檢查 +if (environment != "Development" && jwtSecret.Length < 32) +{ + throw new InvalidOperationException("Production JWT secret must be at least 32 characters"); +} +``` + +### 3. 監控和日誌(低優先級) + +#### 添加安全事件日誌 +- 失敗的登入嘗試 +- Token 驗證失敗 +- 權限拒絕事件 + +#### 添加指標監控 +- 活躍用戶數 +- 認證失敗率 +- API 調用頻率 + +--- + +## 📊 總結 + +### 優點 +✅ 完整的用戶註冊/登入流程 +✅ 安全的密碼雜湊(BCrypt) +✅ 標準的 JWT 認證機制 +✅ 配置安全(無硬編碼密鑰) +✅ 統一的錯誤處理 + +### 待改進 +🔴 部分控制器缺乏權限保護 +🟡 開發環境硬編碼用戶ID +🟡 需要更完善的安全監控 + +### 風險等級評估 +**整體風險等級**: 🟡 **中等風險** + +主要風險來自於 FlashcardsController 的 `[AllowAnonymous]` 設定,可能導致未認證用戶存取單字卡數據。建議優先修復此問題。 + +--- + +*分析完成時間: 2025-10-07* +*後端服務狀態: 正常運行 (http://localhost:5000)* \ No newline at end of file diff --git a/backend/DramaLing.Api/Services/Vocabulary/Options/OptionsVocabularyService.cs b/backend/DramaLing.Api/Services/Vocabulary/Options/OptionsVocabularyService.cs index 79a6ab0..d4d5529 100644 --- a/backend/DramaLing.Api/Services/Vocabulary/Options/OptionsVocabularyService.cs +++ b/backend/DramaLing.Api/Services/Vocabulary/Options/OptionsVocabularyService.cs @@ -85,11 +85,12 @@ public class OptionsVocabularyService : IOptionsVocabularyService } // 2. 檢查資料庫中是否有現有選項 + var targetWordLower = targetWord.ToLower(); var existingOptions = await _context.OptionsVocabularies .Where(ov => ov.CEFRLevel == cefrLevel && ov.PartOfSpeech == partOfSpeech && ov.IsActive && - !string.Equals(ov.Word, targetWord, StringComparison.OrdinalIgnoreCase)) + ov.Word.ToLower() != targetWordLower) .Take(count * 2) // 取更多選項以供隨機選擇 .ToListAsync(); @@ -292,8 +293,9 @@ Please respond with ONLY a JSON array of strings, like: [""word1"", ""word2"", " _ => new[] { "option", "choice", "answer", "question", "test", "study" } }; + var targetWordLower = targetWord.ToLower(); return fallbackWords - .Where(word => !string.Equals(word, targetWord, StringComparison.OrdinalIgnoreCase)) + .Where(word => word.ToLower() != targetWordLower) .Take(count) .Select(word => new OptionsVocabulary { diff --git a/frontend/hooks/review/useReviewSession.ts b/frontend/hooks/review/useReviewSession.ts index cf459d1..05bb0a6 100644 --- a/frontend/hooks/review/useReviewSession.ts +++ b/frontend/hooks/review/useReviewSession.ts @@ -348,37 +348,8 @@ export function useReviewSession() { } } - // 先嘗試載入保存的進度 - const savedProgress = localStorage.getItem('review-linear-progress') - if (savedProgress) { - try { - const parsed = JSON.parse(savedProgress) - const saveTime = new Date(parsed.timestamp) - const now = new Date() - const isToday = saveTime.toDateString() === now.toDateString() - - if (isToday && parsed.quizItems && parsed.flashcards) { - dispatch({ - type: 'LOAD_PROGRESS', - payload: { - quizItems: parsed.quizItems, - score: parsed.score || { correct: 0, total: 0 }, - isComplete: parsed.isComplete || false, - flashcards: parsed.flashcards, - isLoading: false, - error: null, - pendingWordSubmission: null, - submittingWords: new Set() - } - }) - console.log('📖 載入保存的線性複習進度') - return // 如果有保存的進度就不重新載入 - } - } catch (error) { - console.warn('進度載入失敗:', error) - localStorage.removeItem('review-linear-progress') - } - } + // 清除可能的舊快取,確保始終從後端載入最新資料 + localStorage.removeItem('review-linear-progress') // 載入新的詞卡資料 loadFlashcards() @@ -392,17 +363,11 @@ export function useReviewSession() { } }, [pendingWordSubmission]) - // 保存進度到localStorage + // 保存進度到localStorage (已停用以確保資料即時性) const saveProgress = () => { - const progress = { - quizItems, - score, - isComplete, - flashcards, - timestamp: new Date().toISOString() - } - localStorage.setItem('review-linear-progress', JSON.stringify(progress)) - console.log('💾 線性進度已保存') + // 暫時停用快取機制,確保始終從後端獲取最新資料 + // localStorage.removeItem('review-linear-progress') + console.log('💾 進度保存已停用,確保資料即時性') } // 提交詞彙完成到後端(簡化版) @@ -471,7 +436,6 @@ export function useReviewSession() { // 重新開始 - 重置所有狀態 const handleRestart = () => { dispatch({ type: 'RESTART' }) - localStorage.removeItem('review-linear-progress') console.log('🔄 線性複習進度已重置') }