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.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)
|
# F2a-10 / F2d-02: global JWT gate for all /api/* (inside DbSession — uses request.state.db)
|
||||||
app.add_middleware(JwtAuthMiddleware)
|
app.add_middleware(JwtAuthMiddleware)
|
||||||
|
|
||||||
@@ -448,6 +451,107 @@ app.include_router(sync_v2_router)
|
|||||||
# Global Search
|
# Global Search
|
||||||
app.include_router(search_router)
|
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 ──
|
# ── Custom 404: return blank HTML to hide backend identity ──
|
||||||
@app.exception_handler(StarletteHTTPException)
|
@app.exception_handler(StarletteHTTPException)
|
||||||
async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
|
async def custom_http_exception_handler(request: Request, exc: StarletteHTTPException):
|
||||||
|
|||||||
Reference in New Issue
Block a user