Remove application license gate
This commit is contained in:
+2
-2
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user