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:
+104
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user