Files
student_management_system/service/web_auth.py
T
2026-09-21 17:30:02 +08:00

261 lines
11 KiB
Python

"""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)