Decap CMS 登录循环排查

Date 2026-05-02 · Category blog · Status finished · Confidence likely
系统排障, 工程实践

现象

Decap CMS 登录循环排查 阅读导航图

博客后台使用 Decap CMS,GitHub 登录走自建 OAuth 代理。流程看起来正常:点击 /admin/ 里的 “Login with GitHub”,跳转 GitHub,授权成功,然后回到站点。

但结果是:CMS 仍停在登录页,像是 token 从来没有传回来。

这类问题最容易误判成 GitHub OAuth 配置错误。实际排查下来,根因在 Decap CMS 的弹窗通信协议和 origin 校验。


当前登录链路

浏览器 /admin/
  ↓
Decap CMS 打开 OAuth 弹窗
  ↓
/oauth/auth → GitHub 授权
  ↓
/oauth/callback → OAuth 代理换 token
  ↓
弹窗把 token 传回 CMS 主窗口

关键点是最后一步:Decap CMS 不是单纯依赖 URL 重定向,而是用弹窗和主窗口之间的 postMessage 完成认证。


第一处错误:回调路径不对

最初的 OAuth 代理在拿到 token 后执行:

res.redirect(sd.redirect_uri + '/#/callback?access_token=' + token);

这个路径会跳到站点根目录下的 /#/callback,但 CMS 后台在 /admin/。所以第一版修复是改成:

/admin/#/callback

这一步解决了路径问题,但登录仍然失败。说明问题不只是“跳错页面”。


第二处错误:把弹窗协议当成重定向协议

继续看 Decap CMS 的认证流程后,发现它使用的是弹窗握手:

CMS 主窗口                               OAuth 弹窗
   │                                         │
   │ window.open(auth_url) ───────────────>  │
   │                                         │
   │ <──── authorizing:github                │
   │                                         │
   │ ───── 确认消息 ─────────────────────>   │
   │                                         │
   │ <──── authorization:github:success:...  │
   │                                         │
   │ 关闭弹窗,完成登录                       │

也就是说,/oauth/callback 不应该只做 redirect,而应该返回一个 HTML 页面,由这个页面在弹窗里执行脚本,把 token 用 postMessage 发给 opener。


第三处错误:origin 校验失败

我把 callback 改成 postMessage 后,弹窗能发出第一条消息:

authorizing:github

调试日志也显示 CMS 主窗口收到了:

[DEBUG] CMS got message: "authorizing:github" origin: https://techloop.online

但 CMS 没有回复握手确认。问题出在 Decap CMS 内部的校验逻辑:

handshakeCallback(e, t) {
  const r = (n) => {
    if (n.data === "authorizing:" + e.provider && n.origin === this.base_url) {
      window.opener.postMessage(n.data, n.origin);
    }
  };
  return r;
}

它要求 n.origin === this.base_url。而当时的配置是:

backend:
  base_url: https://techloop.online/oauth

浏览器事件里的 origin 只有协议、域名和端口,不包含路径。因此:

n.origin  = https://techloop.online
base_url  = https://techloop.online/oauth

两者不相等,CMS 直接忽略了这条消息。


最终修复

先改 Decap CMS 配置,把域名和路径拆开:

backend:
  name: github
  repo: your-username/your-repo
  branch: main
  base_url: https://techloop.online
  auth_endpoint: oauth/auth

再让 OAuth 代理的 /callback 返回 HTML,并按 Decap CMS 期望的消息格式发送 token:

app.get('/callback', async (req, res) => {
  // ... 用 code 换取 access_token ...
  const content = JSON.stringify({ token: td.access_token, provider: 'github' });

  res.set('Content-Type', 'text/html');
  res.send('<!DOCTYPE html><html><body><script>' +
    '(function(){' +
    'var d=' + content + ';' +
    'var msg="authorization:github:success:"+JSON.stringify(d);' +
    'function receiveMessage(e){' +
    'window.opener.postMessage(msg,e.origin);' +
    'window.removeEventListener("message",receiveMessage,false);' +
    '}' +
    'window.addEventListener("message",receiveMessage,false);' +
    'window.opener.postMessage("authorizing:github","*");' +
    'setTimeout(function(){' +
    'window.opener.postMessage(msg,"https://your-domain.com");' +
    '},1500);' +
    '})()' +
    '</script></body></html>');
});

这里保留了一个 1.5 秒超时回退。正常情况下,弹窗会先发送 authorizing:github,等主窗口回复后再把 token 发回去;如果握手因为时序问题失败,回退逻辑至少还能再尝试一次。


根因整理

层级 问题 修复
回调路径 跳到 /#/callback 改为 CMS 所在路径或使用弹窗页面
通信方式 用重定向传 token 改为 postMessage
origin 校验 base_url/oauth base_url 只保留 origin

真正卡住登录的是第三项。前两项会让流程走偏,第三项则会让 CMS 明明收到了消息,却因为校验失败不处理。


这次排查的经验

  1. 先确认框架的认证模型
    Decap CMS 的 GitHub 登录不是普通页面跳转,而是弹窗通信。协议理解错了,后面修路径、改 redirect 都只能碰运气。

  2. 区分 origin 和 URL
    postMessage 里的 originprotocol://host:port,不包含 path。凡是用 origin 做严格比较的地方,都不能把路径混进去。

  3. 源码比猜测更快
    最关键的信息来自 Decap CMS 打包代码里的 handshakeCallback。看到 n.origin === this.base_url 后,问题基本就确定了。

  4. 给弹窗握手留回退
    弹窗通信依赖两个窗口的加载时序,生产环境里加一个短延迟回退能减少偶发失败。


Decap CMS 期望的消息格式

步骤 方向 消息内容 targetOrigin
1 弹窗 → 主窗口 "authorizing:github" "*"
2 主窗口 → 弹窗 握手确认 e.origin
3 弹窗 → 主窗口 "authorization:github:success:{...}" e.origin

成功消息里的 JSON 至少需要包含:

{
  "token": "...",
  "provider": "github"
}

修复后,GitHub 授权、弹窗回传 token、CMS 写入登录状态这三步才完整闭环。


See also