feat: AppAuth 中间件 — 未登录时 /app/ 页面返回空白 HTML 404

通过 nexus_refresh cookie 验证身份,无有效 token 返回空白 HTML,
不暴露系统存在。白名单路径: login/install/vendor/static assets。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-05-30 02:39:48 +08:00
parent 993a234152
commit f91cd847a9
+104
View File
@@ -399,6 +399,9 @@ class SecurityHeadersMiddleware:
app.add_middleware(SecurityHeadersMiddleware)
# App auth: protect /app/ HTML pages behind refresh-cookie (before JWT gate, standalone)
app.add_middleware(AppAuthMiddleware)
# F2a-10 / F2d-02: global JWT gate for all /api/* (inside DbSession — uses request.state.db)
app.add_middleware(JwtAuthMiddleware)
@@ -448,6 +451,107 @@ app.include_router(sync_v2_router)
# Global Search
app.include_router(search_router)
# ── AppAuth: protect /app/ pages behind refresh-cookie auth ──
# Public /app/ paths that anyone can access without login
_APP_PUBLIC_PATHS = frozenset({
"/app/login.html",
"/app/install.html",
"/app/forgot-password.html",
})
_APP_PUBLIC_PREFIXES = (
"/app/vendor/", # third-party JS/CSS
"/app/api.js", # shared API module (no auth on its own)
"/app/layout.js", # shared layout module
)
def _extract_cookie(scope: dict, name: str) -> str | None:
"""Extract a cookie value from ASGI scope headers."""
for header_name, header_value in scope.get("headers", []):
if header_name == b"cookie":
val = header_value.decode("latin-1", errors="replace")
for part in val.split("; "):
if part.startswith(f"{name}="):
return part[len(name) + 1:]
return None
def _is_app_public_path(path: str) -> bool:
"""Check if the /app/ path is publicly accessible (no login required)."""
if path in _APP_PUBLIC_PATHS:
return True
for prefix in _APP_PUBLIC_PREFIXES:
if path.startswith(prefix):
return True
# Allow non-HTML static assets (CSS, JS, images, fonts)
ext = path.rsplit(".", 1)[-1].lower() if "." in path else ""
if ext in ("css", "js", "svg", "png", "ico", "woff", "woff2"):
return True
return False
class AppAuthMiddleware:
"""Pure ASGI middleware: verify nexus_refresh cookie on /app/ HTML pages.
Without a valid refresh cookie → blank HTML 404 (don't leak that the app exists).
Whitelisted paths (login, install, vendor, static assets) are always allowed.
NOTE: Uses pure ASGI (not BaseHTTPMiddleware) to avoid MissingGreenlet errors.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope["type"] != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
# Only protect /app/ HTML pages
if not path.startswith("/app/"):
await self.app(scope, receive, send)
return
if _is_install_mode():
await self.app(scope, receive, send)
return
if _is_app_public_path(path):
await self.app(scope, receive, send)
return
# Check nexus_refresh cookie
token = _extract_cookie(scope, REFRESH_COOKIE_NAME)
if not token:
response = HTMLResponse(status_code=404)
await response(scope, receive, send)
return
# Verify JWT — use the same verification as JwtAuthMiddleware
try:
import jwt as pyjwt
payload = pyjwt.decode(
token, settings.SECRET_KEY, algorithms=["HS256"],
options={"require": ["exp", "sub"]},
)
except Exception:
response = HTMLResponse(status_code=404)
await response(scope, receive, send)
return
admin_id = payload.get("sub")
if not admin_id:
response = HTMLResponse(status_code=404)
await response(scope, receive, send)
return
# Token is valid — user is authenticated; let them through
await self.app(scope, receive, send)
# ── Custom 404: return blank HTML to hide backend identity ──
@app.exception_handler(StarletteHTTPException)
async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):