Remove application license gate

This commit is contained in:
2026-07-02 12:58:44 +08:00
parent 9c5cbc4637
commit 2da2121829
29 changed files with 51 additions and 1923 deletions
+2 -2
View File
@@ -3,7 +3,7 @@ from __future__ import annotations
from fastapi import APIRouter, Depends
from .routers import include_all_routers
from .routers.dependencies import _require_auth, _require_license
from .routers.dependencies import _require_auth
router = APIRouter(dependencies=[Depends(_require_license), Depends(_require_auth)])
router = APIRouter(dependencies=[Depends(_require_auth)])
include_all_routers(router)
-2
View File
@@ -202,7 +202,6 @@ class Settings(BaseSettings):
CORS_ALLOW_CREDENTIALS: bool = True
CORS_STRICT_MODE: bool = False
LICENSE_PATH: str = ""
INIT_ADMIN_USERNAME: str = "admin"
INIT_ADMIN_PASSWORD: str = ""
INIT_ADMIN_RESET_PASSWORD: bool = False
@@ -1333,7 +1332,6 @@ def validate_runtime_config() -> dict[str, Any]:
_check_path(label="PYTHON_PATH", value=settings.PYTHON_PATH, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="NGINX_PATH", value=settings.NGINX_PATH, errors=errors, warnings=warnings, required=True, expect_file=True)
_check_path(label="LICENSE_PATH", value=settings.LICENSE_PATH, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="IDL_EXECUTABLE", value=settings.IDL_EXECUTABLE, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="IDL_WORKBENCH_PATH", value=settings.IDL_WORKBENCH_PATH, errors=errors, warnings=warnings, expect_file=True)
_check_path(label="GF3_GEO_DEM_PATH", value=settings.GF3_GEO_DEM_PATH, errors=errors, warnings=warnings, expect_file=True)
-164
View File
@@ -1,164 +0,0 @@
"""
授权验证服务 — LIC2 方案
========================
- 公钥硬编码在源码中,无需 .env 配置
- 无本地 state 文件,无时间防回退(去掉脆弱的本地状态)
- 授权文件格式:LIC2|<ed25519签名_b64>|<payload_json_b64>
- 私钥只在签发工具(license-issuer/)中,不随代码部署
私钥丢失处理:
1. 在 license-issuer/ 运行 rotate-key --force 生成新密钥对
2. 将新公钥更新到本文件的 _PUBLIC_KEY_B64 常量
3. 重新部署后端,重新为客户签发授权文件
"""
import base64
import hashlib
import json
import os
import subprocess
import time
import uuid
from datetime import datetime, timezone
from typing import Any, Dict, Optional
from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey
from cryptography.exceptions import InvalidSignature
from .config import settings
# ── 公钥(硬编码,与 license-issuer/public_key.b64 对应) ─────────────────────
# 私钥丢失后,用新密钥对重新签发授权,并将此处更新为新公钥。
_PUBLIC_KEY_B64 = "QOpR1c3bONDwOzrj3IVTogE1ZHIphpwxJY8nhWa09yw="
_APP_DIR = os.path.dirname(os.path.abspath(__file__))
_BACKEND_DIR = os.path.dirname(_APP_DIR)
LICENSE_PATH_DEFAULT = os.path.join(_BACKEND_DIR, "license", "license.lic")
LICENSE_STATUS_CACHE_SECONDS = int(os.getenv("LICENSE_STATUS_CACHE_SECONDS", "30"))
_LICENSE_STATUS_CACHE: Dict[str, Any] = {
"path": None,
"mtime": None,
"checked_at": 0.0,
"payload": None,
}
def _now_utc() -> datetime:
return datetime.now(timezone.utc)
# ── 机器指纹 ──────────────────────────────────────────────────────────────────
def _run_cmd(args: list) -> str:
try:
out = subprocess.check_output(
args, stderr=subprocess.DEVNULL, stdin=subprocess.DEVNULL, shell=False
)
return out.decode("utf-8", errors="ignore").strip()
except Exception:
return ""
def _run_powershell(cmd: str) -> str:
return _run_cmd(["powershell", "-NoProfile", "-Command", cmd])
def _pick_value(text: str) -> str:
lines = [line.strip() for line in text.splitlines() if line.strip()]
if len(lines) <= 1:
return ""
values = [v for v in lines[1:] if v and v.lower() not in ("serialnumber", "uuid")]
return values[0] if values else ""
def _get_machine_fingerprint() -> str:
uuid_text = _run_cmd(["wmic", "csproduct", "get", "uuid"])
if not uuid_text:
uuid_text = _run_powershell("(Get-CimInstance Win32_ComputerSystemProduct).UUID")
disk_text = _run_cmd(["wmic", "diskdrive", "get", "serialnumber"])
if not disk_text:
disk_text = _run_powershell(
"(Get-CimInstance Win32_DiskDrive | Select-Object -First 1 -ExpandProperty SerialNumber)"
)
uuid_val = _pick_value(uuid_text)
disk_val = _pick_value(disk_text)
mac_val = f"{uuid.getnode():012x}"
if not mac_val or mac_val == "000000000000":
mac_val = _run_powershell(
"(Get-NetAdapter | Where-Object {$_.Status -eq 'Up'} | Select-Object -First 1 -ExpandProperty MacAddress)"
) or ""
raw = "|".join([uuid_val, disk_val, mac_val])
return hashlib.sha256(raw.encode("utf-8")).hexdigest()
# ── 授权验证 ──────────────────────────────────────────────────────────────────
def _license_result(result: Dict[str, Any], *, use_cache: bool, path: str, mtime: Optional[float]) -> Dict[str, Any]:
if use_cache:
_LICENSE_STATUS_CACHE.update({
"path": path,
"mtime": mtime,
"checked_at": time.monotonic(),
"payload": dict(result),
})
return result
def check_license(license_path: Optional[str] = None) -> Dict[str, Any]:
use_cache = license_path is None
license_path = license_path or settings.LICENSE_PATH or LICENSE_PATH_DEFAULT
mtime = os.path.getmtime(license_path) if os.path.exists(license_path) else None
if use_cache:
cached = _LICENSE_STATUS_CACHE.get("payload")
cache_age = time.monotonic() - float(_LICENSE_STATUS_CACHE.get("checked_at") or 0.0)
if (
cached is not None
and _LICENSE_STATUS_CACHE.get("path") == license_path
and _LICENSE_STATUS_CACHE.get("mtime") == mtime
and cache_age <= LICENSE_STATUS_CACHE_SECONDS
):
return dict(cached)
if not os.path.exists(license_path):
return _license_result({"ok": False, "reason": "未找到授权文件"}, use_cache=use_cache, path=license_path, mtime=mtime)
try:
blob = open(license_path, "rb").read().strip()
parts = blob.split(b"|", 2)
if len(parts) != 3 or parts[0] != b"LIC2":
return _license_result({"ok": False, "reason": "授权文件格式无效"}, use_cache=use_cache, path=license_path, mtime=mtime)
_, sig_b64, payload_b64 = parts
pub = Ed25519PublicKey.from_public_bytes(base64.b64decode(_PUBLIC_KEY_B64))
pub.verify(base64.b64decode(sig_b64), payload_b64)
payload = json.loads(base64.b64decode(payload_b64))
except InvalidSignature:
return _license_result({"ok": False, "reason": "授权文件签名无效(可能已被篡改)"}, use_cache=use_cache, path=license_path, mtime=mtime)
except Exception as e:
return _license_result({"ok": False, "reason": f"授权文件解析失败: {e}"}, use_cache=use_cache, path=license_path, mtime=mtime)
fp_expected = payload.get("fingerprint")
fp_actual = _get_machine_fingerprint()
if not fp_expected or fp_expected != fp_actual:
return _license_result({"ok": False, "reason": "机器指纹不匹配"}, use_cache=use_cache, path=license_path, mtime=mtime)
expires_at = payload.get("expires_at")
if not expires_at:
return _license_result({"ok": False, "reason": "授权文件缺少有效期"}, use_cache=use_cache, path=license_path, mtime=mtime)
try:
expires_dt = datetime.fromisoformat(expires_at)
except Exception:
return _license_result({"ok": False, "reason": "有效期格式错误"}, use_cache=use_cache, path=license_path, mtime=mtime)
if _now_utc() > expires_dt:
return _license_result({"ok": False, "reason": "授权已过期"}, use_cache=use_cache, path=license_path, mtime=mtime)
return _license_result({
"ok": True,
"issued_to": payload.get("issued_to"),
"expires_at": expires_at,
"fingerprint": fp_actual,
"license_path": license_path,
}, use_cache=use_cache, path=license_path, mtime=mtime)
-2
View File
@@ -15,7 +15,6 @@ from . import (
health,
idl,
landsar_lt1_production,
license,
logs,
monitor,
ops_maintenance,
@@ -42,7 +41,6 @@ def include_all_routers(router: APIRouter) -> None:
"""将所有子路由注册到给定的 APIRouter。"""
router.include_router(health.router)
router.include_router(auth.router)
router.include_router(license.router)
router.include_router(tasks_runtime.router)
router.include_router(workflow.router)
router.include_router(task_batches.router)
-39
View File
@@ -38,29 +38,17 @@ _TRUSTED_PROXY_IPS: frozenset[str] = frozenset(
if ip.strip()
)
from ..database import get_db
from ..license_service import check_license
from ..models import AuthRateLimitORM, AuthUserORM, DinsarTaskBatchORM, DinsarTaskItemORM, PsTaskBatchORM, PsTaskItemORM
# ---------------------------------------------------------------------------
# Path classification constants
# ---------------------------------------------------------------------------
_LICENSE_EXEMPT_PATHS = {
"/api/license/status",
"/api/license/upload",
"/api/license/refresh",
"/api/health",
"/api/auth/login",
"/api/auth/logout",
"/api/auth/me",
}
READ_ONLY_METHODS = {"GET", "HEAD", "OPTIONS"}
READ_SAFE_POST_PATHS = {
"/api/radar-data/search",
}
PUBLIC_AUTH_PATHS = {
"/api/license/status",
"/api/health",
"/api/auth/login",
"/api/auth/logout",
@@ -68,8 +56,6 @@ PUBLIC_AUTH_PATHS = {
HIGH_RISK_WRITE_PATH_PREFIXES = (
"/api/auth/users",
"/api/license/upload",
"/api/license/refresh",
"/api/workflow/runs",
"/api/task-batches/",
"/api/tools/",
@@ -103,13 +89,6 @@ LOGIN_THROTTLE_CLEANUP_INTERVAL_SECONDS = read_int_env(
_LOGIN_THROTTLE_LOCK = asyncio.Lock()
_LOGIN_THROTTLE_LAST_CLEANUP_MONO = 0.0
_LICENSE_UPLOAD_LOCK = asyncio.Lock()
MAX_LICENSE_UPLOAD_BYTES = read_int_env(
"MAX_LICENSE_UPLOAD_BYTES",
1024 * 1024,
minimum=1024,
maximum=20 * 1024 * 1024,
)
# ---------------------------------------------------------------------------
# Statistics cache
@@ -363,24 +342,6 @@ async def _add_operation_audit_log(
)
# ---------------------------------------------------------------------------
# License guard
# ---------------------------------------------------------------------------
def _require_license(request: Request):
"""
授权校验:未授权时拒绝所有 API。
使用精确路径集合匹配,防止 endswith 绕过攻击。
"""
path = (request.url.path or "").rstrip("/") or "/"
if path in _LICENSE_EXEMPT_PATHS:
return
result = check_license()
if not result.get("ok"):
raise HTTPException(status_code=403, detail=f"License required: {result.get('reason')}")
# ---------------------------------------------------------------------------
# Auth guards
# ---------------------------------------------------------------------------
-162
View File
@@ -1,162 +0,0 @@
from __future__ import annotations
import os
import tempfile
from typing import Any, Dict, Optional
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile
from sqlalchemy.ext.asyncio import AsyncSession
from ..config import read_int_env, settings
from ..database import get_db
from ..license_service import check_license
from ..models import AuthUserORM
from .dependencies import (
_add_operation_audit_log,
_get_optional_session_user,
_require_admin,
_LICENSE_UPLOAD_LOCK,
MAX_LICENSE_UPLOAD_BYTES,
)
from ..auth_service import ROLE_ADMIN
router = APIRouter()
def _serialize_license_status(raw_status: Dict[str, Any], include_details: bool) -> Dict[str, Any]:
public_payload = {
"ok": bool(raw_status.get("ok")),
"reason": raw_status.get("reason"),
"expires_at": raw_status.get("expires_at"),
"issued_to": raw_status.get("issued_to"),
}
if include_details:
return {
**public_payload,
"fingerprint": raw_status.get("fingerprint"),
"license_path": raw_status.get("license_path"),
}
return public_payload
@router.get("/license/status")
async def license_status(request: Request, db: AsyncSession = Depends(get_db)):
"""
授权状态查询(无需授权)。
默认返回脱敏字段;管理员会话可看到额外调试字段。
"""
status = check_license()
session_user = await _get_optional_session_user(request, db)
include_details = bool(session_user and session_user.role == ROLE_ADMIN)
return _serialize_license_status(status, include_details=include_details)
@router.post("/license/upload")
async def license_upload(
request: Request,
file: UploadFile = File(...),
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
"""
上传授权文件(覆盖后立即生效)。
"""
filename = (file.filename or "").strip()
if not filename.lower().endswith('.lic'):
raise HTTPException(status_code=400, detail='License file must end with .lic')
license_path = settings.LICENSE_PATH
if not license_path:
license_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "..", "license", "license.lic")
license_path = os.path.abspath(license_path)
license_dir = os.path.dirname(license_path)
os.makedirs(license_dir, exist_ok=True)
content = await file.read()
if not content:
raise HTTPException(status_code=400, detail="License file is empty")
if len(content) > MAX_LICENSE_UPLOAD_BYTES:
raise HTTPException(
status_code=400,
detail=f"License file too large (max {MAX_LICENSE_UPLOAD_BYTES} bytes)",
)
upload_status: Optional[Dict[str, Any]] = None
upload_error: Optional[str] = None
tmp_path: Optional[str] = None
async with _LICENSE_UPLOAD_LOCK:
try:
with tempfile.NamedTemporaryFile(
mode="wb",
delete=False,
dir=license_dir,
prefix="license_upload_",
suffix=".lic.tmp",
) as temp_file:
temp_file.write(content)
temp_file.flush()
tmp_path = temp_file.name
upload_status = check_license(license_path=tmp_path)
if not upload_status.get("ok"):
upload_error = upload_status.get("reason") or "license validation failed"
else:
os.replace(tmp_path, license_path)
tmp_path = None
upload_status = check_license()
if not upload_status.get("ok"):
upload_error = upload_status.get("reason") or "license validation failed after activation"
except Exception as exc:
upload_error = str(exc)
finally:
if tmp_path and os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except OSError:
pass
if upload_error:
await _add_operation_audit_log(
db,
request=request,
action="license_upload_failed",
user=admin_user,
resource="license/upload",
detail={"filename": filename, "reason": upload_error},
)
await db.commit()
raise HTTPException(status_code=400, detail=f"License invalid: {upload_error}")
await _add_operation_audit_log(
db,
request=request,
action="license_uploaded",
user=admin_user,
resource="license/upload",
detail={"filename": filename, "size": len(content)},
)
await db.commit()
return {"message": "License uploaded", "status": upload_status}
@router.post("/license/refresh")
async def license_refresh(
request: Request,
db: AsyncSession = Depends(get_db),
admin_user: AuthUserORM = Depends(_require_admin),
):
"""
刷新授权状态。
"""
status = check_license()
await _add_operation_audit_log(
db,
request=request,
action="license_refreshed",
user=admin_user,
resource="license/refresh",
detail={"license_ok": bool(status.get("ok")), "reason": status.get("reason")},
)
await db.commit()
return status