From 4fbcff2bb48051a9f6415ef946591fc056512f34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=8E=8B=E5=AE=8F=E8=B1=AA?= <2443753184@qq.com> Date: Thu, 3 Sep 2026 18:59:41 +0800 Subject: [PATCH] =?UTF-8?q?feat(auth):=20=E6=94=AF=E6=8C=81=E5=A4=9A?= =?UTF-8?q?=E6=B8=A0=E9=81=93=E7=99=BB=E5=BD=95=E6=80=81=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9Ewebview=E9=87=8D=E5=AE=9A=E5=90=91?= =?UTF-8?q?=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修改get_current_user函数同时支持Authorization header和cookie获取token - 在logout接口中添加JSONResponse并清除webview cookie登录态 - 新增webview模块实现URL token到cookie的转换和安全重定向功能 - 实现域名白名单校验防止开放重定向漏洞 - 在main.py中注册webview路由 --- main.py | 4 ++- routes/auth.py | 22 +++++++++++-- routes/webview.py | 80 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 102 insertions(+), 4 deletions(-) create mode 100644 routes/webview.py diff --git a/main.py b/main.py index 0dc8e52..5eddc4f 100644 --- a/main.py +++ b/main.py @@ -5,7 +5,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent)) from fastapi import FastAPI -from server_prd.routes import auth, checkin, course_vote, feedback, pages, plan, profile, survey, yijing +from server_prd.routes import auth, checkin, course_vote, feedback, pages, plan, profile, survey, webview, yijing app = FastAPI(title="明缘进阶场 API") app.include_router(auth.router) @@ -17,6 +17,7 @@ app.include_router(survey.router) app.include_router(feedback.router) app.include_router(course_vote.router) app.include_router(yijing.router) +app.include_router(webview.router) @app.get("/api/v1/health") @@ -27,3 +28,4 @@ def health(): if __name__ == "__main__": import uvicorn uvicorn.run("server_prd.main:app", host="127.0.0.1", port=8000, reload=True) + diff --git a/routes/auth.py b/routes/auth.py index ff02317..1cfc34c 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -24,10 +24,20 @@ class BindPhoneRequest(BaseModel): def get_current_user(request: Request): + token = "" + + # 1. 优先从 Authorization header 取(小程序 API 调用方式) auth = request.headers.get("Authorization", "") - if not auth.startswith("Bearer "): + if auth.startswith("Bearer "): + token = auth[7:] + + # 2. header 没有则从 cookie 取(webview H5 页面内请求方式) + if not token: + token = request.cookies.get("auth_token", "") + + if not token: raise HTTPException(status_code=401, detail="缺少 token") - token = auth[7:] + session = db.fetch_one( "SELECT s.user_id AS id FROM auth_sessions s " "WHERE s.token=%s AND s.expires_at>NOW()", @@ -98,6 +108,8 @@ def verify(user=Depends(get_current_user)): @router.post("/logout") def logout(request: Request, user=Depends(get_current_user)): + from fastapi.responses import JSONResponse + auth = request.headers.get("Authorization", "") if auth.startswith("Bearer "): db.execute("DELETE FROM auth_sessions WHERE token=%s", (auth[7:],)) @@ -106,4 +118,8 @@ def logout(request: Request, user=Depends(get_current_user)): "UPDATE auth_users SET wechat_openid=NULL WHERE id=%s", (user["id"],), ) - return {"success": True} \ No newline at end of file + resp = JSONResponse(content={"success": True}) + # 同时清除 webview cookie 登录态 + resp.delete_cookie(key="auth_token", path="/") + return resp + diff --git a/routes/webview.py b/routes/webview.py new file mode 100644 index 0000000..7352f9a --- /dev/null +++ b/routes/webview.py @@ -0,0 +1,80 @@ +"""Webview 登录态传递:URL token → cookie → 302 重定向。 + +流程: +1. 小程序端构造 webview URL:/api/v1/webview/redirect?token=xxx&target=xxx +2. web-view 打开该地址,后端验证 token 有效 → 设置 HttpOnly cookie → 302 重定向到 target +3. H5 页面内所有同域请求自动携带 cookie,get_current_user 同时支持 header 和 cookie + +H5 端零改动,权限校验由 H5 自己的接口完成。 +""" +import logging +from datetime import datetime +from urllib.parse import urlparse + +from fastapi import APIRouter, HTTPException, Query, Request +from fastapi.responses import RedirectResponse + +from .. import config, db + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/api/v1/webview", tags=["webview"]) + +# 允许跳转的目标域名白名单(防止开放重定向漏洞) +ALLOWED_REDIRECT_HOSTS = {"m.muguaclub.cn", "muguaclub.cn"} + + +def _is_safe_target(target: str) -> bool: + """校验 target 是否在允许的域名范围内,防止开放重定向漏洞。""" + if not target: + return False + # 相对路径(/xxx 开头)一律允许,同站跳转 + if target.startswith("/") and not target.startswith("//"): + return True + parsed = urlparse(target) + if not parsed.scheme or not parsed.netloc: + return False + if parsed.scheme not in ("http", "https"): + return False + host = parsed.netloc.lower().split(":")[0] + return host in ALLOWED_REDIRECT_HOSTS or any( + host.endswith("." + h) for h in ALLOWED_REDIRECT_HOSTS + ) + + +@router.get("/redirect") +def redirect_with_cookie( + request: Request, + token: str = Query(..., description="会话 token"), + target: str = Query(..., description="目标跳转地址"), +): + """验证 token,设置登录态 cookie,然后 302 重定向到 target。 + + 这个接口由 web-view 直接打开,不需要 Authorization header, + token 通过 URL query 传入,换 cookie 后重定向,URL 中不再暴露 token。 + """ + if not token or len(token) > 128: + raise HTTPException(status_code=400, detail="无效的 token") + if not _is_safe_target(target): + raise HTTPException(status_code=400, detail="无效的跳转地址") + + # 验证 token 有效 + session = db.fetch_one( + "SELECT user_id FROM auth_sessions WHERE token=%s AND expires_at>NOW()", + (token,), + ) + if not session: + raise HTTPException(status_code=401, detail="token 无效或过期") + + # 设置 HttpOnly cookie + 302 重定向 + response = RedirectResponse(url=target, status_code=302) + response.set_cookie( + key="auth_token", + value=token, + max_age=int(config.JWT_TTL_SECONDS), + httponly=True, + secure=request.url.scheme == "https", + samesite="lax", + path="/", + ) + return response