From ad7172367a0d69903e8bb92916f5d927084d8970 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BC=A0=E8=83=9C=E5=AE=87?= <17412268+zzzzz11122222@user.noreply.gitee.com> Date: Thu, 10 Sep 2026 13:56:08 +0800 Subject: [PATCH] feat: add limited visitor tokens --- app/api/controllers/visitor_tokens.py | 14 ++++++ app/api/dependencies/auth.py | 3 +- app/api/schemas/visitor_tokens.py | 9 ++++ app/core/config.py | 1 + app/core/security.py | 35 +++++++++++++++ app/main.py | 2 + tests/unit/api/test_visitor_tokens.py | 14 ++++++ tests/unit/core/test_security.py | 61 ++++++++++++++++++++++++++- 8 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 app/api/controllers/visitor_tokens.py create mode 100644 app/api/schemas/visitor_tokens.py create mode 100644 tests/unit/api/test_visitor_tokens.py diff --git a/app/api/controllers/visitor_tokens.py b/app/api/controllers/visitor_tokens.py new file mode 100644 index 0000000..c141bec --- /dev/null +++ b/app/api/controllers/visitor_tokens.py @@ -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) diff --git a/app/api/dependencies/auth.py b/app/api/dependencies/auth.py index 4d96cb3..97414d8 100644 --- a/app/api/dependencies/auth.py +++ b/app/api/dependencies/auth.py @@ -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 diff --git a/app/api/schemas/visitor_tokens.py b/app/api/schemas/visitor_tokens.py new file mode 100644 index 0000000..59f40bc --- /dev/null +++ b/app/api/schemas/visitor_tokens.py @@ -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) diff --git a/app/core/config.py b/app/core/config.py index 6d53a25..96b9082 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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) diff --git a/app/core/security.py b/app/core/security.py index 5d60411..bc8aa7d 100644 --- a/app/core/security.py +++ b/app/core/security.py @@ -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())) diff --git a/app/main.py b/app/main.py index b0c9842..1f0f0da 100644 --- a/app/main.py +++ b/app/main.py @@ -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) diff --git a/tests/unit/api/test_visitor_tokens.py b/tests/unit/api/test_visitor_tokens.py new file mode 100644 index 0000000..bc448f1 --- /dev/null +++ b/tests/unit/api/test_visitor_tokens.py @@ -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 diff --git a/tests/unit/core/test_security.py b/tests/unit/core/test_security.py index 6bdf3e8..8dab5c5 100644 --- a/tests/unit/core/test_security.py +++ b/tests/unit/core/test_security.py @@ -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)