"""Optional browser form authentication for the independently deployed web app. Authentication is disabled for the original local deployment. Cloud/preview deployments opt in explicitly; invalid enabled configuration aborts startup. There is deliberately no HTTP Basic challenge, including on API failures. """ import asyncio import base64 from collections import OrderedDict, deque from dataclasses import dataclass import hashlib import hmac import json import math import os from pathlib import Path import threading import time from time import monotonic from urllib.parse import urlsplit from fastapi import Request from starlette.responses import FileResponse, JSONResponse, RedirectResponse COOKIE_NAME = "wolin_session" SESSION_SECONDS = 8 * 60 * 60 MAX_LOGIN_BYTES = 4096 LOGIN_HTML = Path(__file__).resolve().parents[1] / "frontend" / "login.html" def _flag(name, default): value = os.getenv(name, default).strip().lower() if value in {"true", "1", "yes", "on"}: return True if value in {"false", "0", "no", "off"}: return False raise RuntimeError(f"{name} must be true or false") @dataclass(frozen=True) class _Config: username: str salt: bytes password_key: bytes secret: bytes secure: bool @classmethod def from_environment(cls): username = os.getenv("WOLIN_AUTH_USER", "admin") secret = os.getenv("WOLIN_SESSION_SECRET", "") if not username or len(username) > 128 or len(secret) < 32: raise RuntimeError("Web authentication requires a valid user and a session secret of at least 32 characters") try: algorithm, n, r, p, salt_hex, key_hex = os.getenv("WOLIN_AUTH_PASSWORD_HASH", "").split("$") if (algorithm, n, r, p) != ("scrypt", "16384", "8", "1"): raise ValueError() if len(salt_hex) != 32 or len(key_hex) != 64: raise ValueError() salt, key = bytes.fromhex(salt_hex), bytes.fromhex(key_hex) if len(salt) != 16 or len(key) != 32: raise ValueError() except (ValueError, TypeError): raise RuntimeError("WOLIN_AUTH_PASSWORD_HASH must be a valid scrypt password hash") from None return cls(username, salt, key, secret.encode("utf-8"), _flag("AUTH_COOKIE_SECURE", "true")) def _base_path(scope): return scope.get("root_path", "").rstrip("/") def _route_path(scope): path = scope.get("path", "/") root = _base_path(scope) if root and (path == root or path.startswith(root + "/")): return path[len(root):] or "/" return path def _json(content, status=200, headers=None): return JSONResponse(content, status_code=status, headers={"Cache-Control": "no-store", **(headers or {})}) def _encode(data): return base64.urlsafe_b64encode(data).rstrip(b"=").decode("ascii") def _decode(value): return base64.b64decode(value + "=" * (-len(value) % 4), altchars=b"-_", validate=True) def _session(config): now = int(time.time()) payload = _encode(json.dumps({"u": config.username, "iat": now, "exp": now + SESSION_SECONDS}, separators=(",", ":")).encode("utf-8")) signature = _encode(hmac.digest(config.secret, payload.encode("ascii"), "sha256")) return payload + "." + signature def _valid_session(value, config): if not value or len(value) > 1024: return False try: payload, signature = value.split(".") expected = hmac.digest(config.secret, payload.encode("ascii"), "sha256") if not hmac.compare_digest(expected, _decode(signature)): return False claims = json.loads(_decode(payload)) issued, expires = claims["iat"], claims["exp"] now = time.time() return (claims["u"] == config.username and type(issued) is int and type(expires) is int and expires - issued == SESSION_SECONDS and issued <= now + 60 and now < expires) except (ValueError, TypeError, KeyError, UnicodeError): return False def _cross_site(request): if request.headers.get("sec-fetch-site", "").lower() == "cross-site": return True origin = request.headers.get("origin") if origin is None: return False try: incoming = urlsplit(origin) target = request.url incoming_port = incoming.port or (443 if incoming.scheme == "https" else 80) target_port = target.port or (443 if target.scheme == "https" else 80) return (incoming.scheme not in {"http", "https"} or incoming.username is not None or incoming.password is not None or incoming.path not in {"", "/"} or bool(incoming.query or incoming.fragment) or incoming.scheme != target.scheme or incoming.hostname != target.hostname or incoming_port != target_port) except (ValueError, TypeError): return True class _LoginLimiter: """Bounded per-process attempts; successful authentication clears failures.""" def __init__(self): self.entries = OrderedDict() self.lock = threading.Lock() def reserve(self, address): now = monotonic() with self.lock: for key in list(self.entries): if self.entries[key][-1] <= now - 60: del self.entries[key] attempts = self.entries.setdefault(address, deque()) while attempts and attempts[0] <= now - 60: attempts.popleft() self.entries.move_to_end(address) if len(attempts) >= 5: return max(1, math.ceil(60 - (now - attempts[0]))) attempts.append(now) while len(self.entries) > 4096: self.entries.popitem(last=False) return 0 def clear(self, address): with self.lock: self.entries.pop(address, None) class _AuthMiddleware: def __init__(self, app, config): self.app = app self.config = config async def __call__(self, scope, receive, send): if scope["type"] != "http": if scope["type"] == "websocket": # No unauthenticated websocket path may bypass the web gate. await send({"type": "websocket.close", "code": 1008}) return await self.app(scope, receive, send) return request = Request(scope, receive) path = _route_path(scope) method = request.method public = ((method in {"GET", "HEAD"} and path == "/login") or (method == "GET" and path == "/healthz") or (method == "POST" and path in {"/auth/login", "/auth/logout"})) authenticated = _valid_session(request.cookies.get(COOKIE_NAME), self.config) check_origin = ((method == "POST" and path in {"/auth/login", "/auth/logout"}) or (authenticated and method in {"POST", "PUT", "PATCH", "DELETE"})) if check_origin and _cross_site(request): response = _json({"detail": "不允许跨站请求。"}, 403) elif public or authenticated: await self.app(scope, receive, send) return else: api_path = (path == "/api" or path.startswith("/api/") or path in {"/docs", "/redoc", "/openapi.json", "/static", "/auth/login", "/auth/logout"} or path.startswith(("/static/", "/docs/", "/redoc/"))) if method in {"GET", "HEAD"} and "text/html" in request.headers.get("accept", "") and not api_path: response = RedirectResponse(_base_path(scope) + "/login", status_code=303, headers={"Cache-Control": "no-store"}) else: response = _json({"detail": "请先登录沃林学生管理系统。"}, 401) await response(scope, receive, send) def install_web_auth(app): """Install once, after other middleware, so authentication is outermost.""" if not _flag("AUTH_ENABLED", "false"): return config = _Config.from_environment() limiter = _LoginLimiter() async def login_page(request: Request): return FileResponse(LOGIN_HTML, media_type="text/html", headers={"Cache-Control": "no-store"}) async def login(request: Request): address = request.client.host if request.client else "unknown" retry_after = limiter.reserve(address) if retry_after: return _json({"detail": "登录尝试过多,请稍后重试。"}, 429, {"Retry-After": str(retry_after)}) if request.headers.get("content-type", "").split(";")[0].strip().lower() != "application/json": return _json({"detail": "请使用 JSON 提交登录信息。"}, 415) body = bytearray() try: async for chunk in request.stream(): if len(body) + len(chunk) > MAX_LOGIN_BYTES: return _json({"detail": "登录信息过长。"}, 413) body.extend(chunk) data = json.loads(body) username, password = data.get("username"), data.get("password") if (not isinstance(username, str) or not isinstance(password, str) or not 1 <= len(username) <= 128 or not 1 <= len(password) <= 1024): return _json({"detail": "登录信息格式不正确。"}, 400) except (ValueError, UnicodeError, AttributeError, TypeError): return _json({"detail": "登录信息格式不正确。"}, 400) try: candidate = await asyncio.to_thread(hashlib.scrypt, password.encode("utf-8"), salt=config.salt, n=16384, r=8, p=1, dklen=32, maxmem=64 * 1024 * 1024) valid_password = hmac.compare_digest(candidate, config.password_key) valid_user = hmac.compare_digest(username.encode("utf-8"), config.username.encode("utf-8")) except (ValueError, UnicodeError): return _json({"detail": "登录信息格式不正确。"}, 400) if not (valid_password and valid_user): return _json({"detail": "账号或密码错误。"}, 401) limiter.clear(address) response = _json({"success": True}) response.set_cookie(COOKIE_NAME, _session(config), max_age=SESSION_SECONDS, path=_base_path(request.scope) + "/", secure=config.secure, httponly=True, samesite="strict") return response async def logout(request: Request): response = _json({"success": True}) response.delete_cookie(COOKIE_NAME, path=_base_path(request.scope) + "/", secure=config.secure, httponly=True, samesite="strict") return response app.add_api_route("/login", login_page, methods=["GET", "HEAD"], include_in_schema=False) app.add_api_route("/auth/login", login, methods=["POST"], include_in_schema=False) app.add_api_route("/auth/logout", logout, methods=["POST"], include_in_schema=False) app.add_middleware(_AuthMiddleware, config=config)