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 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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user