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: secret = (os.environ.get("APS_MOCK_LICENSE_SECRET") or "").strip() tenant_uuid = ( os.environ.get("APS_MOCK_LICENSE_TENANT_UUID") or os.environ.get("APS_MOCK_TENANT_UUID") or "" ).strip() missing = [] if len(secret) < 32: missing.append("APS_MOCK_LICENSE_SECRET(至少 32 个字符)") if not tenant_uuid: missing.append("APS_MOCK_LICENSE_TENANT_UUID") if missing: raise AuthError( "LICENSE_CONFIG_INVALID", f"开发授权配置缺失:{', '.join(missing)}", 503, ) self.secret = secret.encode() self.tenant_uuid = tenant_uuid 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