aps-agent/server/auth/agent_tokens.py

401 lines
15 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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