Quellcode durchsuchen

新增access_token

liuq vor 3 Monaten
Ursprung
Commit
05316e78bb

+ 23 - 3
backend/app/api/v1/deps.py

@@ -1,6 +1,6 @@
 from typing import Generator, Optional
 from fastapi import Depends, HTTPException, status, Response
-from fastapi.security import OAuth2PasswordBearer
+from fastapi.security import OAuth2PasswordBearer, APIKeyHeader
 from jose import jwt, JWTError
 from sqlalchemy.orm import Session
 from datetime import datetime
@@ -16,6 +16,8 @@ reusable_oauth2 = OAuth2PasswordBearer(
     auto_error=False # Allow optional token
 )
 
+token_header_scheme = APIKeyHeader(name="X-App-Access-Token", auto_error=False)
+
 def get_db() -> Generator:
     try:
         db = SessionLocal()
@@ -121,12 +123,30 @@ def get_current_active_user_optional(
 
 def get_current_app(
     db: Session = Depends(get_db),
-    token: str = Depends(reusable_oauth2)
+    token: Optional[str] = Depends(reusable_oauth2),
+    access_token: Optional[str] = Depends(token_header_scheme)
 ) -> Application:
     """
     Get application from token (Machine-to-Machine auth).
-    Subject format: "app:{id}"
+    Supports:
+    1. JWT Bearer Token (Subject: "app:{id}")
+    2. Permanent Access Token (Header: X-App-Access-Token)
     """
+    # 1. Try Access Token first if present
+    if access_token:
+        # Use simple auth with permanent token
+        app = db.query(Application).filter(Application.access_token == access_token).first()
+        if not app:
+             raise HTTPException(status_code=403, detail="Invalid access token")
+        return app
+
+    # 2. Try JWT Bearer Token
+    if not token:
+         raise HTTPException(
+            status_code=status.HTTP_403_FORBIDDEN,
+            detail="Not authenticated",
+        )
+
     try:
         payload = jwt.decode(
             token, settings.SECRET_KEY, algorithms=[settings.ALGORITHM]

+ 82 - 15
backend/app/api/v1/endpoints/apps.py

@@ -35,6 +35,9 @@ from app.services.mapping_service import MappingService
 
 router = APIRouter()
 
+def generate_access_token():
+    return secrets.token_urlsafe(32)
+
 def generate_app_credentials():
     # Generate a random 16-char App ID (hex or alphanumeric)
     app_id = "app_" + secrets.token_hex(8)
@@ -86,12 +89,15 @@ def create_app(
     """
     # 1. Generate ID and Secret
     app_id, app_secret = generate_app_credentials()
+    # 2. Generate Access Token
+    access_token = generate_access_token()
     
-    # 2. Store Secret (Plain text needed for HMAC verification)
+    # 3. Store Secret (Plain text needed for HMAC verification)
     
     db_app = Application(
         app_id=app_id,
         app_secret=app_secret,
+        access_token=access_token,
         app_name=app_in.app_name,
         icon_url=app_in.icon_url,
         protocol_type=app_in.protocol_type,
@@ -104,7 +110,7 @@ def create_app(
     db.commit()
     db.refresh(db_app)
     
-    return ApplicationSecretDisplay(app_id=app_id, app_secret=app_secret)
+    return ApplicationSecretDisplay(app_id=app_id, app_secret=app_secret, access_token=access_token)
 
 @router.put("/{app_id}", response_model=ApplicationResponse, summary="更新应用")
 def update_app(
@@ -181,7 +187,7 @@ def regenerate_secret(
     db.add(app)
     db.commit()
     
-    return ApplicationSecretDisplay(app_id=app.app_id, app_secret=new_secret)
+    return ApplicationSecretDisplay(app_id=app.app_id, app_secret=new_secret, access_token=app.access_token)
 
 @router.post("/{app_id}/view-secret", response_model=ApplicationSecretDisplay, summary="查看密钥")
 def view_secret(
@@ -206,7 +212,7 @@ def view_secret(
     if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
         raise HTTPException(status_code=403, detail="权限不足")
         
-    return ApplicationSecretDisplay(app_id=app.app_id, app_secret=app.app_secret)
+    return ApplicationSecretDisplay(app_id=app.app_id, app_secret=app.app_secret, access_token=app.access_token)
 
 # ==========================================
 # Mappings
@@ -269,11 +275,21 @@ def create_mapping(
     if current_user.role != "SUPER_ADMIN" and app.owner_id != current_user.id:
         raise HTTPException(status_code=403, detail="权限不足")
 
-    # 1. Check if user exists
+    # 1. Find User or Create
     user = db.query(User).filter(User.mobile == mapping_in.mobile).first()
     if not user:
-        raise HTTPException(status_code=404, detail=f"手机号为 {mapping_in.mobile} 的用户未找到")
-        
+        # Auto create user
+        password = secrets.token_urlsafe(8) # Random password
+        user = User(
+            mobile=mapping_in.mobile,
+            password_hash=security.get_password_hash(password),
+            status="ACTIVE",
+            role="DEVELOPER"
+        )
+        db.add(user)
+        db.commit()
+        db.refresh(user)
+
     # 2. Check if mapping exists
     existing = db.query(AppUserMapping).filter(
         AppUserMapping.app_id == app_id,
@@ -281,8 +297,26 @@ def create_mapping(
     ).first()
     if existing:
         raise HTTPException(status_code=400, detail="该用户的映射已存在")
+
+    # 3. Check Uniqueness for mapped_email (if provided)
+    if mapping_in.mapped_email:
+        email_exists = db.query(AppUserMapping).filter(
+            AppUserMapping.app_id == app_id,
+            AppUserMapping.mapped_email == mapping_in.mapped_email
+        ).first()
+        if email_exists:
+            raise HTTPException(status_code=400, detail=f"该应用下邮箱 {mapping_in.mapped_email} 已被使用")
+
+    # 4. Check Uniqueness for mapped_key
+    if mapping_in.mapped_key:
+        key_exists = db.query(AppUserMapping).filter(
+            AppUserMapping.app_id == app_id,
+            AppUserMapping.mapped_key == mapping_in.mapped_key
+        ).first()
+        if key_exists:
+            raise HTTPException(status_code=400, detail=f"该应用下账号 {mapping_in.mapped_key} 已被使用")
         
-    # 3. Create
+    # 5. Create
     mapping = AppUserMapping(
         app_id=app_id,
         user_id=user.id,
@@ -434,12 +468,22 @@ def sync_mapping(
     """
     从外部平台同步用户映射关系(机器对机器)。
     只同步映射关系,不创建或更新用户本身。
-    需要应用访问令牌。
+    需要应用访问令牌 (Authorization Bearer JWT 或 X-App-Access-Token)
     """
-    # 1. Find User
+    # 1. Find User or Create
     user = db.query(User).filter(User.mobile == sync_in.mobile).first()
     if not user:
-        raise HTTPException(status_code=404, detail=f"User with mobile {sync_in.mobile} not found")
+        # Auto create user
+        password = secrets.token_urlsafe(8) # Random password
+        user = User(
+            mobile=sync_in.mobile,
+            password_hash=security.get_password_hash(password),
+            status="ACTIVE",
+            role="DEVELOPER"
+        )
+        db.add(user)
+        db.commit()
+        db.refresh(user)
 
     # 2. Handle Mapping
     mapping = db.query(AppUserMapping).filter(
@@ -447,10 +491,31 @@ def sync_mapping(
         AppUserMapping.user_id == user.id
     ).first()
 
+    # Check Uniqueness for mapped_key (if changing or new, and provided)
+    if sync_in.mapped_key and (not mapping or mapping.mapped_key != sync_in.mapped_key):
+        key_exists = db.query(AppUserMapping).filter(
+            AppUserMapping.app_id == current_app.id,
+            AppUserMapping.mapped_key == sync_in.mapped_key
+        ).first()
+        if key_exists:
+             raise HTTPException(status_code=400, detail=f"该应用下账号 {sync_in.mapped_key} 已被使用")
+
+    # Check Uniqueness for mapped_email (if changing or new, and provided)
+    if sync_in.mapped_email and (not mapping or mapping.mapped_email != sync_in.mapped_email):
+        email_exists = db.query(AppUserMapping).filter(
+            AppUserMapping.app_id == current_app.id,
+            AppUserMapping.mapped_email == sync_in.mapped_email
+        ).first()
+        if email_exists:
+             raise HTTPException(status_code=400, detail=f"该应用下邮箱 {sync_in.mapped_email} 已被使用")
+
     if mapping:
         # Update existing mapping
-        mapping.mapped_key = sync_in.mapped_key
-        if sync_in.mapped_email:
+        if sync_in.mapped_key is not None:
+            mapping.mapped_key = sync_in.mapped_key
+        if sync_in.is_active is not None:
+            mapping.is_active = sync_in.is_active
+        if sync_in.mapped_email is not None:
             mapping.mapped_email = sync_in.mapped_email
     else:
         # Create new mapping
@@ -458,7 +523,8 @@ def sync_mapping(
             app_id=current_app.id,
             user_id=user.id,
             mapped_key=sync_in.mapped_key,
-            mapped_email=sync_in.mapped_email
+            mapped_email=sync_in.mapped_email,
+            is_active=sync_in.is_active if sync_in.is_active is not None else True
         )
         db.add(mapping)
 
@@ -471,5 +537,6 @@ def sync_mapping(
         user_id=mapping.user_id,
         mapped_key=mapping.mapped_key,
         mapped_email=mapping.mapped_email,
-        user_mobile=user.mobile
+        user_mobile=user.mobile,
+        is_active=mapping.is_active
     )

+ 3 - 0
backend/app/models/application.py

@@ -28,6 +28,9 @@ class Application(Base):
     
     notification_url = Column(String(255), nullable=True)
     
+    # Permanent Access Token for M2M operations (User Mapping Sync)
+    access_token = Column(String(128), unique=True, index=True, nullable=True)
+
     # Ownership & Logic Delete
     owner_id = Column(Integer, ForeignKey("users.id"), nullable=True)
     is_deleted = Column(Boolean, default=False, nullable=False)

+ 6 - 2
backend/app/models/mapping.py

@@ -1,4 +1,4 @@
-from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint
+from sqlalchemy import Column, Integer, String, ForeignKey, UniqueConstraint, Boolean
 from sqlalchemy.orm import relationship
 from app.core.database import Base
 
@@ -10,11 +10,14 @@ class AppUserMapping(Base):
     user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
     
     # The username/email/id in the target application
-    mapped_key = Column(String(100), nullable=False)
+    mapped_key = Column(String(100), nullable=True)
     
     # Optional email in the target application
     mapped_email = Column(String(100), nullable=True)
 
+    # Status: True = Active, False = Disabled
+    is_active = Column(Boolean, default=True, nullable=False)
+
     # Relationships
     application = relationship("Application")
     user = relationship("User")
@@ -22,5 +25,6 @@ class AppUserMapping(Base):
     __table_args__ = (
         UniqueConstraint('app_id', 'user_id', name='uq_app_user'),
         UniqueConstraint('app_id', 'mapped_key', name='uq_app_mapped_key'),
+        UniqueConstraint('app_id', 'mapped_email', name='uq_app_mapped_email'),
     )
 

+ 1 - 0
backend/app/schemas/application.py

@@ -42,6 +42,7 @@ class ApplicationList(BaseModel):
 class ApplicationSecretDisplay(BaseModel):
     app_id: str
     app_secret: str
+    access_token: Optional[str] = None
 
 class ViewSecretRequest(BaseModel):
     password: str

+ 3 - 2
backend/app/schemas/mapping.py

@@ -36,16 +36,17 @@ class MappingImportSummary(BaseModel):
 
 class MappingCreate(BaseModel):
     mobile: str
-    mapped_key: str
+    mapped_key: Optional[str] = None
     mapped_email: Optional[str] = None
 
 class MappingResponse(BaseModel):
     id: int
     app_id: int
     user_id: int
-    mapped_key: str
+    mapped_key: Optional[str] = None
     mapped_email: Optional[str] = None
     user_mobile: str  # Convenient to have
+    is_active: bool = True
 
     class Config:
         from_attributes = True

+ 2 - 1
backend/app/schemas/user.py

@@ -32,8 +32,9 @@ class UserSyncRequest(BaseModel):
     mobile: str
     password: Optional[str] = None
     status: Optional[str] = None
-    mapped_key: str # External User ID
+    mapped_key: Optional[str] = None # External User ID
     mapped_email: Optional[str] = None # External User Email
+    is_active: Optional[bool] = None # True=Active, False=Disabled. None=No Change
 
 class UserInDBBase(UserBase):
     id: int

+ 1 - 0
frontend/src/api/apps.ts

@@ -23,6 +23,7 @@ export interface ApplicationCreate {
 export interface AppSecretResponse {
   app_id: string
   app_secret: string
+  access_token: string
 }
 
 export interface ApplicationListResponse {

+ 16 - 10
frontend/src/views/Help.vue

@@ -103,10 +103,17 @@ sign = hmac.new(secret.encode(), query_string.encode(), hashlib.sha256).hexdiges
         <div class="content">
             <p>适用于外部平台与统一认证平台的后端直接对接场景。</p>
             
-            <h4>1. 获取应用令牌 (Client Credentials)</h4>
-            <p>使用 App ID 和 App Secret 获取 Access Token。</p>
+            <h4>1. 认证方式 (Authentication)</h4>
+            <p>平台支持两种认证方式进行接口调用:</p>
+            
+            <h5>方式 A:永久令牌 (推荐)</h5>
+            <p>在应用详情中查看并获取永久有效的 <code>Access Token</code>。</p>
+            <p><strong>Header</strong>: <code>X-App-Access-Token: your_permanent_access_token</code></p>
+            
+            <h5>方式 B:临时令牌 (Client Credentials)</h5>
+            <p>使用 App ID 和 App Secret 获取短期有效的 Access Token。</p>
             <p><strong>接口</strong>: <code>POST /api/v1/auth/app-token</code></p>
-            <p><strong>注意</strong>: Access Token 有效期为 <strong>30分钟</strong>。请在有效期内复用 Token,无需每次请求都重新生成。</p>
+            <p><strong>有效期</strong>: 30分钟。需在 Header 中传递: <code>Authorization: Bearer &lt;token&gt;</code></p>
             <pre class="code-block">
 {
     "app_id": "app_xxxx",
@@ -114,17 +121,16 @@ sign = hmac.new(secret.encode(), query_string.encode(), hashlib.sha256).hexdiges
 }</pre>
             <p><strong>响应</strong>: <code>{"access_token": "...", "token_type": "bearer"}</code></p>
 
-            <h4>2. 同步用户信息</h4>
-            <p>将外部系统的用户信息(新增或变更)同步到统一平台,并自动建立账号映射关系。</p>
-            <p><strong>接口</strong>: <code>POST /api/v1/users/sync</code></p>
-            <p><strong>Header</strong>: <code>Authorization: Bearer &lt;access_token&gt;</code></p>
+            <h4>2. 同步账号映射</h4>
+            <p>将外部系统的用户账号映射关系同步到统一平台。支持新增、更新及停用映射。</p>
+            <p><strong>接口</strong>: <code>POST /api/v1/apps/mapping/sync</code></p>
+            <p><strong>注意</strong>: 目标手机号必须已在统一平台注册。</p>
             <pre class="code-block">
 {
-    "mobile": "13800138000",      // 统一平台标识
+    "mobile": "13800138000",      // 统一平台标识 (必须已存在)
     "mapped_key": "user_001",     // 外部系统唯一标识 (工号/ID)
     "mapped_email": "a@b.com",    // 外部邮箱 (可选)
-    "password": "new_password",   // 若用户不存在,将使用此密码创建 (可选)
-    "status": "ACTIVE"            // 状态 (可选)
+    "is_active": true             // 状态 (可选, true=启用, false=停用)
 }</pre>
         </div>
       </el-collapse-item>

+ 19 - 1
frontend/src/views/apps/AppList.vue

@@ -127,6 +127,10 @@
           <strong>App Secret:</strong>
           <span class="secret-text">{{ secretData.app_secret }}</span>
         </div>
+        <div class="credential-item" v-if="secretData.access_token">
+          <strong>Access Token (永久):</strong>
+          <div class="token-text">{{ secretData.access_token }}</div>
+        </div>
       </div>
       <template #footer>
         <el-button type="primary" @click="secretDialogVisible = false">关闭</el-button>
@@ -178,7 +182,8 @@ const form = reactive<ApplicationCreate>({
 
 const secretData = reactive({
   app_id: '',
-  app_secret: ''
+  app_secret: '',
+  access_token: ''
 })
 
 // Methods
@@ -243,6 +248,7 @@ const handleSubmit = async () => {
       const res = await createApp(form)
       secretData.app_id = res.data.app_id
       secretData.app_secret = res.data.app_secret
+      secretData.access_token = res.data.access_token
       secretIsNew.value = true
       secretDialogVisible.value = true
       dialogVisible.value = false
@@ -270,6 +276,7 @@ const confirmViewSecret = async () => {
     const res = await viewSecret(viewingAppId.value!, adminPassword.value)
     secretData.app_id = res.data.app_id
     secretData.app_secret = res.data.app_secret
+    secretData.access_token = res.data.access_token
     secretIsNew.value = false
     verifyDialogVisible.value = false
     secretDialogVisible.value = true
@@ -302,6 +309,7 @@ const handleRegenerate = (row: Application) => {
     const res = await regenerateSecret(row.id)
     secretData.app_id = res.data.app_id
     secretData.app_secret = res.data.app_secret
+    secretData.access_token = res.data.access_token
     secretIsNew.value = true
     secretDialogVisible.value = true
   })
@@ -351,6 +359,16 @@ onMounted(() => {
   color: #f56c6c;
   font-weight: bold;
 }
+.token-text {
+  color: #67c23a;
+  font-weight: bold;
+  word-break: break-all;
+  margin-top: 5px;
+  background: #fff;
+  padding: 5px;
+  border-radius: 4px;
+  border: 1px dashed #dcdfe6;
+}
 .action-buttons {
   display: flex;
   align-items: center;

+ 4 - 5
需求功能概述.md

@@ -1,7 +1,3 @@
-好的,我们来做一个**最详尽的总结**,让你能够直接使用 Cursor AI 来进行开发。这份总结将覆盖所有已讨论的功能模块、技术选型、关键实现细节、API 设计以及可能的挑战。
-
----
-
 ### 项目概览:统一认证平台 (Unified Authentication Platform - UAP)
 
 **核心价值**:
@@ -54,6 +50,7 @@
 *   **主键**: `id` (Integer, PK)
 *   **标识**: `app_id` (String(32), Unique, Index, Not Null) - 分配给第三方系统
 *   **密钥**: `app_secret_hash` (String(128), Not Null) - 用于接口签名
+*   **访问令牌**: `access_token` (String(64), Unique) - 永久有效的应用访问令牌,用于 M2M 接口鉴权。
 *   **信息**: `app_name`, `icon_url`
 *   **协议类型**: `protocol_type` (Enum: OIDC, SIMPLE_API)
 *   **OIDC 配置 (Hydra 对应)**: `redirect_uris` (Text, Not Null - JSON/Array), `grant_types`, `response_types` (Store in Hydra DB, but link via `app_id`)
@@ -65,6 +62,7 @@
 *   **主键**: `id` (Integer, PK)
 *   **关联**: `app_id` (FK to Applications), `user_id` (FK to Users)
 *   **核心映射**: `mapped_key` (String(100), Not Null) - 目标系统的账号(邮箱、用户名等)
+*   **状态**: `is_active` (Boolean, Default True) - 映射关系是否启用。
 *   **唯一性约束**: `UNIQUE(app_id, user_id)` 确保一个用户在同一应用下只映射一次。
 *   **可选约束**: `UNIQUE(app_id, mapped_key)` (如果目标系统的账号在该应用内必须唯一)。
 
@@ -118,7 +116,8 @@
     *   Payload includes `event_type` (ADD, UPDATE, DISABLE), `app_id`, `user_data` (mobile, name, mapped_key, status).
     *   Signature `X-UAP-Signature` must be verified by the receiving system.
     *   **Logging**: Failed calls are logged to `WebhookLogs` table. Manual retry option for admins.
-*   **API (Pull - Detail Query)**:
+*   **API (Pull - Detail Query & Sync)**:
+    *   `POST /api/v1/apps/mapping/sync`: 同步映射关系(新增/更新/停用)。支持 Header `X-App-Access-Token`。
     *   `GET /api/v1/apps/{app_id}/users/query?mobile={mobile}`: Returns user details and mappings. Used by legacy systems during login if local user not found.
     *   `GET /api/v1/apps/{app_id}/users/changes?since={timestamp}`: Returns a list of changes. Less critical if Webhook is primary.