Files
qiji/docs/wecom-oauth-lessons.md
2026-07-30 12:31:00 +08:00

5.0 KiB
Raw Permalink Blame History

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

目标

用户在企微内点击菜单 → 自动登录 → 直达应用首页,零手动操作。

架构

企微菜单(view) → OAuth authorize URL(snsapi_base)
  → 企微静默获取 code
  → 302 到 callback URL
  → 后端换 wecom_userid → 查 DB → 签 JWT
  → 返回 HTML 页面(JS 写 localStorage + 跳转)
  → 前端启动 → auth store 读到 localStorage → 已登录

踩坑清单

坑 1redirect_uri 不能夹带自定义 query 参数

错误做法:

redirect_uri = https://xxx.com/api/callback?redirect=/m

企微 OAuth 完成后会在 redirect_uri 后追加 ?code=xxx&state=xxx,但可能覆盖或丢弃原有的 query 参数。

正确做法: 把自定义参数编码到 state 里:

state = f"r={urllib.parse.quote('/m')}"
redirect_uri = "https://xxx.com/api/callback"  # 干净,无额外 query

回调时从 state 中解析:

redirect = "/m"
if state.startswith("r="):
    redirect = urllib.parse.unquote(state[2:])

坑 2:必须配置可信域名

企微后台 → 应用管理 → 网页授权及 JS-SDK → 设置可信域名。

不加的话 OAuth 直接报错:redirect_uri 需使用应用可信域名

验证文件(WW_verify_xxx.txt)需要放在前端可访问的静态目录下。

坑 3agentid 参数

内部应用使用 snsapi_base 静默授权时,OAuth URL 必须带 agentid

?appid=CORPID&agentid=AGENTID&redirect_uri=...

不加的话企微可能静默授权失败(表现:跳到登录页而不是自动登录)。

坑 4:服务端 302/307 重定向在 webview 中不可靠

这是最大的坑。 后端回调返回 HTTP 重定向(307/302),企微内置浏览器可能不跟进,或跟进时丢掉 query 参数。

解决方案: 回调返回 HTML 页面,用 JS 做客户端跳转。

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 需要前端路由守卫解析 JWTatob 解码)
  • 企微 webview 中 atob 可能不可靠
  • localStorage 是浏览器原生 API,前端 auth store 启动时直接读取

坑 5:用户名字符串中的特殊字符

如果直接把用户名拼进 JS 字符串,包含 ' " \ 会破坏 JS 语法。

安全做法: 整个 auth data 用 json.dumps 序列化后直接作为 JS 对象字面量:

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

坑 6wecom_userid 大小写

数据库中存的 wecom_userid 可能与 OAuth 返回的大小写不一致(如 WeiJueSen vs weijuesen)。

修复: 查询时做大小写不敏感匹配:

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 → 跳首页 → 已登录