Explorar el Código

feat: git auto-update + client request signing

- POST /api/admin/git-update: git pull + auto restart
- HMAC-SHA256 request signing (X-Timestamp + X-Signature)
- 5-minute replay protection window
- Flutter Dio interceptor auto-signs all API requests
- Backward compatible: requests without signature still accepted
nonvioce hace 3 meses
padre
commit
ab274e8cd4

+ 19 - 0
tiku-client/lib/services/api_service.dart

@@ -1,6 +1,7 @@
 /// API 服务 — 封装所有 HTTP 请求
 
 import 'dart:convert';
+import 'package:crypto/crypto.dart';
 import 'package:dio/dio.dart';
 import 'package:shared_preferences/shared_preferences.dart';
 
@@ -18,6 +19,24 @@ class ApiService {
       receiveTimeout: const Duration(seconds: 30),
       headers: {'Content-Type': 'application/json'},
     ));
+
+    // 签名拦截器:给每个 API 请求加上客户端签名
+    _dio.interceptors.add(InterceptorsWrapper(
+      onRequest: (options, handler) {
+        if (_token != null && !options.path.contains('/auth/verify') &&
+            !options.path.contains('/health')) {
+          final ts = DateTime.now().millisecondsSinceEpoch ~/ 1000;
+          final payload = utf8.encode('${options.method}${options.path}$ts');
+          final key = utf8.encode(_token!);
+          final sig = Hmac(sha256, key).convert(payload);
+          final sigHex = sig.bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join();
+          options.headers['X-Timestamp'] = '$ts';
+          options.headers['X-Signature'] = sigHex;
+        }
+        handler.next(options);
+      },
+    ));
+
     _dio.interceptors.add(LogInterceptor(requestBody: false, responseBody: false));
   }
 

+ 1 - 1
tiku-client/pubspec.lock

@@ -170,7 +170,7 @@ packages:
     source: hosted
     version: "3.1.2"
   crypto:
-    dependency: transitive
+    dependency: "direct main"
     description:
       name: crypto
       sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf

+ 1 - 0
tiku-client/pubspec.yaml

@@ -19,6 +19,7 @@ dependencies:
   go_router: ^14.6.2
   path_provider: ^2.1.5
   intl: ^0.19.0
+  crypto: ^3.0.6
 
 dev_dependencies:
   flutter_test:

+ 41 - 0
tiku-server/app/routers/admin.py

@@ -66,3 +66,44 @@ def rebuild_index(request: Request, _=Depends(verify_token)):
         raise HTTPException(status_code=500, detail={
             "error": {"code": "rebuild_failed", "message": str(e)}
         })
+
+
+@router.post("/admin/git-update")
+def git_update(request: Request, _=Depends(verify_token)):
+    """从远端 git 拉取更新并重启服务"""
+    import subprocess, os, signal
+    config = request.app.state.config
+    project_dir = config.config_path.parent
+
+    try:
+        result = subprocess.run(
+            ["git", "pull", "origin", "master"],
+            cwd=str(project_dir),
+            capture_output=True, text=True, timeout=30
+        )
+
+        if "up to date" in result.stdout.lower() or "已经是最新" in result.stdout:
+            return {"updated": False, "message": "已是最新版本", "git_output": result.stdout}
+
+        if result.returncode != 0:
+            raise RuntimeError(result.stderr)
+
+        # 有更新,触发重启
+        import threading
+        def restart():
+            import time
+            time.sleep(1)
+            os.kill(os.getpid(), signal.SIGTERM)
+
+        threading.Thread(target=restart, daemon=True).start()
+
+        return {"updated": True, "message": "已更新,正在重启...", "git_output": result.stdout}
+
+    except subprocess.TimeoutExpired as e:
+        raise HTTPException(status_code=500, detail={
+            "error": {"code": "git_timeout", "message": "git pull 超时"}
+        })
+    except Exception as e:
+        raise HTTPException(status_code=500, detail={
+            "error": {"code": "git_failed", "message": str(e)}
+        })

+ 31 - 7
tiku-server/app/routers/auth.py

@@ -1,5 +1,9 @@
 """认证路由"""
 
+import hashlib
+import hmac
+import time
+
 from fastapi import APIRouter, Request, HTTPException, Depends
 from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
 
@@ -9,23 +13,43 @@ router = APIRouter()
 
 
 def verify_token(request: Request, credentials: HTTPAuthorizationCredentials | None = Depends(security)):
-    """验证 Bearer Token"""
+    """验证 Bearer Token + 客户端签名(防非客户端请求)"""
     config = request.app.state.config
     if not credentials:
         raise HTTPException(status_code=401, detail={"error": {"code": "unauthorized", "message": "缺少认证 token"}})
     if credentials.credentials != config.token:
         raise HTTPException(status_code=401, detail={"error": {"code": "unauthorized", "message": "Token 无效"}})
+
+    # 客户端请求签名验证(防止浏览器/curl直接调用)
+    req_ts = request.headers.get("X-Timestamp", "")
+    req_sig = request.headers.get("X-Signature", "")
+    if req_ts and req_sig:
+        try:
+            ts = int(req_ts)
+            if abs(time.time() - ts) > 300:  # 5分钟窗口防重放
+                raise HTTPException(status_code=401, detail={
+                    "error": {"code": "timestamp_expired", "message": "请求时间戳过期"}
+                })
+            path = request.url.path
+            expected = hmac.new(
+                config.token.encode(), f"{request.method}{path}{req_ts}".encode(), hashlib.sha256
+            ).hexdigest()
+            if not hmac.compare_digest(expected, req_sig):
+                raise HTTPException(status_code=403, detail={
+                    "error": {"code": "invalid_signature", "message": "客户端签名无效"}
+                })
+        except (ValueError, TypeError):
+            raise HTTPException(status_code=401, detail={
+                "error": {"code": "bad_timestamp", "message": "时间戳格式错误"}
+            })
+
     return True
 
 
 @router.get("/health")
 def health(request: Request):
     """健康检查(无需认证)"""
-    config = request.app.state.config
-    return {
-        "status": "ok",
-        "version": "1.0.0",
-    }
+    return {"status": "ok", "version": "1.0.0"}
 
 
 @router.post("/auth/verify")
@@ -34,5 +58,5 @@ def verify(request: Request, body: dict):
     config = request.app.state.config
     token = body.get("token", "")
     if token == config.token:
-        return {"valid": True}
+        return {"valid": True, "token": token}
     raise HTTPException(status_code=401, detail={"error": {"code": "unauthorized", "message": "Token 无效"}})