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:
2026-07-30 12:33:43 +08:00
parent 4139b54a57
commit 804812dd7e
3 changed files with 128 additions and 3 deletions
+91 -3
View File
@@ -7,7 +7,7 @@ from typing import Optional
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from defusedxml import ElementTree as ET from defusedxml import ElementTree as ET
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import PlainTextResponse from fastapi.responses import PlainTextResponse, HTMLResponse
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import select from sqlalchemy import select
@@ -248,12 +248,13 @@ async def test_message(
@router.post("/setup-menu") @router.post("/setup-menu")
async def setup_menu(current_user: dict = Depends(require_director)): async def setup_menu(current_user: dict = Depends(require_director)):
"""Create the standard app menu (开始填报 view + 绑定账号 click).""" """Create the standard app menu (开始填报 view + 绑定账号 click)."""
oauth_url = _build_oauth_url()
buttons = [ buttons = [
{"type": "view", "name": "开始填报", "url": "https://qj.dhdx.fun/m"}, {"type": "view", "name": "开始填报", "url": oauth_url},
{"type": "click", "name": "绑定账号", "key": "BIND_ACCOUNT"}, {"type": "click", "name": "绑定账号", "key": "BIND_ACCOUNT"},
] ]
success = await wecom_client.create_menu(buttons) 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") @router.get("/menu")
@@ -271,3 +272,90 @@ async def trigger_daily_check(
"""Manually trigger the daily reporting check (for testing or manual use).""" """Manually trigger the daily reporting check (for testing or manual use)."""
result = await check_daily_reporting(db) result = await check_daily_reporting(db)
return result 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)
+22
View File
@@ -80,6 +80,28 @@ const router = createRouter({
router.beforeEach((to, _from, next) => { router.beforeEach((to, _from, next) => {
const auth = useAuthStore() const auth = useAuthStore()
// Handle WeChat Work OAuth silent login callback: ?token=JWT
const tokenParam = to.query.token as string
if (tokenParam) {
// Save token to auth store (the JWT contains user_id, name, role, theme)
try {
const payload = JSON.parse(atob(tokenParam.split('.')[1]))
auth.saveLogin({
access_token: tokenParam,
user_id: payload.user_id,
name: payload.name,
role: payload.role,
theme: payload.theme,
})
} catch (_) { /* invalid token, ignore */ }
// Remove token from URL
const cleanQuery = { ...to.query }
delete cleanQuery.token
next({ path: to.path, query: cleanQuery, replace: true })
return
}
if (to.meta.public) { if (to.meta.public) {
next() next()
return return
+15
View File
@@ -9,6 +9,11 @@ const route = useRoute()
const auth = useAuthStore() const auth = useAuthStore()
const loading = ref(false) const loading = ref(false)
function isWecom(): boolean {
const ua = navigator.userAgent || ''
return /wxwork/i.test(ua)
}
function isMobileDevice(): boolean { function isMobileDevice(): boolean {
const ua = navigator.userAgent || '' const ua = navigator.userAgent || ''
return /Android|iPhone|iPad|iPod|webOS/i.test(ua) || window.innerWidth < 768 return /Android|iPhone|iPad|iPod|webOS/i.test(ua) || window.innerWidth < 768
@@ -80,6 +85,16 @@ onMounted(async () => {
return return
} }
// ── WeChat Work silent login: redirect to OAuth (no user interaction needed) ──
if (isWecom()) {
const targetPath = (route.query.redirect as string) || '/m'
const endpoint = import.meta.env.VITE_CASDOOR_ENDPOINT || ''
// In production, the backend builds the OAuth URL; use the API
const apiBase = window.location.origin
window.location.href = `${apiBase}/api/wecom/oauth-url?redirect=${encodeURIComponent(targetPath)}`
return
}
// ── Auto-redirect to Casdoor (no button needed) ── // ── Auto-redirect to Casdoor (no button needed) ──
loading.value = true loading.value = true
goCasdoorLogin() goCasdoorLogin()