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