feat: add limited visitor tokens

This commit is contained in:
张胜宇
2026-09-10 13:56:08 +08:00
parent c6be99078e
commit ad7172367a
8 changed files with 137 additions and 2 deletions
+14
View File
@@ -0,0 +1,14 @@
from fastapi import APIRouter, status
from app.api.schemas.visitor_tokens import VisitorTokenResponse
from app.core.config import get_settings
from app.core.security import VisitorTokenIssuer
router = APIRouter(prefix="/api/v1/visitor-tokens", tags=["visitor-tokens"])
@router.post("", response_model=VisitorTokenResponse, status_code=status.HTTP_201_CREATED)
async def issue_visitor_token() -> VisitorTokenResponse:
settings = get_settings()
token, _expires_at = VisitorTokenIssuer(settings).issue()
return VisitorTokenResponse(access_token=token, expires_in=settings.visitor_token_ttl_seconds)
+2 -1
View File
@@ -24,7 +24,8 @@ async def build_request_context(
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Unauthorized")
try:
context = _authenticator().authenticate(credentials.credentials)
context = await IdentityService().resolve(context)
if "visitor" not in context.roles:
context = await IdentityService().resolve(context)
except Exception as exc:
from app.core.errors import UnauthorizedAgentError
+9
View File
@@ -0,0 +1,9 @@
from pydantic import BaseModel, ConfigDict, Field
class VisitorTokenResponse(BaseModel):
model_config = ConfigDict(frozen=True)
access_token: str = Field(min_length=1)
token_type: str = "Bearer"
expires_in: int = Field(ge=60, le=3600)
+1
View File
@@ -15,6 +15,7 @@ class Settings(BaseSettings):
jwt_private_key_path: str = "config/jwt/jwt-private.pem"
jwt_public_key_path: str = "config/jwt/jwt-public.pem"
jwt_clock_skew_seconds: int = Field(default=30, ge=0)
visitor_token_ttl_seconds: int = Field(default=900, ge=60, le=3600)
mysql_dsn: str
mysql_pool_size: int = Field(default=5, ge=1)
mysql_max_overflow: int = Field(default=10, ge=0)
+35
View File
@@ -1,4 +1,5 @@
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from pathlib import Path
from typing import Protocol
from uuid import uuid4
@@ -20,6 +21,35 @@ class EmptyRevocationStore:
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
@@ -59,4 +89,9 @@ class JwtAuthenticator:
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",),
permissions=("agent:run",),
)
return RequestContext(user_id=str(claims["sub"]), trace_id=str(uuid4()))
+2
View File
@@ -9,6 +9,7 @@ from app.api.controllers.knowledge import router as knowledge_router
from app.api.controllers.offsite_fund import operation_router as offsite_operation_router
from app.api.controllers.offsite_fund import router as offsite_fund_router
from app.api.controllers.public_platform import router as public_platform_router
from app.api.controllers.visitor_tokens import router as visitor_tokens_router
from app.core.config import get_settings
from app.core.errors import AgentError
@@ -27,6 +28,7 @@ def create_app() -> FastAPI:
application.include_router(agent_runs_router)
application.include_router(conversations_router)
application.include_router(public_platform_router)
application.include_router(visitor_tokens_router)
application.include_router(offsite_fund_router)
application.include_router(offsite_operation_router)
application.include_router(knowledge_router)
+14
View File
@@ -0,0 +1,14 @@
from fastapi.testclient import TestClient
from app.main import create_app
def test_visitor_token_endpoint_returns_short_lived_bearer_token() -> None:
with TestClient(create_app()) as client:
response = client.post("/api/v1/visitor-tokens")
assert response.status_code == 201
body = response.json()
assert isinstance(body["access_token"], str)
assert body["token_type"] == "Bearer"
assert body["expires_in"] == 900
+60 -1
View File
@@ -6,7 +6,7 @@ import pytest
from app.core.config import Settings
from app.core.errors import UnauthorizedAgentError
from app.core.security import JwtAuthenticator
from app.core.security import JwtAuthenticator, VisitorTokenIssuer
def _settings() -> Settings:
@@ -39,6 +39,34 @@ def test_authenticate_valid_token() -> None:
assert context.trace_id
def test_authenticate_visitor_token_returns_limited_anonymous_context() -> None:
now = datetime.now(UTC)
private_key = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8")
token = jwt.encode(
{"sub": "2", "iss": "jr-auth", "aud": "jr-agent-platform", "iat": now,
"nbf": now, "exp": now + timedelta(minutes=5), "jti": "visitor-jti-1",
"visitor": True},
private_key,
algorithm="RS256",
)
context = JwtAuthenticator(_settings()).authenticate(token)
assert context.roles == ("visitor",)
assert context.permissions == ("agent:run",)
assert context.customer_ids == ()
def test_visitor_token_issuer_creates_short_lived_limited_token() -> None:
token, expires_at = VisitorTokenIssuer(_settings()).issue()
context = JwtAuthenticator(_settings()).authenticate(token)
assert context.roles == ("visitor",)
assert context.permissions == ("agent:run",)
assert expires_at > datetime.now(UTC)
@pytest.mark.parametrize("subject", ["abc", "", "0", "-1", "1.5", "12", "1" * 21,
"18446744073709551616"])
def test_invalid_numeric_subject_is_unauthorized(subject):
@@ -62,6 +90,37 @@ def test_signed_invalid_subject_returns_401_before_identity_query(monkeypatch):
resolve.assert_not_awaited()
@pytest.mark.asyncio
async def test_visitor_token_skips_identity_database_resolution(monkeypatch):
from unittest.mock import AsyncMock
from fastapi.security import HTTPAuthorizationCredentials
from starlette.requests import Request
from app.api.dependencies.auth import _authenticator, build_request_context
now = datetime.now(UTC)
private_key = Path("config/jwt/jwt-private.pem").read_text(encoding="utf-8")
token = jwt.encode(
{"sub": "2", "iss": "jr-local", "aud": "jr-agent-platform", "iat": now,
"nbf": now, "exp": now + timedelta(minutes=5), "jti": "visitor-jti-2",
"visitor": True},
private_key,
algorithm="RS256",
)
resolve = AsyncMock()
monkeypatch.setattr("app.service.identity_service.IdentityService.resolve", resolve)
_authenticator.cache_clear()
request = Request({"type": "http", "method": "GET", "path": "/"})
context = await build_request_context(
request, HTTPAuthorizationCredentials(scheme="Bearer", credentials=token)
)
assert context.roles == ("visitor",)
resolve.assert_not_awaited()
def test_authenticate_rejects_expired_token() -> None:
with pytest.raises(UnauthorizedAgentError):
now = datetime.now(UTC)