aps-agent/server/auth/agent_tokens.py

401 lines
15 KiB
Python
Raw Normal View History

# ============================================================
# 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)