feat(auth): 支持多渠道登录态管理并新增webview重定向功能

- 修改get_current_user函数同时支持Authorization header和cookie获取token
- 在logout接口中添加JSONResponse并清除webview cookie登录态
- 新增webview模块实现URL token到cookie的转换和安全重定向功能
- 实现域名白名单校验防止开放重定向漏洞
- 在main.py中注册webview路由
This commit is contained in:
王宏豪
2026-09-03 18:59:41 +08:00
parent f3fbd0377a
commit 4fbcff2bb4
3 changed files with 102 additions and 4 deletions

View File

@@ -5,7 +5,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent))
from fastapi import FastAPI 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 = FastAPI(title="明缘进阶场 API")
app.include_router(auth.router) app.include_router(auth.router)
@@ -17,6 +17,7 @@ app.include_router(survey.router)
app.include_router(feedback.router) app.include_router(feedback.router)
app.include_router(course_vote.router) app.include_router(course_vote.router)
app.include_router(yijing.router) app.include_router(yijing.router)
app.include_router(webview.router)
@app.get("/api/v1/health") @app.get("/api/v1/health")
@@ -27,3 +28,4 @@ def health():
if __name__ == "__main__": if __name__ == "__main__":
import uvicorn import uvicorn
uvicorn.run("server_prd.main:app", host="127.0.0.1", port=8000, reload=True) uvicorn.run("server_prd.main:app", host="127.0.0.1", port=8000, reload=True)

View File

@@ -24,10 +24,20 @@ class BindPhoneRequest(BaseModel):
def get_current_user(request: Request): def get_current_user(request: Request):
token = ""
# 1. 优先从 Authorization header 取(小程序 API 调用方式)
auth = request.headers.get("Authorization", "") 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") raise HTTPException(status_code=401, detail="缺少 token")
token = auth[7:]
session = db.fetch_one( session = db.fetch_one(
"SELECT s.user_id AS id FROM auth_sessions s " "SELECT s.user_id AS id FROM auth_sessions s "
"WHERE s.token=%s AND s.expires_at>NOW()", "WHERE s.token=%s AND s.expires_at>NOW()",
@@ -98,6 +108,8 @@ def verify(user=Depends(get_current_user)):
@router.post("/logout") @router.post("/logout")
def logout(request: Request, user=Depends(get_current_user)): def logout(request: Request, user=Depends(get_current_user)):
from fastapi.responses import JSONResponse
auth = request.headers.get("Authorization", "") auth = request.headers.get("Authorization", "")
if auth.startswith("Bearer "): if auth.startswith("Bearer "):
db.execute("DELETE FROM auth_sessions WHERE token=%s", (auth[7:],)) 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", "UPDATE auth_users SET wechat_openid=NULL WHERE id=%s",
(user["id"],), (user["id"],),
) )
return {"success": True} resp = JSONResponse(content={"success": True})
# 同时清除 webview cookie 登录态
resp.delete_cookie(key="auth_token", path="/")
return resp

80
routes/webview.py Normal file
View File

@@ -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