4139b54a57
Co-Authored-By: Claude <noreply@anthropic.com>
154 lines
5.0 KiB
Markdown
154 lines
5.0 KiB
Markdown
# 企业微信 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())
|
||
)
|
||
```
|
||
|
||
### 坑 7:Login 页面死循环
|
||
|
||
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 → 跳首页 → 已登录
|
||
```
|