401 lines
15 KiB
Python
401 lines
15 KiB
Python
|
|
# ============================================================
|
|||
|
|
# Pi Agent 长期只读凭证(moduleId: auth-agent-tokens, 可重生 ✅)
|
|||
|
|
# 方向 A · P1:/api/agent/* 机器凭证。
|
|||
|
|
# - HMAC(base64url body + secret) 与 licenses.py/providers.py 同风格;
|
|||
|
|
# - token 本身只携带 tokenId/kind/scope/时间;principal/tenant 从登记文件解析;
|
|||
|
|
# - APS_AGENT_TOKEN_SECRET 缺失时签发与鉴权显式失败,绝不回退匿名/本地管理员。
|
|||
|
|
# ============================================================
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import hashlib
|
|||
|
|
import hmac
|
|||
|
|
import json
|
|||
|
|
import os
|
|||
|
|
import threading
|
|||
|
|
import time
|
|||
|
|
import uuid
|
|||
|
|
from base64 import urlsafe_b64decode as _urlsafe_b64decode
|
|||
|
|
from base64 import urlsafe_b64encode as _urlsafe_b64encode
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import Any
|
|||
|
|
|
|||
|
|
from server.auth.context import IdentityContext
|
|||
|
|
from server.auth.providers import AuthError
|
|||
|
|
|
|||
|
|
AGENT_TOKEN_VERSION = 1
|
|||
|
|
AGENT_TOKEN_KIND = "agent.read"
|
|||
|
|
AGENT_TOKEN_SCOPE_READ = "read"
|
|||
|
|
AGENT_TOKEN_FILE_NAME = "agent-tokens.json"
|
|||
|
|
AGENT_TOKEN_MAX_TTL_DAYS = 3650
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _b64encode(raw: bytes) -> str:
|
|||
|
|
return _urlsafe_b64encode(raw).decode("ascii").rstrip("=")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _b64decode_text(raw: str) -> bytes:
|
|||
|
|
return _urlsafe_b64decode(raw + "=" * (-len(raw) % 4))
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _configured_secret() -> bytes:
|
|||
|
|
value = (os.environ.get("APS_AGENT_TOKEN_SECRET") or "").strip()
|
|||
|
|
if not value:
|
|||
|
|
raise AuthError(
|
|||
|
|
"AGENT_TOKEN_NOT_CONFIGURED",
|
|||
|
|
"Pi Agent 凭证服务未配置:请设置 APS_AGENT_TOKEN_SECRET",
|
|||
|
|
503,
|
|||
|
|
)
|
|||
|
|
return value.encode("utf-8")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _default_path() -> Path:
|
|||
|
|
try:
|
|||
|
|
from server.aps_home import data_dir
|
|||
|
|
|
|||
|
|
return data_dir() / AGENT_TOKEN_FILE_NAME
|
|||
|
|
except (OSError, RuntimeError, ImportError):
|
|||
|
|
return (
|
|||
|
|
Path(os.environ.get("APS_DATA_DIR", "server/data")) / AGENT_TOKEN_FILE_NAME
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _configured_path() -> Path:
|
|||
|
|
raw = (os.environ.get("APS_AGENT_TOKEN_PATH") or "").strip()
|
|||
|
|
path = Path(raw).expanduser() if raw else _default_path()
|
|||
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
return path.resolve()
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _issuer_roles(issuer: IdentityContext | None) -> set[str]:
|
|||
|
|
if issuer is None:
|
|||
|
|
return {"system", "admin"}
|
|||
|
|
roles = {str(role).lower() for role in (issuer.roles or ())}
|
|||
|
|
if issuer.auth_kind in {"disabled", "local-admin"}:
|
|||
|
|
roles.update({"system", "admin"})
|
|||
|
|
return roles
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _can_manage(issuer: IdentityContext | None, record: dict[str, Any]) -> bool:
|
|||
|
|
if issuer is None:
|
|||
|
|
return True
|
|||
|
|
if str(record.get("tenantUuid") or "") != issuer.tenant_uuid:
|
|||
|
|
return False
|
|||
|
|
roles = _issuer_roles(issuer)
|
|||
|
|
return bool(roles & {"system", "admin"})
|
|||
|
|
|
|||
|
|
|
|||
|
|
class AgentTokenService:
|
|||
|
|
"""File-backed agent read-token registry with HMAC-signed tokens."""
|
|||
|
|
|
|||
|
|
def __init__(self, secret: bytes | None = None, path: Path | None = None) -> None:
|
|||
|
|
self.secret = secret
|
|||
|
|
self.path = (path or _configured_path()).resolve()
|
|||
|
|
self._lock = threading.RLock()
|
|||
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
|
|||
|
|
def _require_secret(self) -> bytes:
|
|||
|
|
if not self.secret:
|
|||
|
|
raise AuthError(
|
|||
|
|
"AGENT_TOKEN_NOT_CONFIGURED",
|
|||
|
|
"Pi Agent 凭证服务未配置:请设置 APS_AGENT_TOKEN_SECRET",
|
|||
|
|
503,
|
|||
|
|
)
|
|||
|
|
return self.secret
|
|||
|
|
|
|||
|
|
def _load(self) -> dict[str, Any]:
|
|||
|
|
try:
|
|||
|
|
if not self.path.is_file():
|
|||
|
|
return {"version": AGENT_TOKEN_VERSION, "tokens": []}
|
|||
|
|
with self.path.open("r", encoding="utf-8") as handle:
|
|||
|
|
document = json.load(handle)
|
|||
|
|
if not isinstance(document, dict) or not isinstance(
|
|||
|
|
document.get("tokens"), list
|
|||
|
|
):
|
|||
|
|
raise TypeError("registry schema")
|
|||
|
|
return document
|
|||
|
|
except AuthError:
|
|||
|
|
raise
|
|||
|
|
except Exception as exc:
|
|||
|
|
raise AuthError(
|
|||
|
|
"AGENT_TOKEN_STORE_CORRUPT",
|
|||
|
|
f"Pi Agent 凭证登记文件不可读:{self.path}",
|
|||
|
|
503,
|
|||
|
|
) from exc
|
|||
|
|
|
|||
|
|
def _save(self, document: dict[str, Any]) -> None:
|
|||
|
|
import tempfile
|
|||
|
|
|
|||
|
|
with self._lock:
|
|||
|
|
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|||
|
|
fd = None
|
|||
|
|
tmp = None
|
|||
|
|
try:
|
|||
|
|
fd, tmp_name = tempfile.mkstemp(
|
|||
|
|
dir=str(self.path.parent), prefix=".agent-tokens-", suffix=".tmp"
|
|||
|
|
)
|
|||
|
|
tmp = Path(tmp_name)
|
|||
|
|
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|||
|
|
json.dump(document, handle, ensure_ascii=False, indent=2)
|
|||
|
|
os.replace(tmp, self.path)
|
|||
|
|
except BaseException:
|
|||
|
|
if fd is not None:
|
|||
|
|
try:
|
|||
|
|
os.close(fd)
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
if tmp is not None and tmp.exists():
|
|||
|
|
try:
|
|||
|
|
tmp.unlink()
|
|||
|
|
except OSError:
|
|||
|
|
pass
|
|||
|
|
raise
|
|||
|
|
|
|||
|
|
def _sign(self, payload: dict[str, Any]) -> str:
|
|||
|
|
raw = json.dumps(payload, separators=(",", ":"), ensure_ascii=True).encode(
|
|||
|
|
"utf-8"
|
|||
|
|
)
|
|||
|
|
body = _b64encode(raw)
|
|||
|
|
signature = _b64encode(
|
|||
|
|
hmac.new(
|
|||
|
|
self._require_secret(), body.encode("ascii"), hashlib.sha256
|
|||
|
|
).digest()
|
|||
|
|
)
|
|||
|
|
return f"{body}.{signature}"
|
|||
|
|
|
|||
|
|
def _verify_token(self, token: str) -> dict[str, Any]:
|
|||
|
|
secret = self._require_secret()
|
|||
|
|
try:
|
|||
|
|
body, signature = token.split(".", 1)
|
|||
|
|
expected = _b64encode(
|
|||
|
|
hmac.new(secret, body.encode("ascii"), hashlib.sha256).digest()
|
|||
|
|
)
|
|||
|
|
if not hmac.compare_digest(signature, expected):
|
|||
|
|
raise ValueError("signature")
|
|||
|
|
payload = json.loads(_b64decode_text(body).decode("utf-8"))
|
|||
|
|
if (
|
|||
|
|
int(payload.get("v") or 0) != AGENT_TOKEN_VERSION
|
|||
|
|
or str(payload.get("kind") or "") != AGENT_TOKEN_KIND
|
|||
|
|
):
|
|||
|
|
raise ValueError("token shape")
|
|||
|
|
token_id = str(payload.get("tokenId") or "")
|
|||
|
|
if not token_id or len(token_id) > 128:
|
|||
|
|
raise ValueError("token id")
|
|||
|
|
return {
|
|||
|
|
"tokenId": token_id,
|
|||
|
|
"iat": int(payload.get("iat") or 0),
|
|||
|
|
"exp": int(payload.get("exp") or 0),
|
|||
|
|
}
|
|||
|
|
except AuthError:
|
|||
|
|
raise
|
|||
|
|
except Exception as exc:
|
|||
|
|
raise AuthError("AGENT_TOKEN_INVALID", "Pi Agent 凭证无效", 401) from exc
|
|||
|
|
|
|||
|
|
def _find(self, token_id: str) -> dict[str, Any] | None:
|
|||
|
|
document = self._load()
|
|||
|
|
for record in document.get("tokens") or []:
|
|||
|
|
if str(record.get("tokenId") or "") == token_id:
|
|||
|
|
return record
|
|||
|
|
return None
|
|||
|
|
|
|||
|
|
def issue(
|
|||
|
|
self,
|
|||
|
|
principal: IdentityContext,
|
|||
|
|
label: str,
|
|||
|
|
*,
|
|||
|
|
ttl_days: int = 365,
|
|||
|
|
) -> tuple[IdentityContext, str]:
|
|||
|
|
self._require_secret()
|
|||
|
|
label = str(label or "").strip()
|
|||
|
|
if not label or len(label) > 80:
|
|||
|
|
raise AuthError(
|
|||
|
|
"AGENT_TOKEN_LABEL_INVALID", "凭证标签必须为 1-80 字符", 400
|
|||
|
|
)
|
|||
|
|
try:
|
|||
|
|
user_id = int(principal.user_id)
|
|||
|
|
ttl_days = int(ttl_days)
|
|||
|
|
except (TypeError, ValueError) as exc:
|
|||
|
|
raise AuthError(
|
|||
|
|
"AGENT_TOKEN_PRINCIPAL_INVALID", "签发主体标识无效", 400
|
|||
|
|
) from exc
|
|||
|
|
if user_id < 0 or user_id > 2**63 - 1:
|
|||
|
|
raise AuthError(
|
|||
|
|
"AGENT_TOKEN_PRINCIPAL_INVALID", "签发主体 user_id 超出合法范围", 400
|
|||
|
|
)
|
|||
|
|
tenant_uuid = str(principal.tenant_uuid or "").strip()
|
|||
|
|
if not tenant_uuid or len(tenant_uuid) > 64:
|
|||
|
|
raise AuthError(
|
|||
|
|
"AGENT_TOKEN_PRINCIPAL_INVALID", "签发主体租户标识无效", 400
|
|||
|
|
)
|
|||
|
|
if not 1 <= ttl_days <= AGENT_TOKEN_MAX_TTL_DAYS:
|
|||
|
|
raise AuthError("AGENT_TOKEN_TTL_INVALID", "凭证有效期须为 1-3650 天", 400)
|
|||
|
|
|
|||
|
|
now = int(time.time())
|
|||
|
|
token_id = uuid.uuid4().hex
|
|||
|
|
payload = {
|
|||
|
|
"v": AGENT_TOKEN_VERSION,
|
|||
|
|
"kind": AGENT_TOKEN_KIND,
|
|||
|
|
"scope": [AGENT_TOKEN_SCOPE_READ],
|
|||
|
|
"tokenId": token_id,
|
|||
|
|
"iat": now,
|
|||
|
|
"exp": now + ttl_days * 24 * 60 * 60,
|
|||
|
|
}
|
|||
|
|
token = self._sign(payload)
|
|||
|
|
created_by = str(principal.username or principal.fullname or principal.user_id)
|
|||
|
|
record = {
|
|||
|
|
"tokenId": token_id,
|
|||
|
|
"label": label,
|
|||
|
|
"principalUserId": user_id,
|
|||
|
|
"principalUsername": str(principal.username or f"user-{user_id}"),
|
|||
|
|
"principalFullname": str(principal.fullname or ""),
|
|||
|
|
"tenantUuid": tenant_uuid,
|
|||
|
|
"scope": [AGENT_TOKEN_SCOPE_READ],
|
|||
|
|
"iat": now,
|
|||
|
|
"exp": now + ttl_days * 24 * 60 * 60,
|
|||
|
|
"revoked": False,
|
|||
|
|
"createdBy": created_by,
|
|||
|
|
"createdAt": now,
|
|||
|
|
}
|
|||
|
|
with self._lock:
|
|||
|
|
document = self._load()
|
|||
|
|
document["version"] = AGENT_TOKEN_VERSION
|
|||
|
|
document.setdefault("tokens", []).append(record)
|
|||
|
|
self._save(document)
|
|||
|
|
return self._identity_from_record(record), token
|
|||
|
|
|
|||
|
|
@staticmethod
|
|||
|
|
def _identity_from_record(record: dict[str, Any]) -> IdentityContext:
|
|||
|
|
return IdentityContext(
|
|||
|
|
user_id=int(record.get("principalUserId") or 0),
|
|||
|
|
username=str(
|
|||
|
|
record.get("principalUsername") or f"agent-{record.get('tokenId')}"
|
|||
|
|
),
|
|||
|
|
fullname=str(
|
|||
|
|
record.get("principalFullname") or record.get("label") or "Pi Agent"
|
|||
|
|
),
|
|||
|
|
tenant_uuid=str(record.get("tenantUuid") or "platform"),
|
|||
|
|
roles=("pi-agent",),
|
|||
|
|
expires_at=int(record.get("exp") or 0),
|
|||
|
|
auth_kind="agent",
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
def authenticate(self, token: str) -> IdentityContext:
|
|||
|
|
payload = self._verify_token(token)
|
|||
|
|
now = int(time.time())
|
|||
|
|
if payload["exp"] <= now:
|
|||
|
|
raise AuthError("AGENT_TOKEN_EXPIRED", "Pi Agent 凭证已到期", 401)
|
|||
|
|
record = self._find(str(payload["tokenId"]))
|
|||
|
|
if record is None:
|
|||
|
|
raise AuthError("AGENT_TOKEN_INVALID", "Pi Agent 凭证未登记或已删除", 401)
|
|||
|
|
if bool(record.get("revoked")):
|
|||
|
|
raise AuthError("AGENT_TOKEN_REVOKED", "Pi Agent 凭证已吊销", 401)
|
|||
|
|
if (
|
|||
|
|
int(record.get("exp") or 0) != payload["exp"]
|
|||
|
|
or int(record.get("iat") or 0) != payload["iat"]
|
|||
|
|
):
|
|||
|
|
raise AuthError("AGENT_TOKEN_INVALID", "Pi Agent 凭证登记不一致", 401)
|
|||
|
|
if AGENT_TOKEN_SCOPE_READ not in (record.get("scope") or []):
|
|||
|
|
raise AuthError(
|
|||
|
|
"AGENT_TOKEN_SCOPE_INVALID", "Pi Agent 凭证缺少只读作用域", 403
|
|||
|
|
)
|
|||
|
|
return self._identity_from_record(record)
|
|||
|
|
|
|||
|
|
def list_tokens(self, tenant_uuid: str | None = None) -> list[dict[str, Any]]:
|
|||
|
|
document = self._load()
|
|||
|
|
rows: list[dict[str, Any]] = []
|
|||
|
|
for record in document.get("tokens") or []:
|
|||
|
|
if tenant_uuid and str(record.get("tenantUuid") or "") != tenant_uuid:
|
|||
|
|
continue
|
|||
|
|
rows.append(
|
|||
|
|
{
|
|||
|
|
"tokenId": str(record.get("tokenId") or ""),
|
|||
|
|
"label": str(record.get("label") or ""),
|
|||
|
|
"principalUserId": int(record.get("principalUserId") or 0),
|
|||
|
|
"principalUsername": str(record.get("principalUsername") or ""),
|
|||
|
|
"tenantUuid": str(record.get("tenantUuid") or ""),
|
|||
|
|
"scope": list(record.get("scope") or []),
|
|||
|
|
"iat": int(record.get("iat") or 0),
|
|||
|
|
"exp": int(record.get("exp") or 0),
|
|||
|
|
"revoked": bool(record.get("revoked")),
|
|||
|
|
"createdBy": str(record.get("createdBy") or ""),
|
|||
|
|
"createdAt": int(record.get("createdAt") or 0),
|
|||
|
|
}
|
|||
|
|
)
|
|||
|
|
return rows
|
|||
|
|
|
|||
|
|
def revoke(
|
|||
|
|
self,
|
|||
|
|
token_id: str,
|
|||
|
|
*,
|
|||
|
|
tenant_uuid: str | None = None,
|
|||
|
|
issuer: IdentityContext | None = None,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
token_id = str(token_id or "").strip()
|
|||
|
|
if not token_id:
|
|||
|
|
raise AuthError("AGENT_TOKEN_ID_REQUIRED", "缺少凭证 ID", 400)
|
|||
|
|
self._require_secret()
|
|||
|
|
with self._lock:
|
|||
|
|
document = self._load()
|
|||
|
|
for record in document.get("tokens") or []:
|
|||
|
|
if str(record.get("tokenId") or "") != token_id:
|
|||
|
|
continue
|
|||
|
|
if tenant_uuid and str(record.get("tenantUuid") or "") != tenant_uuid:
|
|||
|
|
raise AuthError("AGENT_TOKEN_NOT_FOUND", "凭证不存在", 404)
|
|||
|
|
if not _can_manage(issuer, record):
|
|||
|
|
raise AuthError(
|
|||
|
|
"AGENT_TOKEN_FORBIDDEN", "当前身份无权吊销该凭证", 403
|
|||
|
|
)
|
|||
|
|
record["revoked"] = True
|
|||
|
|
self._save(document)
|
|||
|
|
return {
|
|||
|
|
"tokenId": token_id,
|
|||
|
|
"revoked": True,
|
|||
|
|
"tenantUuid": str(record.get("tenantUuid") or ""),
|
|||
|
|
}
|
|||
|
|
raise AuthError("AGENT_TOKEN_NOT_FOUND", "凭证不存在", 404)
|
|||
|
|
|
|||
|
|
|
|||
|
|
_service: AgentTokenService | None = None
|
|||
|
|
_service_signature: tuple[bytes | None, str] | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def reset_agent_token_service() -> None:
|
|||
|
|
"""丢弃进程级缓存,供环境变更后的测试与运维脚本调用。"""
|
|||
|
|
global _service, _service_signature
|
|||
|
|
_service = None
|
|||
|
|
_service_signature = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _get_service() -> AgentTokenService:
|
|||
|
|
global _service, _service_signature
|
|||
|
|
secret = (os.environ.get("APS_AGENT_TOKEN_SECRET") or "").strip().encode(
|
|||
|
|
"utf-8"
|
|||
|
|
) or None
|
|||
|
|
signature = (secret, str(_configured_path()))
|
|||
|
|
if _service is None or _service_signature != signature:
|
|||
|
|
_service = AgentTokenService(secret=secret, path=Path(signature[1]))
|
|||
|
|
_service_signature = signature
|
|||
|
|
return _service
|
|||
|
|
|
|||
|
|
|
|||
|
|
def issue(
|
|||
|
|
principal: IdentityContext, label: str, *, ttl_days: int = 365
|
|||
|
|
) -> tuple[IdentityContext, str]:
|
|||
|
|
return _get_service().issue(principal, label, ttl_days=ttl_days)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def authenticate(token: str) -> IdentityContext:
|
|||
|
|
return _get_service().authenticate(token)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def list_tokens(tenant_uuid: str | None = None) -> list[dict[str, Any]]:
|
|||
|
|
return _get_service().list_tokens(tenant_uuid=tenant_uuid)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def revoke(
|
|||
|
|
token_id: str,
|
|||
|
|
*,
|
|||
|
|
tenant_uuid: str | None = None,
|
|||
|
|
issuer: IdentityContext | None = None,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
return _get_service().revoke(token_id, tenant_uuid=tenant_uuid, issuer=issuer)
|