import os, secrets, hashlib, hmac, time from fastapi import FastAPI, Form, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from starlette.middleware.base import BaseHTTPMiddleware PWD = os.environ.get("COPAW_PASSWORD", "") SEC = os.environ.get("COPAW_SESSION_SECRET", "") or (secrets.token_hex(32) if PWD else "") MAX = int(os.environ.get("COPAW_SESSION_MAX_AGE", "86400")) ON = bool(PWD) SKIP = {"/auth/login", "/auth/logout", "/health"} EXTS = (".js", ".css", ".png", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".jpg", ".gif", ".wasm") def _mk(): t = str(int(time.time())) k = secrets.token_urlsafe(32) s = hmac.new(SEC.encode(), f"{k}:{t}".encode(), hashlib.sha256).hexdigest() return f"{k}:{t}:{s}" def _ok(v): if not v: return False try: k, t, s = v.split(":") expected = hmac.new(SEC.encode(), f"{k}:{t}".encode(), hashlib.sha256).hexdigest() return hmac.compare_digest(s, expected) and time.time() - int(t) <= MAX except: return False LOGIN_HTML = """ Login

CoPaw

""" class AuthMiddleware(BaseHTTPMiddleware): async def dispatch(self, req, call_next): path = req.url.path # 直接在中间件处理 auth 路由(避免被 catch-all 拦截) if path == "/auth/login": if req.method == "GET": return HTMLResponse(content=LOGIN_HTML) elif req.method == "POST": # 解析 form 数据 form = await req.form() password = form.get("password", "") if ON and secrets.compare_digest(password, PWD): r = JSONResponse({"ok": True, "redirect": "/"}) r.set_cookie("copaw_session", _mk(), max_age=MAX, httponly=True, samesite="lax") return r return JSONResponse({"error": "Invalid password"}, status_code=401) if path == "/auth/logout": r = RedirectResponse(url="/auth/login", status_code=302) r.delete_cookie("copaw_session") return r if path == "/auth/status": return JSONResponse({"authenticated": _ok(req.cookies.get("copaw_session")), "auth_required": ON}) # 未启用鉴权则直接放行 if not ON: return await call_next(req) # 静态资源放行 if any(path.endswith(e) for e in EXTS): return await call_next(req) # 已登录则放行 if _ok(req.cookies.get("copaw_session")): return await call_next(req) # API 返回 401,其他跳转登录 if path.startswith("/api"): return JSONResponse({"error": "Unauthorized"}, status_code=401) return RedirectResponse(f"/auth/login?next={path}", status_code=302) def setup_auth(app: FastAPI): if ON: print(f"[Auth] ENABLED - password protected") else: print(f"[Auth] DISABLED - no COPAW_PASSWORD set") app.add_middleware(AuthMiddleware)