2026-07-28 02:12:46 +08:00
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-08-11 19:01:05 +08:00
|
|
|
|
import hashlib
|
2026-08-11 00:54:05 +08:00
|
|
|
|
import os
|
2026-08-11 19:01:05 +08:00
|
|
|
|
import re
|
2026-08-11 00:54:05 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
from fastapi import Request
|
|
|
|
|
|
from fastapi.responses import JSONResponse
|
2026-08-11 19:01:05 +08:00
|
|
|
|
from starlette.datastructures import MutableHeaders
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
2026-09-08 00:07:26 +08:00
|
|
|
|
from server.auth import agent_tokens
|
2026-08-11 00:54:05 +08:00
|
|
|
|
from server.auth.context import IdentityContext, bind_identity, reset_identity
|
2026-07-28 02:12:46 +08:00
|
|
|
|
from server.auth.licenses import get_license_provider, is_desktop_request
|
|
|
|
|
|
from server.auth.providers import AuthError, get_auth_provider
|
|
|
|
|
|
|
|
|
|
|
|
PUBLIC_API_PATHS = {
|
|
|
|
|
|
"/api/health",
|
|
|
|
|
|
"/api/auth/login",
|
2026-07-29 23:22:40 +08:00
|
|
|
|
"/api/auth/login/precheck",
|
|
|
|
|
|
"/api/auth/tenants",
|
|
|
|
|
|
"/api/auth/captcha",
|
2026-07-28 02:12:46 +08:00
|
|
|
|
"/api/auth/refresh",
|
|
|
|
|
|
"/api/auth/logout",
|
|
|
|
|
|
"/api/auth/license/activate",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
SELF_AUTHORIZED_WRITE_PREFIXES = (
|
|
|
|
|
|
"/api/workspace",
|
|
|
|
|
|
"/api/projects",
|
|
|
|
|
|
"/api/sessions",
|
|
|
|
|
|
"/api/project-files",
|
2026-08-11 00:54:05 +08:00
|
|
|
|
"/api/drawings", # 图纸解析只读放行;候选入库走 P2 门禁
|
2026-07-28 02:12:46 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-08-11 19:01:05 +08:00
|
|
|
|
ANONYMOUS_COOKIE = "aps_anonymous_id"
|
|
|
|
|
|
ANONYMOUS_COOKIE_MAX_AGE = 365 * 24 * 3600
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
|
2026-08-11 00:54:05 +08:00
|
|
|
|
def authentication_enabled() -> bool:
|
|
|
|
|
|
"""Return whether browser/desktop requests must present login credentials."""
|
|
|
|
|
|
value = (os.environ.get("APS_AUTH_ENABLED") or "1").strip().lower()
|
|
|
|
|
|
return value not in {"0", "false", "no", "off"}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def bypass_identity() -> IdentityContext:
|
|
|
|
|
|
"""Single local administrator identity used only when auth is explicitly disabled."""
|
|
|
|
|
|
return IdentityContext(
|
|
|
|
|
|
user_id=1,
|
|
|
|
|
|
username="local-admin",
|
|
|
|
|
|
fullname="本地免登录管理员",
|
|
|
|
|
|
tenant_uuid="platform",
|
2026-09-08 00:07:26 +08:00
|
|
|
|
roles=(
|
|
|
|
|
|
"system",
|
|
|
|
|
|
"admin",
|
|
|
|
|
|
"planner",
|
|
|
|
|
|
"approver",
|
|
|
|
|
|
"auditor",
|
|
|
|
|
|
"scheduler",
|
|
|
|
|
|
"desktop",
|
|
|
|
|
|
),
|
2026-08-11 00:54:05 +08:00
|
|
|
|
auth_kind="disabled",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 19:01:05 +08:00
|
|
|
|
def _visitor_identity(visitor: str) -> IdentityContext | None:
|
|
|
|
|
|
visitor = str(visitor or "").strip().lower()
|
2026-09-08 00:07:26 +08:00
|
|
|
|
if (
|
|
|
|
|
|
len(visitor) < 8
|
|
|
|
|
|
or len(visitor) > 128
|
|
|
|
|
|
or not re.fullmatch(r"[a-z0-9_-]+", visitor)
|
|
|
|
|
|
):
|
2026-08-11 19:01:05 +08:00
|
|
|
|
return None
|
2026-09-08 00:07:26 +08:00
|
|
|
|
digest = hashlib.sha256(f"aps-anonymous:{visitor}".encode()).hexdigest()
|
2026-08-11 19:01:05 +08:00
|
|
|
|
user_id = int(digest[:15], 16)
|
|
|
|
|
|
return IdentityContext(
|
|
|
|
|
|
user_id=user_id,
|
|
|
|
|
|
username=f"anon-{digest[:10]}",
|
|
|
|
|
|
fullname=f"访客 {digest[:8]}",
|
|
|
|
|
|
tenant_uuid="platform",
|
2026-09-08 00:07:26 +08:00
|
|
|
|
roles=(
|
|
|
|
|
|
"system",
|
|
|
|
|
|
"admin",
|
|
|
|
|
|
"planner",
|
|
|
|
|
|
"approver",
|
|
|
|
|
|
"auditor",
|
|
|
|
|
|
"scheduler",
|
|
|
|
|
|
"desktop",
|
|
|
|
|
|
),
|
2026-08-11 19:01:05 +08:00
|
|
|
|
auth_kind="anonymous",
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def anonymous_visitor_identity(request: Request) -> tuple[IdentityContext, str | None]:
|
|
|
|
|
|
"""免登录云部署:按浏览器访客 ID 派生独立用户作用域。
|
|
|
|
|
|
|
|
|
|
|
|
Web 前端把 localStorage 里稳定的访客 ID 随 X-APS-Visitor-ID 发送;
|
|
|
|
|
|
后端用同一 ID 派生出固定 user_id,并签发长期 HttpOnly Cookie。
|
|
|
|
|
|
之后即使 localStorage 被清空,Cookie 仍能让浏览器找回同一身份。
|
|
|
|
|
|
没有 Cookie/头的旧客户端回退到本地管理员身份,保持既有行为。
|
|
|
|
|
|
"""
|
|
|
|
|
|
identity = _visitor_identity(request.cookies.get(ANONYMOUS_COOKIE))
|
|
|
|
|
|
if identity is not None:
|
|
|
|
|
|
return identity, None
|
|
|
|
|
|
visitor = request.headers.get("x-aps-visitor-id")
|
|
|
|
|
|
identity = _visitor_identity(visitor)
|
|
|
|
|
|
if identity is not None:
|
|
|
|
|
|
return identity, str(visitor).strip().lower()
|
|
|
|
|
|
return bypass_identity(), None
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
class AuthenticationMiddleware:
|
|
|
|
|
|
"""ASGI middleware keeps identity bound for the full streaming response lifetime."""
|
|
|
|
|
|
|
|
|
|
|
|
def __init__(self, app) -> None:
|
|
|
|
|
|
self.app = app
|
|
|
|
|
|
|
|
|
|
|
|
async def __call__(self, scope, receive, send) -> None:
|
|
|
|
|
|
if scope.get("type") != "http":
|
|
|
|
|
|
await self.app(scope, receive, send)
|
|
|
|
|
|
return
|
|
|
|
|
|
path = scope.get("path") or ""
|
2026-09-08 00:07:26 +08:00
|
|
|
|
if (
|
|
|
|
|
|
scope.get("method") == "OPTIONS"
|
|
|
|
|
|
or not path.startswith("/api/")
|
|
|
|
|
|
or path in PUBLIC_API_PATHS
|
|
|
|
|
|
):
|
2026-07-28 02:12:46 +08:00
|
|
|
|
await self.app(scope, receive, send)
|
|
|
|
|
|
return
|
2026-08-11 19:01:05 +08:00
|
|
|
|
request = Request(scope, receive=receive)
|
|
|
|
|
|
anon_cookie: str | None = None
|
2026-09-08 00:07:26 +08:00
|
|
|
|
authorization = request.headers.get("authorization") or ""
|
|
|
|
|
|
agent_request = path.startswith("/api/agent/")
|
|
|
|
|
|
if (
|
|
|
|
|
|
agent_request
|
|
|
|
|
|
and not authorization.lower().startswith("bearer ")
|
|
|
|
|
|
and not authentication_enabled()
|
|
|
|
|
|
):
|
|
|
|
|
|
response = JSONResponse(
|
|
|
|
|
|
{
|
|
|
|
|
|
"error": {
|
|
|
|
|
|
"code": "AGENT_TOKEN_REQUIRED",
|
|
|
|
|
|
"message": "Pi Agent 端点需要 Authorization: Bearer <agent-token>",
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
status_code=401,
|
|
|
|
|
|
)
|
|
|
|
|
|
await response(scope, receive, send)
|
|
|
|
|
|
return
|
|
|
|
|
|
if agent_request and authorization.lower().startswith("bearer "):
|
|
|
|
|
|
try:
|
|
|
|
|
|
identity = agent_tokens.authenticate(authorization[7:].strip())
|
|
|
|
|
|
except AuthError as exc:
|
|
|
|
|
|
response = JSONResponse(
|
|
|
|
|
|
{"error": {"code": exc.code, "message": exc.message}},
|
|
|
|
|
|
status_code=exc.status_code,
|
|
|
|
|
|
)
|
|
|
|
|
|
await response(scope, receive, send)
|
|
|
|
|
|
return
|
|
|
|
|
|
elif authentication_enabled():
|
2026-08-11 00:54:05 +08:00
|
|
|
|
try:
|
2026-09-08 00:07:26 +08:00
|
|
|
|
provider = (
|
|
|
|
|
|
get_license_provider()
|
|
|
|
|
|
if is_desktop_request(request)
|
|
|
|
|
|
else get_auth_provider()
|
|
|
|
|
|
)
|
2026-08-11 00:54:05 +08:00
|
|
|
|
identity = await provider.authenticate(request)
|
|
|
|
|
|
except AuthError as exc:
|
|
|
|
|
|
response = JSONResponse(
|
|
|
|
|
|
{"error": {"code": exc.code, "message": exc.message}},
|
|
|
|
|
|
status_code=exc.status_code,
|
|
|
|
|
|
)
|
|
|
|
|
|
await response(scope, receive, send)
|
|
|
|
|
|
return
|
|
|
|
|
|
else:
|
2026-08-11 19:01:05 +08:00
|
|
|
|
identity, anon_cookie = anonymous_visitor_identity(request)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
token = bind_identity(identity)
|
|
|
|
|
|
try:
|
|
|
|
|
|
method = scope.get("method") or "GET"
|
2026-09-08 00:07:26 +08:00
|
|
|
|
if method not in {"GET", "HEAD", "OPTIONS"} and not path.startswith(
|
|
|
|
|
|
SELF_AUTHORIZED_WRITE_PREFIXES
|
|
|
|
|
|
):
|
2026-07-28 02:12:46 +08:00
|
|
|
|
from server.state.projects import get_project_store
|
2026-09-08 00:07:26 +08:00
|
|
|
|
|
2026-07-28 02:12:46 +08:00
|
|
|
|
try:
|
|
|
|
|
|
get_project_store().require_active_write()
|
|
|
|
|
|
except PermissionError as exc:
|
|
|
|
|
|
response = JSONResponse(
|
|
|
|
|
|
{"error": {"code": "PROJECT_READ_ONLY", "message": str(exc)}},
|
|
|
|
|
|
status_code=403,
|
|
|
|
|
|
)
|
|
|
|
|
|
await response(scope, receive, send)
|
|
|
|
|
|
return
|
2026-08-11 19:01:05 +08:00
|
|
|
|
if anon_cookie:
|
|
|
|
|
|
secure = "; Secure" if request.url.scheme == "https" else ""
|
|
|
|
|
|
cookie_value = (
|
|
|
|
|
|
f"{ANONYMOUS_COOKIE}={anon_cookie}; Path=/; "
|
|
|
|
|
|
f"Max-Age={ANONYMOUS_COOKIE_MAX_AGE}; HttpOnly; SameSite=Lax{secure}"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
async def send_with_cookie(message) -> None:
|
|
|
|
|
|
if message["type"] == "http.response.start":
|
|
|
|
|
|
MutableHeaders(scope=message).append("set-cookie", cookie_value)
|
|
|
|
|
|
await send(message)
|
|
|
|
|
|
|
|
|
|
|
|
await self.app(scope, receive, send_with_cookie)
|
|
|
|
|
|
else:
|
|
|
|
|
|
await self.app(scope, receive, send)
|
2026-07-28 02:12:46 +08:00
|
|
|
|
finally:
|
|
|
|
|
|
reset_identity(token)
|