docs: 企业微信OAuth静默登录踩坑记录

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
2026-07-30 12:31:00 +08:00
parent 2187be7aeb
commit 4139b54a57
+153
View File
@@ -0,0 +1,153 @@
# 企业微信 OAuth 静默登录 — 踩坑记录
## 目标
用户在企微内点击菜单 → 自动登录 → 直达应用首页,零手动操作。
## 架构
```
企微菜单(view) → OAuth authorize URL(snsapi_base)
→ 企微静默获取 code
→ 302 到 callback URL
→ 后端换 wecom_userid → 查 DB → 签 JWT
→ 返回 HTML 页面(JS 写 localStorage + 跳转)
→ 前端启动 → auth store 读到 localStorage → 已登录
```
## 踩坑清单
### 坑 1`redirect_uri` 不能夹带自定义 query 参数
**错误做法:**
```
redirect_uri = https://xxx.com/api/callback?redirect=/m
```
企微 OAuth 完成后会在 `redirect_uri` 后追加 `?code=xxx&state=xxx`,但可能覆盖或丢弃原有的 query 参数。
**正确做法:** 把自定义参数编码到 `state` 里:
```python
state = f"r={urllib.parse.quote('/m')}"
redirect_uri = "https://xxx.com/api/callback" # 干净,无额外 query
```
回调时从 `state` 中解析:
```python
redirect = "/m"
if state.startswith("r="):
redirect = urllib.parse.unquote(state[2:])
```
### 坑 2:必须配置可信域名
企微后台 → 应用管理 → 网页授权及 JS-SDK → 设置可信域名。
不加的话 OAuth 直接报错:`redirect_uri 需使用应用可信域名`
验证文件(`WW_verify_xxx.txt`)需要放在前端可访问的静态目录下。
### 坑 3`agentid` 参数
内部应用使用 `snsapi_base` 静默授权时,OAuth URL 必须带 `agentid`
```
?appid=CORPID&agentid=AGENTID&redirect_uri=...
```
不加的话企微可能静默授权失败(表现:跳到登录页而不是自动登录)。
### 坑 4:服务端 302/307 重定向在 webview 中不可靠
**这是最大的坑。** 后端回调返回 HTTP 重定向(307/302),企微内置浏览器可能不跟进,或跟进时丢掉 query 参数。
**解决方案:** 回调返回 HTML 页面,用 JS 做客户端跳转。
```python
html = """<!DOCTYPE html><html><head><meta charset="utf-8"></head>
<body><script>
var d = {auth_data_json};
localStorage.setItem('token', d.token);
localStorage.setItem('userId', d.userId);
localStorage.setItem('userName', d.userName);
localStorage.setItem('userRole', d.userRole);
window.location.replace({frontend_url_json});
</script></body></html>"""
return HTMLResponse(content=html, status_code=200)
```
**为什么直接写 localStorage 而不是 URL 传 token**
- URL `?token=xxx` 需要前端路由守卫解析 JWT`atob` 解码)
- 企微 webview 中 `atob` 可能不可靠
- `localStorage` 是浏览器原生 API,前端 auth store 启动时直接读取
### 坑 5:用户名字符串中的特殊字符
如果直接把用户名拼进 JS 字符串,包含 `'` `"` `\` 会破坏 JS 语法。
**安全做法:** 整个 auth data 用 `json.dumps` 序列化后直接作为 JS 对象字面量:
```python
import json
auth_data = json.dumps({
"token": token,
"userId": str(user.id),
"userName": user.name, # json.dumps 会自动转义特殊字符
"userRole": user.role,
"theme": user.theme or "editorial",
}, ensure_ascii=False)
# 在 HTML 模板中:
# var d = {auth_data}; ← 直接是 JS 对象,不需要 JSON.parse
```
### 坑 6`wecom_userid` 大小写
数据库中存的 `wecom_userid` 可能与 OAuth 返回的大小写不一致(如 `WeiJueSen` vs `weijuesen`)。
**修复:** 查询时做大小写不敏感匹配:
```python
from sqlalchemy import func
result = await db.execute(
select(User).where(func.lower(User.wecom_userid) == wecom_userid.lower())
)
```
### 坑 7Login 页面死循环
Login.vue 检测到企微环境后自动跳 OAuth,但如果 OAuth 回调又回到 Login 页,就形成死循环。
**原因分析:**
- OAuth 成功 → 回调 → 写 localStorage → 跳到首页
- 如果 localStorage 写入失败或 token 无效 → 路由守卫 → /login
- Login 检测 isWecom() → 又跳 OAuth → 死循环
**防护:** 在 Login 页跳 OAuth 前检查是否刚从 OAuth 回来(如 URL 带 `?from_wecom=1`),避免重复跳转。本项目通过 localStorage 写入 + 前端 auth store 从 localStorage 初始化避开了这个问题。
## 调试技巧
1. **后端日志看回调是否被触发:** `grep oauth-callback /var/log/app.log`
2. **HTTP 状态码:** 回调返回 307 说明走到了 RedirectResponse,返回 200 说明走了 HTML 方案
3. **检查绑定状态:** 查数据库确认 `users.wecom_userid` 有值
4. **OAuth URL 自测:** 浏览器直接访问 `/api/wecom/oauth-url`,看 302 跳转的目标 URL 是否正确
## 完整代码量
| 文件 | 新增行数 | 说明 |
|------|---------|------|
| 后端 OAuth 回调 | ~30 行 | `_build_oauth_url` + `oauth-callback` 端点 |
| 前端路由守卫 | ~15 行 | 处理 `?token=` 参数自动登录(本方案中作为 fallback) |
| 前端 Login 页 | ~8 行 | 企微环境检测 + 跳 OAuth |
## 最终效果
用户视角:企微点击「开始填报」→ 白屏 0.5 秒 → 首页。
技术视角:
```
点菜单 → OAuth(snsapi_base静默) → 回调HTML → JS写localStorage → 跳首页 → 已登录
```