aps-agent/server/auth/licenses.py

293 lines
12 KiB
Python
Raw Normal View History

from __future__ import annotations
import base64
import hashlib
import hmac
import json
import os
import time
import uuid
from abc import ABC, abstractmethod
from datetime import datetime, timezone
from typing import Any
from fastapi import Request
from sqlalchemy import select
from server.auth.context import IdentityContext
from server.auth.providers import AuthError
from server.db.database import get_session
from server.db.models import LicenseActivation
LICENSE_DURATIONS: dict[str, int | None] = {
"hour": 60 * 60,
"day": 24 * 60 * 60,
"week": 7 * 24 * 60 * 60,
"month": 30 * 24 * 60 * 60,
"year": 365 * 24 * 60 * 60,
"permanent": None,
}
LICENSE_LABELS = {
"hour": "小时授权",
"day": "天授权",
"week": "周授权",
"month": "月授权",
"year": "年授权",
"permanent": "永久授权",
}
DEFAULT_MOCK_CODES: dict[str, str] = {
"APS-HOUR-DEMO": "hour",
"APS-DAY-DEMO": "day",
"APS-WEEK-DEMO": "week",
"APS-MONTH-DEMO": "month",
"APS-YEAR-DEMO": "year",
"APS-PERP-DEMO": "permanent",
}
DESKTOP_COOKIE = "aps_desktop_session"
def is_desktop_request(request: Request) -> bool:
return (request.headers.get("x-aps-client") or "").strip().lower() == "desktop"
def _b64encode(raw: bytes) -> str:
return base64.urlsafe_b64encode(raw).decode("ascii").rstrip("=")
def _b64decode(raw: str) -> bytes:
return base64.urlsafe_b64decode(raw + "=" * (-len(raw) % 4))
def _sha256(value: str) -> str:
return hashlib.sha256(value.encode("utf-8")).hexdigest()
def _device_user_id(device_hash: str) -> int:
# Keep the generated owner ID inside signed BIGINT while remaining stable per installation.
return int(device_hash[:15], 16)
def _utc_text(epoch: int) -> str:
return datetime.fromtimestamp(epoch, tz=timezone.utc).isoformat()
class LicenseProvider(ABC):
mode = "unconfigured"
@abstractmethod
async def activate(self, request: Request, payload: dict[str, Any]) -> tuple[IdentityContext, str]:
raise NotImplementedError
@abstractmethod
async def authenticate(self, request: Request) -> IdentityContext:
raise NotImplementedError
async def refresh(self, request: Request) -> tuple[IdentityContext, str]:
raise AuthError("LICENSE_NOT_CONFIGURED", "授权刷新接口尚未配置", 503)
async def logout(self, request: Request) -> None:
return None
def describe(self, identity: IdentityContext) -> dict[str, Any] | None:
if identity.auth_kind != "license" or not identity.license_type:
return None
return {
"type": identity.license_type,
"label": LICENSE_LABELS.get(identity.license_type, identity.license_type),
"activatedAt": identity.license_activated_at,
"expiresAt": identity.license_expires_at,
"permanent": identity.license_expires_at is None,
}
class UnconfiguredLicenseProvider(LicenseProvider):
async def activate(self, request: Request, payload: dict[str, Any]) -> tuple[IdentityContext, str]:
raise AuthError("LICENSE_NOT_CONFIGURED", "客户端授权接口尚未配置", 503)
async def authenticate(self, request: Request) -> IdentityContext:
raise AuthError("LICENSE_NOT_CONFIGURED", "客户端授权接口尚未配置", 503)
class MockLicenseProvider(LicenseProvider):
"""Development provider; production remains fail-closed unless explicitly configured."""
mode = "mock"
def __init__(self) -> None:
self.secret = (os.environ.get("APS_MOCK_LICENSE_SECRET") or "aps-license-development-only").encode()
self.tenant_uuid = os.environ.get("APS_MOCK_LICENSE_TENANT_UUID") or (
os.environ.get("APS_MOCK_TENANT_UUID") or "demo0000000000000000000000000001"
)
def _catalog(self) -> dict[str, str]:
configured = (os.environ.get("APS_MOCK_LICENSE_CODES") or "").strip()
if not configured:
return dict(DEFAULT_MOCK_CODES)
try:
data = json.loads(configured)
except json.JSONDecodeError as exc:
raise AuthError("LICENSE_CONFIG_INVALID", "开发授权码配置无效", 503) from exc
result: dict[str, str] = {}
if isinstance(data, dict):
for code, grant in data.items():
license_type = grant.get("type") if isinstance(grant, dict) else grant
if isinstance(code, str) and license_type in LICENSE_DURATIONS:
result[code.strip().upper()] = str(license_type)
return result
@staticmethod
def _device_id(request: Request, payload: dict[str, Any] | None = None) -> str:
device_id = (request.headers.get("x-aps-device-id") or "").strip()
if not device_id and payload:
device_id = str(payload.get("deviceId") or "").strip()
if len(device_id) < 8 or len(device_id) > 256:
raise AuthError("DEVICE_ID_REQUIRED", "无法识别当前客户端设备", 400)
return device_id
@staticmethod
def _require_desktop(request: Request) -> None:
if not is_desktop_request(request):
raise AuthError("DESKTOP_CLIENT_REQUIRED", "授权码仅可用于桌面客户端", 403)
def _issue(self, identity: IdentityContext, device_hash: str) -> str:
payload = {**identity.to_dict(), "device_hash": device_hash}
raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=False).encode("utf-8")
body = _b64encode(raw)
signature = _b64encode(hmac.new(self.secret, body.encode("ascii"), hashlib.sha256).digest())
return f"{body}.{signature}"
def _identity(self, row: LicenseActivation) -> IdentityContext:
token_expiry = row.expires_at or 4_102_444_800 # 2100-01-01; DB validation still supports revocation.
return IdentityContext(
user_id=row.user_id,
username=f"desktop-{row.device_hash[:8]}",
fullname="本机授权用户",
tenant_uuid=row.tenant_uuid,
roles=("desktop", "planner"),
expires_at=token_expiry,
auth_kind="license",
license_type=row.license_type,
license_activated_at=row.activated_at,
license_expires_at=row.expires_at,
activation_id=row.id,
)
def _verify_token(self, token: str, device_hash: str) -> IdentityContext:
try:
body, signature = token.split(".", 1)
expected = _b64encode(hmac.new(self.secret, body.encode("ascii"), hashlib.sha256).digest())
if not hmac.compare_digest(signature, expected):
raise ValueError("signature")
payload = json.loads(_b64decode(body))
if payload.get("device_hash") != device_hash:
raise AuthError("LICENSE_DEVICE_MISMATCH", "授权与当前设备不匹配", 401)
activation_id = str(payload.get("activation_id") or "")
if not activation_id:
raise ValueError("activation")
except AuthError:
raise
except Exception as exc:
raise AuthError("LICENSE_INVALID", "客户端授权凭证无效", 401) from exc
now = int(time.time())
with get_session() as session:
row = session.get(LicenseActivation, activation_id)
if row is None or row.deleted != 0 or row.device_hash != device_hash:
raise AuthError("LICENSE_INVALID", "客户端授权凭证无效", 401)
if row.status != "active" or row.revoked_at is not None:
raise AuthError("LICENSE_REVOKED", "客户端授权已被停用", 401)
if row.expires_at is not None and row.expires_at <= now:
row.status = "expired"
session.commit()
raise AuthError("LICENSE_EXPIRED", "客户端授权已到期", 401)
if now - row.last_seen_at >= 3600:
row.last_seen_at = now
session.commit()
return self._identity(row)
async def activate(self, request: Request, payload: dict[str, Any]) -> tuple[IdentityContext, str]:
self._require_desktop(request)
device_id = self._device_id(request, payload)
code = str(payload.get("code") or "").strip().upper()
if not code:
raise AuthError("LICENSE_CODE_REQUIRED", "请输入授权码", 400)
license_type = self._catalog().get(code)
if not license_type:
raise AuthError("LICENSE_CODE_INVALID", "授权码无效", 401)
now = int(time.time())
device_hash = _sha256(device_id)
# Demo codes are reusable across developer installations. Each resulting
# activation is still device-bound; the production provider owns seat limits.
code_hash = _sha256(f"mock:{code}:{device_hash}")
with get_session() as session:
row = session.scalar(select(LicenseActivation).where(
LicenseActivation.code_hash == code_hash,
LicenseActivation.deleted == 0,
))
if row is not None:
if row.device_hash != device_hash:
raise AuthError("LICENSE_DEVICE_MISMATCH", "授权与当前设备不匹配", 409)
if row.status != "active" or row.revoked_at is not None:
raise AuthError("LICENSE_REVOKED", "客户端授权已被停用", 401)
if row.expires_at is not None and row.expires_at <= now:
row.status = "expired"
session.commit()
raise AuthError("LICENSE_EXPIRED", "授权码已到期", 401)
else:
seconds = LICENSE_DURATIONS[license_type]
row = LicenseActivation(
id=uuid.uuid4().hex,
code_hash=code_hash,
code_hint=f"{code[:4]}...{code[-4:]}",
device_hash=device_hash,
user_id=_device_user_id(device_hash),
license_type=license_type,
status="active",
activated_at=now,
expires_at=(now + seconds) if seconds is not None else None,
last_seen_at=now,
revoked_at=None,
tenant_uuid=self.tenant_uuid,
creator_id=None,
updater_id=None,
updated_at=_utc_text(now),
deleted=0,
created_at=_utc_text(now),
)
session.add(row)
session.commit()
session.refresh(row)
identity = self._identity(row)
return identity, self._issue(identity, device_hash)
async def authenticate(self, request: Request) -> IdentityContext:
self._require_desktop(request)
device_hash = _sha256(self._device_id(request))
token = request.cookies.get(DESKTOP_COOKIE) or ""
if not token:
raise AuthError("LICENSE_REQUIRED", "请先使用授权码激活客户端", 401)
return self._verify_token(token, device_hash)
async def refresh(self, request: Request) -> tuple[IdentityContext, str]:
identity = await self.authenticate(request)
device_hash = _sha256(self._device_id(request))
return identity, self._issue(identity, device_hash)
_provider: LicenseProvider | None = None
_provider_mode: str | None = None
def get_license_provider() -> LicenseProvider:
global _provider, _provider_mode
mode = (os.environ.get("APS_LICENSE_PROVIDER") or "unconfigured").strip().lower()
if _provider is None or _provider_mode != mode:
_provider = MockLicenseProvider() if mode == "mock" else UnconfiguredLicenseProvider()
_provider_mode = mode
return _provider