# 企业微信 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 = """ """ 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 → 跳首页 → 已登录 ```