588 lines
25 KiB
Python
588 lines
25 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, LicenseCode
|
||
|
||
|
||
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": "永久授权",
|
||
"custom": "自定义授权",
|
||
}
|
||
|
||
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"
|
||
WEB_LICENSE_COOKIE = "aps_license_session"
|
||
|
||
|
||
def is_desktop_request(request: Request) -> bool:
|
||
return (request.headers.get("x-aps-client") or "").strip().lower() == "desktop"
|
||
|
||
|
||
def license_clients() -> frozenset[str]:
|
||
"""授权码机制作用的客户端集合(APS_LICENSE_CLIENTS,默认仅 desktop,兼容既有部署)。"""
|
||
raw = (os.environ.get("APS_LICENSE_CLIENTS") or "desktop").strip().lower()
|
||
return frozenset(item.strip() for item in raw.split(",") if item.strip())
|
||
|
||
|
||
def use_license_auth(request: Request) -> bool:
|
||
"""判定当前请求是否应走授权码认证。
|
||
|
||
- 桌面客户端:始终走授权(与既有行为一致)。
|
||
- Web 客户端:仅当 APS_LICENSE_CLIENTS 包含 web、授权提供方为 codes(正式授权码模式)、
|
||
且未配置 JMS 认证(JMS 环境优先,避免与 MES 集成冲突)时走授权码。
|
||
"""
|
||
if is_desktop_request(request):
|
||
return True
|
||
if "web" not in license_clients():
|
||
return False
|
||
if (os.environ.get("APS_LICENSE_PROVIDER") or "").strip().lower() != "codes":
|
||
return False
|
||
from server.auth.providers import get_auth_provider
|
||
|
||
return get_auth_provider().mode != "jms"
|
||
|
||
|
||
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)
|
||
|
||
|
||
ADMIN_LICENSE_CODE = "aps@Z@Agent@Admin"
|
||
ADMIN_LICENSE_ROLES = ("system", "admin", "planner", "approver", "auditor", "scheduler", "license")
|
||
DEFAULT_CODE_ROLES = ("license", "planner")
|
||
|
||
|
||
class CodeLicenseProvider(LicenseProvider):
|
||
"""正式授权码提供方:授权码即用户身份。
|
||
|
||
每个授权码唯一对应一个稳定的 user_id(由授权码哈希派生),智能体会话、项目、
|
||
主数据等全部按 tenant_uuid + user_id 归属到该授权码;不同授权码之间数据天然隔离,
|
||
同一授权码可在任意浏览器/设备激活并找回同一份数据。
|
||
内置超级管理员授权码 aps@Z@Agent@Admin(永久授权 + 全功能角色),
|
||
其余授权码经 APS_LICENSE_CODES(JSON)发放。
|
||
"""
|
||
|
||
mode = "codes"
|
||
|
||
def __init__(self) -> None:
|
||
secret = (os.environ.get("APS_LICENSE_SECRET") or "").strip()
|
||
if len(secret) < 32:
|
||
raise AuthError(
|
||
"LICENSE_CONFIG_INVALID",
|
||
"授权配置缺失:APS_LICENSE_SECRET(至少 32 个字符)",
|
||
503,
|
||
)
|
||
self.secret = secret.encode()
|
||
tenant = (os.environ.get("APS_LICENSE_TENANT_UUID") or "platform").strip()
|
||
self.tenant_uuid = tenant or "platform"
|
||
|
||
def _catalog(self) -> dict[str, dict[str, Any]]:
|
||
catalog: dict[str, dict[str, Any]] = {
|
||
ADMIN_LICENSE_CODE: {
|
||
"type": "permanent",
|
||
"roles": ADMIN_LICENSE_ROLES,
|
||
"name": "aps-admin",
|
||
},
|
||
}
|
||
configured = (os.environ.get("APS_LICENSE_CODES") or "").strip()
|
||
if not configured:
|
||
# 无环境变量发码时也必须合并面板发放的 DB 授权码,否则新码无法激活
|
||
catalog.update(self._db_codes())
|
||
return catalog
|
||
try:
|
||
data = json.loads(configured)
|
||
except json.JSONDecodeError as exc:
|
||
raise AuthError("LICENSE_CONFIG_INVALID", "授权码配置无效(APS_LICENSE_CODES 须为 JSON)", 503) from exc
|
||
if isinstance(data, dict):
|
||
for code, grant in data.items():
|
||
if not isinstance(code, str) or not code.strip():
|
||
continue
|
||
if isinstance(grant, dict):
|
||
license_type = str(grant.get("type") or "")
|
||
roles = grant.get("roles")
|
||
name = grant.get("name")
|
||
else:
|
||
license_type, roles, name = str(grant), None, None
|
||
if license_type not in LICENSE_DURATIONS:
|
||
continue
|
||
entry: dict[str, Any] = {"type": license_type}
|
||
if isinstance(roles, (list, tuple)) and roles:
|
||
entry["roles"] = tuple(str(role) for role in roles)
|
||
else:
|
||
entry["roles"] = DEFAULT_CODE_ROLES
|
||
if isinstance(name, str) and name.strip():
|
||
entry["name"] = name.strip()[:64]
|
||
catalog[code.strip()] = entry
|
||
# 超管在设置面板发放的授权码(DB 登记,覆盖同名环境配置)
|
||
catalog.update(self._db_codes())
|
||
return catalog
|
||
|
||
def _db_codes(self) -> dict[str, dict[str, Any]]:
|
||
with get_session() as session:
|
||
rows = session.scalars(select(LicenseCode).where(
|
||
LicenseCode.tenant_uuid == self.tenant_uuid,
|
||
LicenseCode.status == "active",
|
||
LicenseCode.deleted == 0,
|
||
)).all()
|
||
result: dict[str, dict[str, Any]] = {}
|
||
for row in rows:
|
||
if row.license_type not in LICENSE_DURATIONS and not row.expires_at:
|
||
continue
|
||
db_entry: dict[str, Any] = {"type": row.license_type, "roles": DEFAULT_CODE_ROLES}
|
||
if row.expires_at:
|
||
db_entry["expiresAt"] = int(row.expires_at)
|
||
if row.name:
|
||
db_entry["name"] = row.name
|
||
result[row.code] = db_entry
|
||
return result
|
||
|
||
@staticmethod
|
||
def _scope(request: Request) -> str:
|
||
scope = "desktop" if is_desktop_request(request) else "web"
|
||
if scope not in license_clients():
|
||
raise AuthError("LICENSE_CLIENT_DISABLED", "授权码在当前部署的该客户端不可用", 403)
|
||
return scope
|
||
|
||
@staticmethod
|
||
def _code_user_id(tenant_uuid: str, code: str) -> int:
|
||
# 授权码即身份:同一授权码恒定派生同一 user_id(保持在有符号 BIGINT 范围内)。
|
||
digest = _sha256(f"aps-license:{tenant_uuid}:{code}")
|
||
return int(digest[:15], 16)
|
||
|
||
def _issue(self, identity: IdentityContext) -> str:
|
||
payload = {**identity.to_dict(), "issuer": "codes"}
|
||
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 _verify_token(self, token: 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))
|
||
activation_id = str(payload.get("activation_id") or "")
|
||
if not activation_id:
|
||
raise ValueError("activation")
|
||
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:
|
||
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)
|
||
code_row = session.scalar(select(LicenseCode).where(
|
||
LicenseCode.code_hash == row.code_hash,
|
||
LicenseCode.deleted == 0,
|
||
))
|
||
if code_row is not None and code_row.status != "active":
|
||
raise AuthError("LICENSE_REVOKED", "授权码已被停用,请联系管理员", 401)
|
||
if now - row.last_seen_at >= 3600:
|
||
row.last_seen_at = now
|
||
session.commit()
|
||
roles = payload.get("roles")
|
||
return IdentityContext(
|
||
user_id=row.user_id,
|
||
username=str(payload.get("username") or "license-user"),
|
||
fullname=str(payload.get("fullname") or "授权用户"),
|
||
tenant_uuid=row.tenant_uuid,
|
||
roles=tuple(str(role) for role in roles) if isinstance(roles, list) and roles else DEFAULT_CODE_ROLES,
|
||
expires_at=row.expires_at or 4_102_444_800, # 2100-01-01;DB 校验仍支持停用/到期
|
||
auth_kind="license",
|
||
license_type=row.license_type,
|
||
license_activated_at=row.activated_at,
|
||
license_expires_at=row.expires_at,
|
||
activation_id=row.id,
|
||
)
|
||
|
||
async def activate(self, request: Request, payload: dict[str, Any]) -> tuple[IdentityContext, str]:
|
||
self._scope(request)
|
||
code = str(payload.get("code") or "").strip()
|
||
if not code:
|
||
raise AuthError("LICENSE_CODE_REQUIRED", "请输入授权码", 400)
|
||
entry = self._catalog().get(code)
|
||
if entry is None:
|
||
raise AuthError("LICENSE_CODE_INVALID", "授权码无效", 401)
|
||
|
||
now = int(time.time())
|
||
code_hash = _sha256(f"codes:{self.tenant_uuid}:{code}")
|
||
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.revoked_at is not None:
|
||
raise AuthError("LICENSE_REVOKED", "授权已被停用", 401)
|
||
if row.expires_at is not None and row.expires_at <= now:
|
||
if row.status != "expired":
|
||
row.status = "expired"
|
||
session.commit()
|
||
raise AuthError("LICENSE_EXPIRED", "授权码已到期,请联系发放方续期", 401)
|
||
if row.status != "active":
|
||
raise AuthError("LICENSE_REVOKED", "授权已被停用", 401)
|
||
row.last_seen_at = now
|
||
session.commit()
|
||
session.refresh(row)
|
||
else:
|
||
seconds = LICENSE_DURATIONS.get(entry["type"])
|
||
absolute_expiry = entry.get("expiresAt")
|
||
if absolute_expiry:
|
||
expires_at = int(absolute_expiry)
|
||
elif seconds is not None:
|
||
expires_at = now + seconds
|
||
else:
|
||
expires_at = None
|
||
if expires_at is not None and expires_at <= now:
|
||
raise AuthError("LICENSE_EXPIRED", "授权码已到期,请联系发放方续期", 401)
|
||
row = LicenseActivation(
|
||
id=uuid.uuid4().hex,
|
||
code_hash=code_hash,
|
||
code_hint=f"{code[:2]}***{code[-2:]}" if len(code) > 4 else "***",
|
||
device_hash=_sha256(f"codes-shared:{self.tenant_uuid}"),
|
||
user_id=self._code_user_id(self.tenant_uuid, code),
|
||
license_type=entry["type"],
|
||
status="active",
|
||
activated_at=now,
|
||
expires_at=expires_at,
|
||
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 = IdentityContext(
|
||
user_id=row.user_id,
|
||
username=str(entry.get("name") or f"lic-{row.code_hint}"),
|
||
fullname="超级管理员" if code == ADMIN_LICENSE_CODE else str(entry.get("name") or "授权用户"),
|
||
tenant_uuid=row.tenant_uuid,
|
||
roles=tuple(entry.get("roles") or DEFAULT_CODE_ROLES),
|
||
expires_at=row.expires_at or 4_102_444_800,
|
||
auth_kind="license",
|
||
license_type=row.license_type,
|
||
license_activated_at=row.activated_at,
|
||
license_expires_at=row.expires_at,
|
||
activation_id=row.id,
|
||
)
|
||
return identity, self._issue(identity)
|
||
|
||
async def authenticate(self, request: Request) -> IdentityContext:
|
||
scope = self._scope(request)
|
||
cookie = DESKTOP_COOKIE if scope == "desktop" else WEB_LICENSE_COOKIE
|
||
token = request.cookies.get(cookie) or ""
|
||
if not token:
|
||
message = "请先使用授权码激活客户端" if scope == "desktop" else "请提供授权码激活后使用"
|
||
raise AuthError("LICENSE_REQUIRED", message, 401)
|
||
return self._verify_token(token)
|
||
|
||
async def refresh(self, request: Request) -> tuple[IdentityContext, str]:
|
||
identity = await self.authenticate(request)
|
||
return identity, self._issue(identity)
|
||
|
||
|
||
_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:
|
||
if mode == "mock":
|
||
_provider = MockLicenseProvider()
|
||
elif mode == "codes":
|
||
_provider = CodeLicenseProvider()
|
||
else:
|
||
_provider = UnconfiguredLicenseProvider()
|
||
_provider_mode = mode
|
||
return _provider
|