From f91cd847a9243101217d99c37e8caad35efdf08a Mon Sep 17 00:00:00 2001 From: Your Name Date: Sat, 30 May 2026 02:39:48 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20AppAuth=20=E4=B8=AD=E9=97=B4=E4=BB=B6?= =?UTF-8?q?=20=E2=80=94=20=E6=9C=AA=E7=99=BB=E5=BD=95=E6=97=B6=20/app/=20?= =?UTF-8?q?=E9=A1=B5=E9=9D=A2=E8=BF=94=E5=9B=9E=E7=A9=BA=E7=99=BD=20HTML?= =?UTF-8?q?=20404?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通过 nexus_refresh cookie 验证身份,无有效 token 返回空白 HTML, 不暴露系统存在。白名单路径: login/install/vendor/static assets。 Co-Authored-By: Claude Opus 4.7 (1M context) --- server/main.py | 104 +++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 104 insertions(+) diff --git a/server/main.py b/server/main.py index 6c4d241c..d818228a 100644 --- a/server/main.py +++ b/server/main.py @@ -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):