feat: 企业微信OAuth静默登录
- 新增 GET /api/wecom/oauth-url 和 /api/wecom/oauth-callback - OAuth回调返回HTML页面直接写localStorage(避坑企微webview 307重定向不可靠) - 菜单URL改为OAuth静默授权链接(snsapi_base + agentid) - redirect目标编码到state参数中(避坑redirect_uri不允许自定义query) - wecom_userid大小写不敏感匹配 - 前端路由守卫处理 ?token= 自动登录(fallback) - Login.vue企微环境检测自动跳OAuth - setup-menu同步更新为OAuth链接 Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -7,7 +7,7 @@ from typing import Optional
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
from defusedxml import ElementTree as ET
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import PlainTextResponse
|
||||
from fastapi.responses import PlainTextResponse, HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy import select
|
||||
@@ -248,12 +248,13 @@ async def test_message(
|
||||
@router.post("/setup-menu")
|
||||
async def setup_menu(current_user: dict = Depends(require_director)):
|
||||
"""Create the standard app menu (开始填报 view + 绑定账号 click)."""
|
||||
oauth_url = _build_oauth_url()
|
||||
buttons = [
|
||||
{"type": "view", "name": "开始填报", "url": "https://qj.dhdx.fun/m"},
|
||||
{"type": "view", "name": "开始填报", "url": oauth_url},
|
||||
{"type": "click", "name": "绑定账号", "key": "BIND_ACCOUNT"},
|
||||
]
|
||||
success = await wecom_client.create_menu(buttons)
|
||||
return {"success": success, "message": "菜单已部署 (view + click)" if success else "菜单创建失败"}
|
||||
return {"success": success, "message": "菜单已部署 (含静默登录)" if success else "菜单创建失败"}
|
||||
|
||||
|
||||
@router.get("/menu")
|
||||
@@ -271,3 +272,90 @@ async def trigger_daily_check(
|
||||
"""Manually trigger the daily reporting check (for testing or manual use)."""
|
||||
result = await check_daily_reporting(db)
|
||||
return result
|
||||
|
||||
|
||||
# ── OAuth silent login ──
|
||||
|
||||
import urllib.parse
|
||||
from app.services.auth import build_token_for_user
|
||||
|
||||
|
||||
def _build_oauth_url(redirect_path: str = "/m") -> str:
|
||||
"""Build the WeChat Work OAuth2 authorize URL (snsapi_base = silent).
|
||||
The redirect target is encoded in the 'state' param to avoid query-string issues
|
||||
with the OAuth redirect_uri validation."""
|
||||
if not settings.WECOM_CORP_ID:
|
||||
return f"https://qj.dhdx.fun{redirect_path}"
|
||||
params = urllib.parse.urlencode({
|
||||
"appid": settings.WECOM_CORP_ID,
|
||||
"redirect_uri": "https://qj.dhdx.fun/api/wecom/oauth-callback",
|
||||
"response_type": "code",
|
||||
"scope": "snsapi_base",
|
||||
"agentid": settings.WECOM_AGENT_ID,
|
||||
"state": f"r={urllib.parse.quote(redirect_path)}",
|
||||
})
|
||||
return f"https://open.weixin.qq.com/connect/oauth2/authorize?{params}#wechat_redirect"
|
||||
|
||||
|
||||
@router.get("/oauth-url")
|
||||
async def get_oauth_url(redirect: str = Query("/m")):
|
||||
"""Redirect to the WeChat Work OAuth authorize URL (silent login)."""
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=_build_oauth_url(redirect))
|
||||
|
||||
|
||||
@router.get("/oauth-callback")
|
||||
async def oauth_callback(
|
||||
code: str = Query(...),
|
||||
state: str = Query(""),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""WeChat Work OAuth2 callback — exchange code for userid, find bound user, auto-login."""
|
||||
# Parse redirect target from state param: "r=%2Fm"
|
||||
redirect = "/m"
|
||||
if state.startswith("r="):
|
||||
redirect = urllib.parse.unquote(state[2:])
|
||||
|
||||
# Exchange code for wecom userinfo
|
||||
userinfo = await wecom_client.get_userinfo_by_code(code)
|
||||
if not userinfo:
|
||||
raise HTTPException(status_code=400, detail="Failed to exchange code with WeChat Work")
|
||||
|
||||
wecom_userid = userinfo.get("UserId") or userinfo.get("userid")
|
||||
if not wecom_userid:
|
||||
raise HTTPException(status_code=400, detail="Could not get userid from WeChat Work")
|
||||
|
||||
# Find bound user (case-insensitive — WeChat Work userids may vary in case)
|
||||
from sqlalchemy import func as sa_func
|
||||
result = await db.execute(
|
||||
select(User).where(sa_func.lower(User.wecom_userid) == wecom_userid.lower())
|
||||
)
|
||||
user = result.scalar_one_or_none()
|
||||
if not user:
|
||||
# Not bound — redirect to bind page
|
||||
bind_url = f"https://qj.dhdx.fun/wecom-bind?wecom_userid={wecom_userid}"
|
||||
from fastapi.responses import RedirectResponse
|
||||
return RedirectResponse(url=bind_url)
|
||||
|
||||
# Issue JWT and auto-login via HTML page — saves credentials to localStorage, then redirects
|
||||
token = build_token_for_user(user)
|
||||
import json as _json
|
||||
auth_data = _json.dumps({
|
||||
"token": token,
|
||||
"userId": str(user.id),
|
||||
"userName": user.name,
|
||||
"userRole": user.role,
|
||||
"theme": user.theme or "editorial",
|
||||
}, ensure_ascii=False)
|
||||
frontend_url = f"https://qj.dhdx.fun{redirect}"
|
||||
html = f"""<!DOCTYPE html><html><head><meta charset="utf-8"><title>登录中...</title></head>
|
||||
<body><script>
|
||||
var d = {auth_data};
|
||||
localStorage.setItem('token', d.token);
|
||||
localStorage.setItem('userId', d.userId);
|
||||
localStorage.setItem('userName', d.userName);
|
||||
localStorage.setItem('userRole', d.userRole);
|
||||
localStorage.setItem('theme', d.theme);
|
||||
window.location.replace({_json.dumps(frontend_url)});
|
||||
</script></body></html>"""
|
||||
return HTMLResponse(content=html, status_code=200)
|
||||
|
||||
Reference in New Issue
Block a user