Files
group_fqcd_jr/app/core/security.py
T

99 lines
3.8 KiB
Python

from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Protocol
from uuid import uuid4
import jwt
from app.core.config import Settings
from app.core.contracts import RequestContext
from app.core.errors import UnauthorizedAgentError
class RevocationStore(Protocol):
def is_revoked(self, jti: str) -> bool: ...
@dataclass(frozen=True)
class EmptyRevocationStore:
def is_revoked(self, jti: str) -> bool:
return False
class VisitorTokenIssuer:
"""Issues short-lived anonymous tokens for public customer-service access."""
def __init__(self, settings: Settings) -> None:
self._settings = settings
self._private_key = self._load_private_key()
def _load_private_key(self) -> str:
path = Path(self._settings.jwt_private_key_path)
if not path.is_absolute():
path = Path.cwd() / path
try:
return path.read_text(encoding="utf-8")
except OSError as exc:
raise RuntimeError(f"JWT private key cannot be read: {path}") from exc
def issue(self) -> tuple[str, datetime]:
now = datetime.now(UTC)
expires_at = now + timedelta(seconds=self._settings.visitor_token_ttl_seconds)
subject = str(uuid4().int % 9_000_000_000_000_000_000 + 1)
token = jwt.encode(
{"sub": subject, "iss": self._settings.jwt_issuer,
"aud": self._settings.jwt_audience, "iat": now, "nbf": now,
"exp": expires_at, "jti": str(uuid4()), "visitor": True},
self._private_key, algorithm=self._settings.jwt_algorithm,
)
return token, expires_at
class JwtAuthenticator:
def __init__(self, settings: Settings, revocation_store: RevocationStore | None = None) -> None:
self._settings = settings
self._revocation_store = revocation_store or EmptyRevocationStore()
self._public_key = self._load_public_key()
def _load_public_key(self) -> str:
path = Path(self._settings.jwt_public_key_path)
if not path.is_absolute():
path = Path.cwd() / path
try:
return path.read_text(encoding="utf-8")
except OSError as exc:
raise RuntimeError(f"JWT public key cannot be read: {path}") from exc
def authenticate(self, token: str) -> RequestContext:
if not token:
raise UnauthorizedAgentError("missing bearer token")
try:
claims = jwt.decode(
token,
self._public_key,
algorithms=[self._settings.jwt_algorithm],
issuer=self._settings.jwt_issuer,
audience=self._settings.jwt_audience,
leeway=self._settings.jwt_clock_skew_seconds,
options={"require": ["sub", "iss", "aud", "exp", "nbf", "jti"]},
)
except jwt.PyJWTError as exc:
raise UnauthorizedAgentError("invalid bearer token") from exc
jti = str(claims["jti"])
if self._revocation_store.is_revoked(jti):
raise UnauthorizedAgentError("revoked bearer token")
subject = claims["sub"]
if (not isinstance(subject, str) or not subject.isascii()
or not subject.isdecimal() or len(subject) > 20
or not 0 < int(subject) <= 18446744073709551615):
raise UnauthorizedAgentError("invalid subject")
if claims.get("visitor") is True:
return RequestContext(
user_id=str(subject), trace_id=str(uuid4()), roles=("visitor",),
# 访客仅可运行 Agent 与读取已发布的公共知识,绝不含个人数据权限。
permissions=("agent:run", "knowledge:query"), data_scope="public",
)
return RequestContext(user_id=str(claims["sub"]), trace_id=str(uuid4()))