瀏覽代碼

-dev:接入了oauth平台

LuoChinWen 1 月之前
父節點
當前提交
9d67d5a960

+ 207 - 0
OAUTH_DUPLICATE_CALL_FIX.md

@@ -0,0 +1,207 @@
+# OAuth 回调重复调用问题修复
+
+## 问题描述
+
+在开发环境中,OAuth 回调端点 `/api/oauth/callback` 被调用了两次,导致第二次调用时出现错误:
+
+```json
+{
+  "detail": "OAuth 登录失败: 无效的令牌响应格式: {'error': 'invalid_grant', 'error_description': '授权码已被使用'}"
+}
+```
+
+## 根本原因
+
+### React StrictMode
+
+在开发环境中,React 18 的 StrictMode 会**故意**渲染组件两次,以帮助发现副作用问题。这导致 `useEffect` 被执行两次。
+
+```tsx
+// main.tsx
+<StrictMode>
+  <BrowserRouter>
+    <App />
+  </BrowserRouter>
+</StrictMode>
+```
+
+### 错误的修复尝试
+
+最初尝试使用 `useState` 来防止重复调用:
+
+```tsx
+const [isProcessing, setIsProcessing] = useState(false);
+
+useEffect(() => {
+  if (isProcessing) {
+    return; // 试图阻止重复调用
+  }
+  
+  setIsProcessing(true);
+  // ... 处理逻辑
+}, [searchParams, navigate, setAuth, isProcessing]); // ❌ 问题在这里!
+```
+
+**问题**:`isProcessing` 被添加到依赖数组中,当它从 `false` 变为 `true` 时,会触发 `useEffect` 重新运行,从而绕过了防重复调用的检查。
+
+## 正确的解决方案
+
+使用 `useRef` 而不是 `useState` 来跟踪处理状态:
+
+```tsx
+const isProcessingRef = useRef(false);
+
+useEffect(() => {
+  // 防止 React StrictMode 导致的重复调用
+  if (isProcessingRef.current) {
+    console.log('Already processing, skipping duplicate call');
+    return;
+  }
+
+  const processCallback = async () => {
+    isProcessingRef.current = true;
+    console.log('Starting OAuth callback processing...');
+    
+    try {
+      // ... OAuth 处理逻辑
+    } catch (err) {
+      // ... 错误处理
+    }
+  };
+
+  processCallback();
+}, [searchParams, navigate, setAuth]); // ✅ 不包含 isProcessingRef
+```
+
+### 为什么 useRef 有效?
+
+1. **不触发重渲染**:修改 `ref.current` 不会触发组件重渲染
+2. **不在依赖数组中**:`useRef` 返回的对象在组件生命周期内保持不变,不需要添加到依赖数组
+3. **跨渲染持久化**:即使组件被渲染两次,`ref` 的值也会保持
+
+## 执行流程
+
+### StrictMode 下的执行顺序
+
+```
+1. 第一次渲染
+   - isProcessingRef.current = false
+   - 检查通过,开始处理
+   - isProcessingRef.current = true
+   - 调用 /api/oauth/callback (第一次)
+
+2. StrictMode 触发第二次渲染
+   - isProcessingRef.current = true (保持不变)
+   - 检查失败,跳过处理
+   - 不调用 /api/oauth/callback
+
+结果:只调用一次 ✅
+```
+
+### 使用 useState 的错误流程
+
+```
+1. 第一次渲染
+   - isProcessing = false
+   - 检查通过,开始处理
+   - setIsProcessing(true)
+   - 调用 /api/oauth/callback (第一次)
+
+2. isProcessing 变化触发 useEffect 重新运行
+   - isProcessing = true
+   - 检查失败,跳过... 等等!
+   
+3. StrictMode 触发第二次渲染
+   - isProcessing = false (重置)
+   - 检查通过,开始处理
+   - 调用 /api/oauth/callback (第二次) ❌
+
+结果:调用两次 ❌
+```
+
+## 测试验证
+
+### 开发环境测试
+
+1. 启动前端:`cd web && yarn start lq_label`
+2. 访问:`http://localhost:4200/login`
+3. 点击"使用 SSO 登录"
+4. 完成 OAuth 登录
+5. 查看浏览器控制台:
+
+```
+OAuth callback URL parameters: {code: "...", state: "..."}
+Starting OAuth callback processing...
+Code: NhbmBfgF4XMJcsAlykbs2GOAec_0ylqu9JcAKvCcffA
+State: O_Fo8TTBRAHdTxw4GTZD_ZmvAz0fD1KnQIo4TE337fc
+Exchanging code for tokens...
+Already processing, skipping duplicate call  ← 第二次调用被阻止
+Token exchange successful
+```
+
+6. 查看后端日志:
+
+```
+INFO:     127.0.0.1:xxxxx - "GET /api/oauth/callback?code=...&state=... HTTP/1.1" 200 OK
+```
+
+**只有一次请求!** ✅
+
+### 生产环境
+
+在生产环境中,StrictMode 会被禁用,所以不会有重复调用问题。但保留这个修复不会有任何负面影响。
+
+## 其他解决方案(不推荐)
+
+### 方案 1:禁用 StrictMode(不推荐)
+
+```tsx
+// main.tsx
+// ❌ 不推荐:失去 StrictMode 的开发时检查
+<BrowserRouter>
+  <App />
+</BrowserRouter>
+```
+
+**缺点**:失去 React 18 的开发时检查和警告。
+
+### 方案 2:使用 AbortController(过度设计)
+
+```tsx
+useEffect(() => {
+  const controller = new AbortController();
+  
+  const processCallback = async () => {
+    try {
+      const response = await fetch('/api/oauth/callback', {
+        signal: controller.signal
+      });
+      // ...
+    } catch (err) {
+      if (err.name === 'AbortError') return;
+      // ...
+    }
+  };
+  
+  processCallback();
+  
+  return () => controller.abort();
+}, [searchParams]);
+```
+
+**缺点**:更复杂,且第一次请求仍然会被发送(只是被取消)。
+
+## 总结
+
+- ✅ **使用 `useRef`** 跟踪处理状态
+- ✅ **不要将 ref 添加到依赖数组**
+- ✅ **保留 StrictMode** 以获得开发时检查
+- ✅ **添加日志** 以便调试
+
+这个修复简单、有效,且不影响生产环境的行为。
+
+---
+
+**修复日期**: 2024-01-22  
+**文件**: `web/apps/lq_label/src/components/oauth-callback/oauth-callback.tsx`  
+**状态**: ✅ 已修复

+ 0 - 0
AUTHENTICATION_TEST_GUIDE.md → OAUTH_INTEGRATION_COMPLETE.md


+ 250 - 0
OAUTH_INTEGRATION_GUIDE.md

@@ -0,0 +1,250 @@
+# OAuth 2.0 单点登录对接方案
+
+## 概述
+
+本文档详细说明如何将标注平台与 OAuth 2.0 认证中心集成,实现单点登录(SSO)功能。
+
+## OAuth 2.0 授权码模式流程
+
+```
+┌─────────┐                                           ┌──────────────┐
+│         │                                           │              │
+│  用户   │                                           │  标注平台    │
+│         │                                           │  (Client)    │
+└────┬────┘                                           └──────┬───────┘
+     │                                                       │
+     │  1. 访问标注平台                                      │
+     ├──────────────────────────────────────────────────────>│
+     │                                                       │
+     │  2. 重定向到 OAuth 登录页                             │
+     │<──────────────────────────────────────────────────────┤
+     │                                                       │
+     │                                                       │
+     │  ┌──────────────────────────────────────────┐        │
+     │  │  OAuth 认证中心                           │        │
+     │  │  (http://192.168.92.61:8000)             │        │
+     │  └──────────────────────────────────────────┘        │
+     │                                                       │
+     │  3. 用户登录并授权                                    │
+     ├──────────────────────────────────────────────────────>│
+     │                                                       │
+     │  4. 返回授权码 (code)                                 │
+     │<──────────────────────────────────────────────────────┤
+     │                                                       │
+     │  5. 携带授权码回调标注平台                            │
+     ├──────────────────────────────────────────────────────>│
+     │                                                       │
+     │                                                       │  6. 用授权码换取 token
+     │                                                       ├────────────────────>
+     │                                                       │                     OAuth
+     │                                                       │  7. 返回 access_token
+     │                                                       │<────────────────────
+     │                                                       │
+     │                                                       │  8. 获取用户信息
+     │                                                       ├────────────────────>
+     │                                                       │                     OAuth
+     │                                                       │  9. 返回用户信息
+     │                                                       │<────────────────────
+     │                                                       │
+     │  10. 登录成功,建立会话                               │
+     │<──────────────────────────────────────────────────────┤
+     │                                                       │
+```
+
+## OAuth 认证中心信息
+
+### 基础配置
+
+- **OAuth 服务地址**: `http://192.168.92.61:8000`
+- **授权端点**: `http://192.168.92.61:8000/oauth/authorize`
+- **令牌端点**: `http://192.168.92.61:8000/oauth/token`
+- **用户信息端点**: `http://192.168.92.61:8000/oauth/userinfo`
+- **撤销端点**: `http://192.168.92.61:8000/oauth/revoke`
+
+### 应用配置(待提供)
+
+- **Client ID (应用Key)**: `待提供`
+- **Client Secret (应用密钥)**: `待提供`
+- **回调 URL**: `http://localhost:4200/auth/callback` (开发环境)
+- **回调 URL**: `http://192.168.92.61:8100/auth/callback` (生产环境)
+
+### 授权参数
+
+```
+response_type: code              # 授权码模式
+client_id: <YOUR_CLIENT_ID>      # 应用标识
+redirect_uri: <YOUR_CALLBACK>    # 回调地址
+scope: profile email             # 请求的权限范围
+state: <RANDOM_STRING>           # 防CSRF攻击的随机字符串
+```
+
+## 后端实现方案
+
+### 1. 环境配置
+
+在 `backend/.env` 中添加 OAuth 配置:
+
+```env
+# OAuth 2.0 配置
+OAUTH_ENABLED=true
+OAUTH_BASE_URL=http://192.168.92.61:8000
+OAUTH_CLIENT_ID=<待提供的Client ID>
+OAUTH_CLIENT_SECRET=<待提供的Client Secret>
+OAUTH_REDIRECT_URI=http://localhost:4200/auth/callback
+OAUTH_SCOPE=profile email
+```
+
+### 2. 更新配置模块
+
+修改 `backend/config.py`:
+
+```python
+from pydantic_settings import BaseSettings
+from typing import Optional
+
+class Settings(BaseSettings):
+    # ... 现有配置 ...
+    
+    # OAuth 2.0 配置
+    OAUTH_ENABLED: bool = False
+    OAUTH_BASE_URL: str = "http://192.168.92.61:8000"
+    OAUTH_CLIENT_ID: str = ""
+    OAUTH_CLIENT_SECRET: str = ""
+    OAUTH_REDIRECT_URI: str = "http://localhost:4200/auth/callback"
+    OAUTH_SCOPE: str = "profile email"
+    
+    class Config:
+        env_file = ".env"
+        case_sensitive = True
+
+settings = Settings()
+```
+
+### 3. 创建 OAuth 服务
+
+创建 `backend/services/oauth_service.py`:
+
+```python
+"""
+OAuth 2.0 认证服务
+"""
+import httpx
+import secrets
+from typing import Dict, Any, Optional
+from backend.config import settings
+from backend.models import User
+from backend.database import get_db_connection
+from datetime import datetime
+
+class OAuthService:
+    """OAuth 认证服务"""
+    
+    @staticmethod
+    def generate_state() -> str:
+        """生成随机 state 参数"""
+        return secrets.token_urlsafe(32)
+    
+    @staticmethod
+    def get_authorization_url(state: str) -> str:
+        """
+        构建授权 URL
+        
+        Args:
+            state: 防CSRF的随机字符串
+            
+        Returns:
+            完整的授权URL
+        """
+        from urllib.parse import urlencode
+        
+        params = {
+            "response_type": "code",
+            "client_id": settings.OAUTH_CLIENT_ID,
+            "redirect_uri": settings.OAUTH_REDIRECT_URI,
+            "scope": settings.OAUTH_SCOPE,
+            "state": state
+        }
+        
+        return f"{settings.OAUTH_BASE_URL}/oauth/authorize?{urlencode(params)}"
+    
+    @staticmethod
+    async def exchange_code_for_token(code: str) -> Dict[str, Any]:
+        """
+        用授权码换取访问令牌
+        
+        Args:
+            code: 授权码
+            
+        Returns:
+            令牌信息字典
+        """
+        async with httpx.AsyncClient() as client:
+            response = await client.post(
+                f"{settings.OAUTH_BASE_URL}/oauth/token",
+                data={
+                    "grant_type": "authorization_code",
+                    "code": code,
+                    "redirect_uri": settings.OAUTH_REDIRECT_URI,
+                    "client_id": settings.OAUTH_CLIENT_ID,
+                    "client_secret": settings.OAUTH_CLIENT_SECRET
+                }
+            )
+            
+            if response.status_code != 200:
+                raise Exception(f"令牌交换失败: {response.text}")
+            
+            data = response.json()
+            
+            # 处理不同的响应格式
+            if "access_token" in data:
+                return data
+            elif data.get("code") == 0 and "data" in data:
+                return data["data"]
+            else:
+                raise Exception(f"无效的令牌响应格式: {data}")
+    
+    @staticmethod
+    async def get_user_info(access_token: str) -> Dict[str, Any]:
+        """
+        获取用户信息
+        
+        Args:
+            access_token: 访问令牌
+            
+        Returns:
+            用户信息字典
+        """
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                f"{settings.OAUTH_BASE_URL}/oauth/userinfo",
+                headers={"Authorization": f"Bearer {access_token}"}
+            )
+            
+            if response.status_code != 200:
+                raise Exception(f"获取用户信息失败: {response.text}")
+            
+            data = response.json()
+            
+            # 处理不同的响应格式
+            if "sub" in data:
+                return data
+            elif data.get("code") == 0 and "data" in data:
+                return data["data"]
+            else:
+                raise Exception(f"无效的用户信息响应格式: {data}")
+    
+    @staticmethod
+    async def sync_user_from_oauth(oauth_user_info: Dict[str, Any]) -> User:
+        """
+        从 OAuth 用户信息同步到本地数据库
+        
+        Args:
+            oauth_user_info: OAuth 返回的用户信息
+            
+        Returns:
+            本地用户对象
+        """
+        with get_db_connection() as conn:
+            cursor = conn.cursor()
+            
+            # 提取

+ 331 - 0
OAUTH_LOGIN_TEST_GUIDE.md

@@ -0,0 +1,331 @@
+# OAuth 单点登录测试指南
+
+## 概述
+
+OAuth 2.0 单点登录已成功集成到标注平台!现在可以使用 SSO 认证中心进行登录。
+
+## 配置信息
+
+### 后端配置 (`backend/config.yaml`)
+
+```yaml
+oauth:
+  enabled: true
+  base_url: "http://192.168.92.61:8000"
+  client_id: "sRyfcQwNVoFimigzuuZxhqd36fPkVN5G"
+  client_secret: "96RuKb4obAn9bQ9i5NtINiKBMvF_9uuCR7eNzD9dWQMbOWZaV3P593-8yLOqzWRd"
+  redirect_uri: "http://localhost:4200/auth/callback"
+  scope: "profile email"
+```
+
+### OAuth 端点
+
+- **授权端点**: `http://192.168.92.61:8000/oauth/authorize`
+- **令牌端点**: `http://192.168.92.61:8000/oauth/token`
+- **用户信息端点**: `http://192.168.92.61:8000/oauth/userinfo`
+
+## 测试步骤
+
+### 1. 启动服务
+
+**后端**:
+```bash
+cd backend
+python main.py
+```
+
+**前端**:
+```bash
+cd web
+yarn start lq_label
+```
+
+### 2. 访问登录页面
+
+打开浏览器访问: `http://localhost:4200/login`
+
+### 3. 使用 OAuth 登录
+
+1. 点击 **"使用 SSO 登录"** 按钮
+2. 浏览器会重定向到 OAuth 认证中心 (`http://192.168.92.61:8000`)
+3. 在 SSO 登录页面输入用户名和密码
+4. 授权后,浏览器会重定向回标注平台 (`http://localhost:4200/auth/callback`)
+5. 系统自动完成登录,跳转到首页
+
+### 4. 验证登录状态
+
+登录成功后,你应该能看到:
+- 右上角显示用户名和头像
+- 可以访问所有受保护的页面(项目、任务、标注等)
+- 用户菜单显示用户信息和"退出登录"按钮
+
+## OAuth 登录流程
+
+```
+1. 用户点击"使用 SSO 登录"
+   ↓
+2. 前端调用 /api/oauth/login 获取授权 URL 和 state
+   ↓
+3. 前端保存 state 到 sessionStorage
+   ↓
+4. 前端重定向到 OAuth 授权页面
+   ↓
+5. 用户在 SSO 登录并授权
+   ↓
+6. OAuth 重定向回 /auth/callback?code=xxx&state=yyy
+   ↓
+7. 前端验证 state 参数
+   ↓
+8. 前端调用 /api/oauth/callback 用 code 换取 token
+   ↓
+9. 后端用 code 向 OAuth 换取 access_token
+   ↓
+10. 后端用 access_token 获取用户信息
+   ↓
+11. 后端同步用户到本地数据库
+   ↓
+12. 后端生成本地 JWT tokens
+   ↓
+13. 前端保存 tokens 和用户信息
+   ↓
+14. 前端跳转到首页
+```
+
+## API 端点
+
+### 后端 OAuth API
+
+| 端点 | 方法 | 描述 |
+|------|------|------|
+| `/api/oauth/login` | GET | 获取授权 URL 和 state |
+| `/api/oauth/callback` | GET | 处理 OAuth 回调,换取 token |
+| `/api/oauth/status` | GET | 获取 OAuth 配置状态 |
+
+### 示例请求
+
+**获取授权 URL**:
+```bash
+curl http://localhost:8000/api/oauth/login
+```
+
+**响应**:
+```json
+{
+  "authorization_url": "http://192.168.92.61:8000/oauth/authorize?response_type=code&client_id=sRyfcQwNVoFimigzuuZxhqd36fPkVN5G&redirect_uri=http://localhost:4200/auth/callback&scope=profile+email&state=xxx",
+  "state": "xxx"
+}
+```
+
+**处理回调**:
+```bash
+curl "http://localhost:8000/api/oauth/callback?code=xxx&state=yyy"
+```
+
+**响应**:
+```json
+{
+  "access_token": "eyJ...",
+  "refresh_token": "eyJ...",
+  "token_type": "bearer",
+  "user": {
+    "id": "user_xxx",
+    "username": "testuser",
+    "email": "test@example.com",
+    "role": "annotator",
+    "created_at": "2024-01-22T..."
+  }
+}
+```
+
+## 用户同步逻辑
+
+### 首次登录
+
+1. OAuth 返回用户信息(包含 `sub` 或 `id` 字段)
+2. 系统检查本地数据库是否存在该 OAuth 用户
+3. 如果不存在,创建新用户记录:
+   - `oauth_provider`: "sso"
+   - `oauth_id`: OAuth 用户 ID
+   - `username`: OAuth 用户名
+   - `email`: OAuth 邮箱
+   - `role`: "annotator"(默认)
+   - `password_hash`: ""(OAuth 用户不需要密码)
+
+### 再次登录
+
+1. 系统根据 `oauth_provider` 和 `oauth_id` 查找用户
+2. 更新用户名和邮箱(如果有变化)
+3. 返回现有用户信息
+
+## 角色管理
+
+**当前实现**:
+- 所有 OAuth 用户默认角色为 `annotator`(标注员)
+- SSO 暂时未提供角色信息
+
+**未来扩展**:
+- 当 SSO 提供角色信息后,可以从 OAuth 用户信息中读取角色
+- 支持 `admin` 和 `annotator` 两种角色
+- 管理员可以删除项目和任务
+
+## 安全特性
+
+### State 参数验证
+
+- 前端生成随机 state 参数并保存到 sessionStorage
+- OAuth 回调时验证 state 参数是否匹配
+- 防止 CSRF 攻击
+
+### Token 安全
+
+- OAuth access_token 仅用于获取用户信息
+- 系统生成独立的 JWT tokens 用于后续 API 调用
+- JWT tokens 有过期时间(access: 15分钟,refresh: 7天)
+
+### 用户隔离
+
+- 每个 OAuth 用户在本地数据库有独立记录
+- 用户只能访问自己的标注数据
+- 管理员可以访问所有数据
+
+## 故障排除
+
+### 问题 1: "无效的客户端ID" 错误
+
+**原因**: OAuth 服务器上的应用 ID 配置不正确
+
+**解决方案**:
+- 确认 `backend/config.yaml` 中的 `client_id` 是正确的
+- 当前正确的 ID:`sRyfcQwNVoFimigzuuZxhqd36fPkVN5G`
+- 确认 OAuth 服务器上已创建该应用
+
+### 问题 2: "无效的重定向URI" 错误
+
+**原因**: 回调 URL 未在 OAuth 服务器上配置
+
+**解决方案**:
+- 在 OAuth 服务器上添加回调 URL:`http://localhost:4200/auth/callback`
+- 确保 URL 完全匹配(包括协议、端口、路径)
+
+### 问题 3: "授权码已被使用" 错误
+
+**原因**: React StrictMode 在开发环境下会导致组件渲染两次,授权码被使用两次
+
+**解决方案**:
+- 已在 `oauth-callback.tsx` 中添加防重复调用逻辑(使用 `isProcessing` 状态)
+- 生产环境不会有此问题(StrictMode 仅在开发环境启用)
+
+### 问题 4: Client ID 和 Secret 配置错误
+
+**症状**: 配置文件中 `client_id` 和 `client_secret` 的值搞反了
+
+**正确配置**:
+```yaml
+oauth:
+  client_id: "sRyfcQwNVoFimigzuuZxhqd36fPkVN5G"
+  client_secret: "96RuKb4obAn9bQ9i5NtINiKBMvF_9uuCR7eNzD9dWQMbOWZaV3P593-8yLOqzWRd"
+```
+
+### 问题 5: 点击"使用 SSO 登录"没有反应
+
+**检查**:
+1. 打开浏览器控制台查看错误
+2. 确认后端 OAuth 配置正确
+3. 确认 OAuth 服务可访问
+
+### 问题 6: 重定向到 OAuth 后无法返回
+
+**检查**:
+1. 确认 `redirect_uri` 配置正确
+2. 确认 OAuth 中心配置了正确的回调 URL
+3. 检查浏览器控制台错误
+
+### 问题 7: 回调后显示"登录失败"
+
+**检查**:
+1. 查看浏览器控制台错误信息
+2. 查看后端日志
+3. 确认 OAuth 令牌交换成功
+4. 确认用户信息获取成功
+
+### 问题 8: State 参数不匹配
+
+**原因**: sessionStorage 被清除或浏览器不支持
+
+**解决方案**:
+1. 不要在登录过程中清除浏览器缓存
+2. 确保浏览器支持 sessionStorage
+3. 检查是否有浏览器扩展干扰
+
+## 测试账号
+
+请使用 SSO 认证中心提供的测试账号进行测试。
+
+## 开发调试
+
+### 查看 OAuth 配置状态
+
+```bash
+curl http://localhost:8000/api/oauth/status
+```
+
+### 查看用户数据库
+
+```bash
+cd backend
+sqlite3 annotation_platform.db
+SELECT * FROM users WHERE oauth_provider = 'sso';
+```
+
+### 清除 OAuth 用户
+
+```bash
+cd backend
+sqlite3 annotation_platform.db
+DELETE FROM users WHERE oauth_provider = 'sso';
+```
+
+## 生产环境配置
+
+### 更新回调 URL
+
+修改 `backend/config.yaml`:
+
+```yaml
+oauth:
+  redirect_uri: "http://your-production-domain.com/auth/callback"
+```
+
+### 更新前端 CORS
+
+修改 `backend/main.py`:
+
+```python
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=[
+        "http://your-production-domain.com",
+    ],
+    ...
+)
+```
+
+## 总结
+
+OAuth 单点登录已成功集成!主要特性:
+
+- ✅ 支持 OAuth 2.0 授权码模式
+- ✅ 自动用户同步和创建
+- ✅ State 参数防 CSRF 攻击
+- ✅ 独立的 JWT token 管理
+- ✅ 用户角色支持(默认 annotator)
+- ✅ 与现有认证系统兼容
+- ✅ 支持传统用户名密码登录
+
+现在可以使用 SSO 登录标注平台了!🎉
+
+---
+
+**实现日期**: 2024-01-22  
+**状态**: ✅ 完成  
+**文档版本**: 1.0

+ 51 - 38
backend/config.py

@@ -1,53 +1,66 @@
 """
 Application configuration module.
-Manages JWT and OAuth settings using environment variables.
+Manages JWT and OAuth settings from YAML configuration file.
 """
 import os
 import secrets
 import logging
-from pydantic_settings import BaseSettings
+import yaml
+from pathlib import Path
+from typing import Dict, Any
 
 logger = logging.getLogger(__name__)
 
 
-class Settings(BaseSettings):
-    """Application settings loaded from environment variables."""
+class Settings:
+    """Application settings loaded from config.yaml."""
     
-    # JWT Settings
-    JWT_SECRET_KEY: str = os.getenv(
-        "JWT_SECRET_KEY",
-        secrets.token_urlsafe(32)
-    )
-    JWT_ALGORITHM: str = os.getenv("JWT_ALGORITHM", "HS256")
-    ACCESS_TOKEN_EXPIRE_MINUTES: int = int(
-        os.getenv("ACCESS_TOKEN_EXPIRE_MINUTES", "15")
-    )
-    REFRESH_TOKEN_EXPIRE_DAYS: int = int(
-        os.getenv("REFRESH_TOKEN_EXPIRE_DAYS", "7")
-    )
-    
-    # Database Settings
-    DATABASE_PATH: str = os.getenv(
-        "DATABASE_PATH",
-        "annotation_platform.db"
-    )
-    
-    # OAuth Settings (预留,未来使用)
-    OAUTH_ENABLED: bool = os.getenv("OAUTH_ENABLED", "false").lower() == "true"
-    OAUTH_PROVIDER_URL: str = os.getenv("OAUTH_PROVIDER_URL", "")
-    OAUTH_CLIENT_ID: str = os.getenv("OAUTH_CLIENT_ID", "")
-    OAUTH_CLIENT_SECRET: str = os.getenv("OAUTH_CLIENT_SECRET", "")
-    
-    class Config:
-        env_file = ".env"
-        case_sensitive = True
+    def __init__(self):
+        """Load configuration from YAML file."""
+        config_path = Path(__file__).parent / "config.yaml"
+        
+        if not config_path.exists():
+            raise FileNotFoundError(f"配置文件不存在: {config_path}")
+        
+        with open(config_path, 'r', encoding='utf-8') as f:
+            config = yaml.safe_load(f)
+        
+        # JWT Settings
+        jwt_config = config.get('jwt', {})
+        self.JWT_SECRET_KEY = jwt_config.get('secret_key', secrets.token_urlsafe(32))
+        self.JWT_ALGORITHM = jwt_config.get('algorithm', 'HS256')
+        self.ACCESS_TOKEN_EXPIRE_MINUTES = jwt_config.get('access_token_expire_minutes', 15)
+        self.REFRESH_TOKEN_EXPIRE_DAYS = jwt_config.get('refresh_token_expire_days', 7)
+        
+        # Database Settings
+        db_config = config.get('database', {})
+        self.DATABASE_PATH = db_config.get('path', 'annotation_platform.db')
+        
+        # OAuth Settings
+        oauth_config = config.get('oauth', {})
+        self.OAUTH_ENABLED = oauth_config.get('enabled', False)
+        self.OAUTH_BASE_URL = oauth_config.get('base_url', '')
+        self.OAUTH_CLIENT_ID = oauth_config.get('client_id', '')
+        self.OAUTH_CLIENT_SECRET = oauth_config.get('client_secret', '')
+        self.OAUTH_REDIRECT_URI = oauth_config.get('redirect_uri', '')
+        self.OAUTH_SCOPE = oauth_config.get('scope', 'profile email')
+        
+        # OAuth Endpoints
+        self.OAUTH_AUTHORIZE_ENDPOINT = oauth_config.get('authorize_endpoint', '/oauth/authorize')
+        self.OAUTH_TOKEN_ENDPOINT = oauth_config.get('token_endpoint', '/oauth/token')
+        self.OAUTH_USERINFO_ENDPOINT = oauth_config.get('userinfo_endpoint', '/oauth/userinfo')
+        self.OAUTH_REVOKE_ENDPOINT = oauth_config.get('revoke_endpoint', '/oauth/revoke')
+        
+        # Server Settings
+        server_config = config.get('server', {})
+        self.SERVER_HOST = server_config.get('host', '0.0.0.0')
+        self.SERVER_PORT = server_config.get('port', 8000)
+        self.SERVER_RELOAD = server_config.get('reload', True)
+        
+        # Warn if using default JWT secret
+        if self.JWT_SECRET_KEY == 'your-secret-key-here':
+            logger.warning("使用默认 JWT_SECRET_KEY,生产环境请修改 config.yaml!")
 
 
 # Create settings instance
 settings = Settings()
-
-# Warn if using default JWT secret
-if not os.getenv("JWT_SECRET_KEY"):
-    logger.warning(
-        "使用默认生成的 JWT_SECRET_KEY,生产环境请设置环境变量!"
-    )

+ 33 - 0
backend/config.yaml

@@ -0,0 +1,33 @@
+# 标注平台配置文件
+
+# JWT 配置
+jwt:
+  secret_key: "your-secret-key-here"  # 生产环境请修改
+  algorithm: "HS256"
+  access_token_expire_minutes: 15
+  refresh_token_expire_days: 7
+
+# OAuth 2.0 单点登录配置
+oauth:
+  enabled: true
+  base_url: "http://192.168.92.61:8000"
+  client_id: "sRyfcQwNVoFimigzuuZxhqd36fPkVN5G"
+  client_secret: "96RuKb4obAn9bQ9i5NtINiKBMvF_9uuCR7eNzD9dWQMbOWZaV3P593-8yLOqzWRd"
+  redirect_uri: "http://localhost:4200/auth/callback"
+  scope: "profile email"
+  
+  # OAuth 端点
+  authorize_endpoint: "/oauth/login"
+  token_endpoint: "/oauth/token"
+  userinfo_endpoint: "/oauth/userinfo"
+  revoke_endpoint: "/oauth/revoke"
+
+# 数据库配置
+database:
+  path: "annotation_platform.db"
+
+# 服务器配置
+server:
+  host: "0.0.0.0"
+  port: 8000
+  reload: true

+ 2 - 1
backend/main.py

@@ -6,7 +6,7 @@ from fastapi import FastAPI
 from fastapi.middleware.cors import CORSMiddleware
 from contextlib import asynccontextmanager
 from database import init_database
-from routers import project, task, annotation, auth
+from routers import project, task, annotation, auth, oauth
 from middleware.auth_middleware import AuthMiddleware
 
 
@@ -47,6 +47,7 @@ app.add_middleware(AuthMiddleware)
 
 # Include routers
 app.include_router(auth.router)
+app.include_router(oauth.router)
 app.include_router(project.router)
 app.include_router(task.router)
 app.include_router(annotation.router)

+ 3 - 0
backend/middleware/auth_middleware.py

@@ -25,6 +25,9 @@ class AuthMiddleware(BaseHTTPMiddleware):
         "/api/auth/register",
         "/api/auth/login",
         "/api/auth/refresh",
+        "/api/oauth/status",
+        "/api/oauth/login",
+        "/api/oauth/callback"
     }
     
     async def dispatch(self, request: Request, call_next):

+ 1 - 0
backend/requirements.txt

@@ -13,3 +13,4 @@ httpx==0.26.0
 hypothesis==6.92.1
 faker==20.1.0
 requests==2.31.0
+PyYAML==6.0.1

+ 2 - 2
backend/routers/__init__.py

@@ -2,6 +2,6 @@
 API routers package.
 Exports all API routers for the application.
 """
-from . import project, task, annotation, auth
+from . import project, task, annotation, auth, oauth
 
-__all__ = ["project", "task", "annotation", "auth"]
+__all__ = ["project", "task", "annotation", "auth", "oauth"]

二進制
backend/routers/__pycache__/__init__.cpython-311.pyc


+ 127 - 0
backend/routers/oauth.py

@@ -0,0 +1,127 @@
+"""
+OAuth 2.0 认证路由
+处理 OAuth 登录流程
+"""
+from fastapi import APIRouter, HTTPException, Query
+from fastapi.responses import RedirectResponse
+from pydantic import BaseModel
+from typing import Optional
+from config import settings
+from services.oauth_service import OAuthService
+from services.jwt_service import JWTService
+from schemas.auth import TokenResponse, UserResponse
+
+router = APIRouter(prefix="/api/oauth", tags=["oauth"])
+
+
+class OAuthLoginResponse(BaseModel):
+    """OAuth 登录响应"""
+    authorization_url: str
+    state: str
+
+
+@router.get("/login", response_model=OAuthLoginResponse)
+async def oauth_login():
+    """
+    启动 OAuth 登录流程
+    
+    生成授权 URL 和 state 参数,前端需要保存 state 并重定向到授权 URL
+    
+    Returns:
+        包含授权 URL 和 state 的响应
+    """
+    if not settings.OAUTH_ENABLED:
+        raise HTTPException(status_code=400, detail="OAuth 登录未启用")
+    
+    # 生成 state 参数
+    state = OAuthService.generate_state()
+    
+    # 构建授权 URL
+    authorization_url = OAuthService.get_authorization_url(state)
+    
+    return OAuthLoginResponse(
+        authorization_url=authorization_url,
+        state=state
+    )
+
+
+@router.get("/callback", response_model=TokenResponse)
+async def oauth_callback(
+    code: str = Query(..., description="OAuth 授权码"),
+    state: str = Query(..., description="State 参数"),
+):
+    """
+    OAuth 回调端点
+    
+    处理 OAuth 认证中心的回调,用授权码换取令牌,获取用户信息,
+    并创建或更新本地用户记录
+    
+    Args:
+        code: OAuth 授权码
+        state: State 参数(前端需要验证)
+        
+    Returns:
+        JWT tokens 和用户信息
+    """
+    if not settings.OAUTH_ENABLED:
+        raise HTTPException(status_code=400, detail="OAuth 登录未启用")
+    
+    try:
+        # 1. 用授权码换取访问令牌
+        token_data = await OAuthService.exchange_code_for_token(code)
+        access_token = token_data.get("access_token")
+        
+        if not access_token:
+            raise HTTPException(status_code=400, detail="未能获取访问令牌")
+        
+        # 2. 使用访问令牌获取用户信息
+        oauth_user_info = await OAuthService.get_user_info(access_token)
+        
+        # 3. 同步用户到本地数据库
+        user = OAuthService.sync_user_from_oauth(oauth_user_info)
+        
+        # 4. 生成本地 JWT tokens
+        user_data = {
+            "id": user.id,
+            "username": user.username,
+            "email": user.email,
+            "role": user.role
+        }
+        
+        jwt_access_token = JWTService.create_access_token(user_data)
+        jwt_refresh_token = JWTService.create_refresh_token(user_data)
+        
+        # 5. 返回 tokens 和用户信息
+        return TokenResponse(
+            access_token=jwt_access_token,
+            refresh_token=jwt_refresh_token,
+            token_type="bearer",
+            user=UserResponse(
+                id=user.id,
+                username=user.username,
+                email=user.email,
+                role=user.role,
+                created_at=user.created_at
+            )
+        )
+        
+    except Exception as e:
+        raise HTTPException(
+            status_code=400,
+            detail=f"OAuth 登录失败: {str(e)}"
+        )
+
+
+@router.get("/status")
+async def oauth_status():
+    """
+    获取 OAuth 配置状态
+    
+    Returns:
+        OAuth 是否启用及相关配置信息
+    """
+    return {
+        "enabled": settings.OAUTH_ENABLED,
+        "provider": "SSO" if settings.OAUTH_ENABLED else None,
+        "base_url": settings.OAUTH_BASE_URL if settings.OAUTH_ENABLED else None
+    }

+ 0 - 0
backend/init_image_annotation_data.py → backend/script/init_image_annotation_data.py


+ 0 - 0
backend/init_sample_data.py → backend/script/init_sample_data.py


+ 0 - 0
backend/test_annotation.py → backend/script/test_annotation.py


+ 0 - 0
backend/test_auth_flow.ps1 → backend/script/test_auth_flow.ps1


+ 206 - 0
backend/services/oauth_service.py

@@ -0,0 +1,206 @@
+"""
+OAuth 2.0 认证服务
+处理与 OAuth 认证中心的交互
+"""
+import httpx
+import secrets
+from typing import Dict, Any, Optional
+from datetime import datetime
+from config import settings
+from models import User
+from database import get_db_connection
+
+
+class OAuthService:
+    """OAuth 2.0 认证服务"""
+    
+    @staticmethod
+    def generate_state() -> str:
+        """
+        生成随机 state 参数,用于防止 CSRF 攻击
+        
+        Returns:
+            随机字符串
+        """
+        return secrets.token_urlsafe(32)
+    
+    @staticmethod
+    def get_authorization_url(state: str) -> str:
+        """
+        构建 OAuth 授权 URL
+        
+        Args:
+            state: 防CSRF的随机字符串
+            
+        Returns:
+            完整的授权URL
+        """
+        from urllib.parse import urlencode
+        
+        params = {
+            "response_type": "code",
+            "client_id": settings.OAUTH_CLIENT_ID,
+            "redirect_uri": settings.OAUTH_REDIRECT_URI,
+            "scope": settings.OAUTH_SCOPE,
+            "state": state
+        }
+        
+        authorize_url = f"{settings.OAUTH_BASE_URL}{settings.OAUTH_AUTHORIZE_ENDPOINT}"
+        return f"{authorize_url}?{urlencode(params)}"
+    
+    @staticmethod
+    async def exchange_code_for_token(code: str) -> Dict[str, Any]:
+        """
+        用授权码换取访问令牌
+        
+        Args:
+            code: OAuth 授权码
+            
+        Returns:
+            令牌信息字典,包含 access_token, token_type, expires_in 等
+            
+        Raises:
+            Exception: 令牌交换失败
+        """
+        token_url = f"{settings.OAUTH_BASE_URL}{settings.OAUTH_TOKEN_ENDPOINT}"
+        
+        async with httpx.AsyncClient() as client:
+            response = await client.post(
+                token_url,
+                data={
+                    "grant_type": "authorization_code",
+                    "code": code,
+                    "redirect_uri": settings.OAUTH_REDIRECT_URI,
+                    "client_id": settings.OAUTH_CLIENT_ID,
+                    "client_secret": settings.OAUTH_CLIENT_SECRET
+                },
+                headers={"Content-Type": "application/x-www-form-urlencoded"}
+            )
+            
+            if response.status_code != 200:
+                raise Exception(f"令牌交换失败 ({response.status_code}): {response.text}")
+            
+            data = response.json()
+            
+            # 处理不同的响应格式
+            if "access_token" in data:
+                return data
+            elif data.get("code") == 0 and "data" in data:
+                return data["data"]
+            else:
+                raise Exception(f"无效的令牌响应格式: {data}")
+    
+    @staticmethod
+    async def get_user_info(access_token: str) -> Dict[str, Any]:
+        """
+        使用访问令牌获取用户信息
+        
+        Args:
+            access_token: OAuth 访问令牌
+            
+        Returns:
+            用户信息字典
+            
+        Raises:
+            Exception: 获取用户信息失败
+        """
+        userinfo_url = f"{settings.OAUTH_BASE_URL}{settings.OAUTH_USERINFO_ENDPOINT}"
+        
+        async with httpx.AsyncClient() as client:
+            response = await client.get(
+                userinfo_url,
+                headers={"Authorization": f"Bearer {access_token}"}
+            )
+            
+            if response.status_code != 200:
+                raise Exception(f"获取用户信息失败 ({response.status_code}): {response.text}")
+            
+            data = response.json()
+            
+            # 处理不同的响应格式
+            if "sub" in data or "id" in data:
+                return data
+            elif data.get("code") == 0 and "data" in data:
+                return data["data"]
+            else:
+                raise Exception(f"无效的用户信息响应格式: {data}")
+    
+    @staticmethod
+    def sync_user_from_oauth(oauth_user_info: Dict[str, Any]) -> User:
+        """
+        从 OAuth 用户信息同步到本地数据库
+        如果用户不存在则创建,如果存在则更新
+        
+        Args:
+            oauth_user_info: OAuth 返回的用户信息
+            
+        Returns:
+            本地用户对象
+        """
+        with get_db_connection() as conn:
+            cursor = conn.cursor()
+            
+            # 提取用户信息(兼容不同的字段名)
+            oauth_id = oauth_user_info.get("sub") or oauth_user_info.get("id")
+            username = oauth_user_info.get("username") or oauth_user_info.get("preferred_username") or oauth_user_info.get("name")
+            email = oauth_user_info.get("email", "")
+            
+            if not oauth_id:
+                raise ValueError("OAuth 用户信息缺少 ID 字段")
+            
+            if not username:
+                raise ValueError("OAuth 用户信息缺少用户名字段")
+            
+            # 查找是否已存在该 OAuth 用户
+            cursor.execute(
+                "SELECT * FROM users WHERE oauth_provider = ? AND oauth_id = ?",
+                ("sso", oauth_id)
+            )
+            row = cursor.fetchone()
+            
+            if row:
+                # 用户已存在,更新信息
+                user = User.from_row(row)
+                
+                # 更新用户名和邮箱(如果有变化)
+                cursor.execute("""
+                    UPDATE users 
+                    SET username = ?, email = ?
+                    WHERE id = ?
+                """, (username, email, user.id))
+                
+                conn.commit()
+                
+                # 重新查询更新后的用户
+                cursor.execute("SELECT * FROM users WHERE id = ?", (user.id,))
+                row = cursor.fetchone()
+                return User.from_row(row)
+            else:
+                # 新用户,创建记录
+                user_id = f"user_{datetime.now().strftime('%Y%m%d%H%M%S')}_{secrets.token_hex(4)}"
+                
+                # 暂时所有用户都是 annotator 角色(SSO 未提供角色信息)
+                role = "annotator"
+                
+                cursor.execute("""
+                    INSERT INTO users (
+                        id, username, email, password_hash, role,
+                        oauth_provider, oauth_id, created_at
+                    ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)
+                """, (
+                    user_id,
+                    username,
+                    email,
+                    "",  # OAuth 用户不需要密码
+                    role,
+                    "sso",
+                    oauth_id,
+                    datetime.now()
+                ))
+                
+                conn.commit()
+                
+                # 查询新创建的用户
+                cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))
+                row = cursor.fetchone()
+                return User.from_row(row)

+ 53 - 0
backend/test_oauth_config.py

@@ -0,0 +1,53 @@
+"""
+测试 OAuth 配置
+直接访问 OAuth 服务器验证配置
+"""
+import requests
+from config import settings
+
+def test_oauth_config():
+    """测试 OAuth 配置"""
+    print("=" * 60)
+    print("OAuth 配置测试")
+    print("=" * 60)
+    
+    print(f"\n1. OAuth 配置:")
+    print(f"   Base URL: {settings.OAUTH_BASE_URL}")
+    print(f"   Client ID: {settings.OAUTH_CLIENT_ID}")
+    print(f"   Client Secret: {settings.OAUTH_CLIENT_SECRET}")
+    print(f"   Redirect URI: {settings.OAUTH_REDIRECT_URI}")
+    print(f"   Authorize Endpoint: {settings.OAUTH_AUTHORIZE_ENDPOINT}")
+    
+    # 构建授权 URL
+    from urllib.parse import urlencode
+    params = {
+        "response_type": "code",
+        "client_id": settings.OAUTH_CLIENT_ID,
+        "redirect_uri": settings.OAUTH_REDIRECT_URI,
+        "scope": settings.OAUTH_SCOPE,
+        "state": "test_state_123"
+    }
+    
+    authorize_url = f"{settings.OAUTH_BASE_URL}{settings.OAUTH_AUTHORIZE_ENDPOINT}"
+    full_url = f"{authorize_url}?{urlencode(params)}"
+    
+    print(f"\n2. 授权 URL:")
+    print(f"   {full_url}")
+    
+    print(f"\n3. 测试 OAuth 服务器连接...")
+    try:
+        response = requests.get(settings.OAUTH_BASE_URL, timeout=5)
+        print(f"   ✓ OAuth 服务器可访问 (状态码: {response.status_code})")
+    except Exception as e:
+        print(f"   ✗ OAuth 服务器不可访问: {e}")
+    
+    print("\n" + "=" * 60)
+    print("请手动访问上面的授权 URL 测试登录流程")
+    print("如果返回 'invalid_client' 错误,请检查:")
+    print("1. Client ID 是否在 OAuth 服务器上注册")
+    print("2. Redirect URI 是否在 OAuth 服务器上配置")
+    print("3. Client Secret 是否正确")
+    print("=" * 60)
+
+if __name__ == "__main__":
+    test_oauth_config()

+ 100 - 0
backend/test_oauth_flow.py

@@ -0,0 +1,100 @@
+"""
+测试 OAuth 登录流程
+验证配置是否正确
+"""
+import requests
+import json
+
+BASE_URL = "http://localhost:8000"
+
+def test_oauth_status():
+    """测试 OAuth 状态端点"""
+    print("=" * 60)
+    print("测试 1: OAuth 状态")
+    print("=" * 60)
+    
+    response = requests.get(f"{BASE_URL}/api/oauth/status")
+    print(f"状态码: {response.status_code}")
+    print(f"响应: {json.dumps(response.json(), indent=2, ensure_ascii=False)}")
+    print()
+    
+    return response.status_code == 200
+
+
+def test_oauth_login():
+    """测试 OAuth 登录端点"""
+    print("=" * 60)
+    print("测试 2: OAuth 登录 URL 生成")
+    print("=" * 60)
+    
+    response = requests.get(f"{BASE_URL}/api/oauth/login")
+    print(f"状态码: {response.status_code}")
+    
+    if response.status_code == 200:
+        data = response.json()
+        print(f"授权 URL: {data['authorization_url']}")
+        print(f"State: {data['state']}")
+        print()
+        
+        # 验证 URL 包含正确的 client_id
+        if "sRyfcQwNVoFimigzuuZxhqd36fPkVN5G" in data['authorization_url']:
+            print("✅ Client ID 正确")
+        else:
+            print("❌ Client ID 错误")
+            
+        # 验证 URL 包含正确的 redirect_uri
+        if "http://localhost:4200/auth/callback" in data['authorization_url']:
+            print("✅ Redirect URI 正确")
+        else:
+            print("❌ Redirect URI 错误")
+            
+        return True
+    else:
+        print(f"错误: {response.text}")
+        return False
+
+
+def main():
+    """运行所有测试"""
+    print("\n🚀 开始测试 OAuth 配置...\n")
+    
+    tests = [
+        ("OAuth 状态", test_oauth_status),
+        ("OAuth 登录", test_oauth_login),
+    ]
+    
+    results = []
+    for name, test_func in tests:
+        try:
+            result = test_func()
+            results.append((name, result))
+        except Exception as e:
+            print(f"❌ 测试失败: {e}\n")
+            results.append((name, False))
+    
+    # 打印总结
+    print("=" * 60)
+    print("测试总结")
+    print("=" * 60)
+    
+    for name, result in results:
+        status = "✅ 通过" if result else "❌ 失败"
+        print(f"{name}: {status}")
+    
+    passed = sum(1 for _, result in results if result)
+    total = len(results)
+    print(f"\n总计: {passed}/{total} 测试通过")
+    
+    if passed == total:
+        print("\n🎉 所有测试通过!OAuth 配置正确。")
+        print("\n下一步:")
+        print("1. 在浏览器中访问: http://localhost:4200/login")
+        print("2. 点击 '使用 SSO 登录' 按钮")
+        print("3. 在 OAuth 登录页面输入用户名和密码")
+        print("4. 授权后应该自动登录到标注平台")
+    else:
+        print("\n⚠️ 部分测试失败,请检查配置。")
+
+
+if __name__ == "__main__":
+    main()

+ 1 - 0
subsystem-demo

@@ -0,0 +1 @@
+Subproject commit 628049aff41e9817dd5d55e9d324a62b7c3b400f

+ 2 - 0
web/apps/lq_label/src/app/app.tsx

@@ -5,6 +5,7 @@ import { ThemeProvider } from '../components/theme-provider';
 import { ProtectedRoute } from '../components/protected-route';
 import { LoginForm } from '../components/login-form';
 import { RegisterForm } from '../components/register-form';
+import { OAuthCallback } from '../components/oauth-callback';
 import { isAuthenticatedAtom } from '../atoms/auth-atoms';
 import {
   HomeView,
@@ -51,6 +52,7 @@ export function App() {
               isAuthenticated ? <Navigate to="/" replace /> : <RegisterForm />
             }
           />
+          <Route path="/auth/callback" element={<OAuthCallback />} />
 
           {/* Protected Routes - Require Authentication */}
           <Route

+ 53 - 0
web/apps/lq_label/src/components/login-form/login-form.module.scss

@@ -31,6 +31,59 @@
   text-align: center;
 }
 
+.oauthButton {
+  width: 100%;
+  padding: 0.875rem 1.5rem;
+  background: white;
+  color: #667eea;
+  border: 2px solid #667eea;
+  border-radius: 8px;
+  font-size: 1rem;
+  font-weight: 600;
+  cursor: pointer;
+  transition: all 0.2s;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 0.5rem;
+  margin-bottom: 1.5rem;
+
+  &:hover {
+    background: #667eea;
+    color: white;
+    transform: translateY(-2px);
+    box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
+  }
+
+  &:active {
+    transform: translateY(0);
+  }
+}
+
+.divider {
+  position: relative;
+  text-align: center;
+  margin: 1.5rem 0;
+
+  &::before {
+    content: '';
+    position: absolute;
+    top: 50%;
+    left: 0;
+    right: 0;
+    height: 1px;
+    background: #e2e8f0;
+  }
+
+  span {
+    position: relative;
+    background: white;
+    padding: 0 1rem;
+    color: #a0aec0;
+    font-size: 0.875rem;
+  }
+}
+
 .form {
   display: flex;
   flex-direction: column;

+ 52 - 2
web/apps/lq_label/src/components/login-form/login-form.tsx

@@ -1,13 +1,14 @@
 /**
  * Login form component
- * Provides user authentication interface
+ * Provides user authentication interface with OAuth support
  * 
  * Requirements: 2.1
  */
-import React, { useState } from 'react';
+import React, { useState, useEffect } from 'react';
 import { useAtom } from 'jotai';
 import { useNavigate, Link } from 'react-router-dom';
 import { login } from '../../services/auth-service';
+import { startOAuthLogin, getOAuthStatus } from '../../services/oauth-service';
 import { loginAtom } from '../../atoms/auth-atoms';
 import { toast } from '../../services/toast';
 import styles from './login-form.module.scss';
@@ -19,6 +20,20 @@ export const LoginForm: React.FC = () => {
   const [username, setUsername] = useState('');
   const [password, setPassword] = useState('');
   const [isLoading, setIsLoading] = useState(false);
+  const [oauthEnabled, setOauthEnabled] = useState(false);
+
+  // Check if OAuth is enabled
+  useEffect(() => {
+    const checkOAuthStatus = async () => {
+      try {
+        const status = await getOAuthStatus();
+        setOauthEnabled(status.enabled);
+      } catch (error) {
+        console.error('Failed to check OAuth status:', error);
+      }
+    };
+    checkOAuthStatus();
+  }, []);
 
   const handleSubmit = async (e: React.FormEvent) => {
     e.preventDefault();
@@ -63,12 +78,47 @@ export const LoginForm: React.FC = () => {
     }
   };
 
+  const handleOAuthLogin = async () => {
+    try {
+      await startOAuthLogin();
+    } catch (error: any) {
+      console.error('OAuth login failed:', error);
+      toast.error('OAuth 登录失败');
+    }
+  };
+
   return (
     <div className={styles.container}>
       <div className={styles.formCard}>
         <h1 className={styles.title}>登录</h1>
         <p className={styles.subtitle}>欢迎回到标注平台</p>
 
+        {oauthEnabled && (
+          <>
+            <button
+              type="button"
+              onClick={handleOAuthLogin}
+              className={styles.oauthButton}
+            >
+              <svg width="20" height="20" viewBox="0 0 24 24" fill="none">
+                <path
+                  d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2ZM12 20C7.59 20 4 16.41 4 12C4 7.59 7.59 4 12 4C16.41 4 20 7.59 20 12C20 16.41 16.41 20 12 20Z"
+                  fill="currentColor"
+                />
+                <path
+                  d="M12 6C8.69 6 6 8.69 6 12C6 15.31 8.69 18 12 18C15.31 18 18 15.31 18 12C18 8.69 15.31 6 12 6ZM12 16C9.79 16 8 14.21 8 12C8 9.79 9.79 8 12 8C14.21 8 16 9.79 16 12C16 14.21 14.21 16 12 16Z"
+                  fill="currentColor"
+                />
+              </svg>
+              使用 SSO 登录
+            </button>
+
+            <div className={styles.divider}>
+              <span>或</span>
+            </div>
+          </>
+        )}
+
         <form onSubmit={handleSubmit} className={styles.form}>
           <div className={styles.formGroup}>
             <label htmlFor="username" className={styles.label}>

+ 1 - 0
web/apps/lq_label/src/components/oauth-callback/index.ts

@@ -0,0 +1 @@
+export { OAuthCallback } from './oauth-callback';

+ 64 - 0
web/apps/lq_label/src/components/oauth-callback/oauth-callback.module.scss

@@ -0,0 +1,64 @@
+.container {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  min-height: 100vh;
+  background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
+  padding: 1rem;
+}
+
+.card {
+  background: white;
+  border-radius: 12px;
+  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
+  padding: 3rem 2.5rem;
+  width: 100%;
+  max-width: 420px;
+  text-align: center;
+}
+
+.spinner {
+  display: flex;
+  justify-content: center;
+  margin-bottom: 2rem;
+}
+
+.spinnerCircle {
+  width: 64px;
+  height: 64px;
+  border: 4px solid #e2e8f0;
+  border-top-color: #667eea;
+  border-radius: 50%;
+  animation: spin 1s linear infinite;
+}
+
+@keyframes spin {
+  to {
+    transform: rotate(360deg);
+  }
+}
+
+.errorIcon {
+  display: flex;
+  justify-content: center;
+  margin-bottom: 2rem;
+}
+
+.title {
+  font-size: 1.75rem;
+  font-weight: 700;
+  color: #1a202c;
+  margin: 0 0 1rem 0;
+}
+
+.message {
+  font-size: 1rem;
+  color: #718096;
+  margin: 0 0 0.5rem 0;
+}
+
+.redirect {
+  font-size: 0.9rem;
+  color: #a0aec0;
+  margin: 0;
+}

+ 141 - 0
web/apps/lq_label/src/components/oauth-callback/oauth-callback.tsx

@@ -0,0 +1,141 @@
+/**
+ * OAuth callback component
+ * Handles OAuth redirect and completes authentication
+ */
+import React, { useEffect, useState, useRef } from 'react';
+import { useNavigate, useSearchParams } from 'react-router-dom';
+import { useAtom } from 'jotai';
+import { handleOAuthCallback } from '../../services/oauth-service';
+import { loginAtom } from '../../atoms/auth-atoms';
+import { toast } from '../../services/toast';
+import styles from './oauth-callback.module.scss';
+
+export const OAuthCallback: React.FC = () => {
+  const [searchParams] = useSearchParams();
+  const navigate = useNavigate();
+  const [, setAuth] = useAtom(loginAtom);
+  const [error, setError] = useState<string | null>(null);
+  const isProcessingRef = useRef(false);
+
+  useEffect(() => {
+    // Prevent duplicate processing in React StrictMode
+    if (isProcessingRef.current) {
+      console.log('Already processing, skipping duplicate call');
+      return;
+    }
+
+    const processCallback = async () => {
+      isProcessingRef.current = true;
+      console.log('Starting OAuth callback processing...');
+      try {
+        // Log all URL parameters for debugging
+        const allParams: Record<string, string> = {};
+        searchParams.forEach((value, key) => {
+          allParams[key] = value;
+        });
+        console.log('OAuth callback URL parameters:', allParams);
+
+        // Check for error from OAuth provider
+        const error = searchParams.get('error');
+        const errorDescription = searchParams.get('error_description');
+        
+        if (error) {
+          throw new Error(`OAuth 错误: ${error} - ${errorDescription || '未知错误'}`);
+        }
+
+        // Get code and state from URL
+        const code = searchParams.get('code');
+        const state = searchParams.get('state');
+
+        console.log('Code:', code);
+        console.log('State:', state);
+
+        if (!code || !state) {
+          console.error('Missing parameters. All params:', allParams);
+          throw new Error('缺少授权码或 state 参数');
+        }
+
+        // Verify state matches what we saved
+        const savedState = sessionStorage.getItem('oauth_state');
+        console.log('Saved state:', savedState);
+        
+        if (state !== savedState) {
+          throw new Error('State 参数不匹配,可能存在 CSRF 攻击');
+        }
+
+        // Exchange code for tokens
+        console.log('Exchanging code for tokens...');
+        const response = await handleOAuthCallback({ code, state });
+        console.log('Token exchange successful');
+
+        // Clear saved state
+        sessionStorage.removeItem('oauth_state');
+
+        // Update auth state
+        setAuth({
+          tokens: {
+            access_token: response.access_token,
+            refresh_token: response.refresh_token,
+            token_type: response.token_type,
+          },
+          user: {
+            ...response.user,
+            role: response.user.role as 'annotator' | 'admin',
+          },
+        });
+
+        // Show success message
+        toast.success(`欢迎,${response.user.username}!`);
+
+        // Redirect to home page
+        navigate('/');
+      } catch (err: any) {
+        console.error('OAuth callback error:', err);
+        setError(err.message || 'OAuth 登录失败');
+        toast.error(err.message || 'OAuth 登录失败');
+
+        // Redirect to login page after 3 seconds
+        setTimeout(() => {
+          navigate('/login');
+        }, 3000);
+      }
+    };
+
+    processCallback();
+  }, [searchParams, navigate, setAuth]);
+
+  if (error) {
+    return (
+      <div className={styles.container}>
+        <div className={styles.card}>
+          <div className={styles.errorIcon}>
+            <svg width="64" height="64" viewBox="0 0 24 24" fill="none">
+              <circle cx="12" cy="12" r="10" stroke="#ef4444" strokeWidth="2" />
+              <path
+                d="M15 9L9 15M9 9L15 15"
+                stroke="#ef4444"
+                strokeWidth="2"
+                strokeLinecap="round"
+              />
+            </svg>
+          </div>
+          <h1 className={styles.title}>登录失败</h1>
+          <p className={styles.message}>{error}</p>
+          <p className={styles.redirect}>正在返回登录页...</p>
+        </div>
+      </div>
+    );
+  }
+
+  return (
+    <div className={styles.container}>
+      <div className={styles.card}>
+        <div className={styles.spinner}>
+          <div className={styles.spinnerCircle}></div>
+        </div>
+        <h1 className={styles.title}>正在登录...</h1>
+        <p className={styles.message}>请稍候,正在完成身份验证</p>
+      </div>
+    </div>
+  );
+};

+ 3 - 0
web/apps/lq_label/src/services/index.ts

@@ -12,3 +12,6 @@ export * from './api';
 
 // Export authentication service
 export * from './auth-service';
+
+// Export OAuth service
+export * from './oauth-service';

+ 98 - 0
web/apps/lq_label/src/services/oauth-service.ts

@@ -0,0 +1,98 @@
+/**
+ * OAuth 2.0 authentication service
+ * Handles OAuth login flow with SSO provider
+ */
+import { apiClient } from './api';
+
+/**
+ * OAuth login response
+ */
+export interface OAuthLoginResponse {
+  authorization_url: string;
+  state: string;
+}
+
+/**
+ * OAuth callback parameters
+ */
+export interface OAuthCallbackParams {
+  code: string;
+  state: string;
+}
+
+/**
+ * OAuth token response
+ */
+export interface OAuthTokenResponse {
+  access_token: string;
+  refresh_token: string;
+  token_type: string;
+  user: {
+    id: string;
+    username: string;
+    email: string;
+    role: string;
+    created_at: string;
+  };
+}
+
+/**
+ * OAuth status response
+ */
+export interface OAuthStatusResponse {
+  enabled: boolean;
+  provider: string | null;
+  base_url: string | null;
+}
+
+/**
+ * Initiate OAuth login flow
+ * Returns authorization URL and state parameter
+ */
+export async function initiateOAuthLogin(): Promise<OAuthLoginResponse> {
+  const response = await apiClient.get<OAuthLoginResponse>('/api/oauth/login');
+  return response.data;
+}
+
+/**
+ * Handle OAuth callback
+ * Exchange authorization code for tokens
+ */
+export async function handleOAuthCallback(
+  params: OAuthCallbackParams
+): Promise<OAuthTokenResponse> {
+  const response = await apiClient.get<OAuthTokenResponse>(
+    '/api/oauth/callback',
+    {
+      params: {
+        code: params.code,
+        state: params.state,
+      },
+    }
+  );
+  return response.data;
+}
+
+/**
+ * Get OAuth configuration status
+ */
+export async function getOAuthStatus(): Promise<OAuthStatusResponse> {
+  const response = await apiClient.get<OAuthStatusResponse>(
+    '/api/oauth/status'
+  );
+  return response.data;
+}
+
+/**
+ * Start OAuth login process
+ * Saves state to sessionStorage and redirects to authorization URL
+ */
+export async function startOAuthLogin(): Promise<void> {
+  const { authorization_url, state } = await initiateOAuthLogin();
+
+  // Save state to sessionStorage for verification in callback
+  sessionStorage.setItem('oauth_state', state);
+
+  // Redirect to OAuth provider
+  window.location.href = authorization_url;
+}