293 lines
12 KiB
Python
293 lines
12 KiB
Python
|
|
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
|