Caputchin
代理门禁

反向代理食谱与回调契约

查看 Markdown

每个反向代理都需要同样的两块:一个 授权方 检查,问 Caputchin 一个请求是否带着有效通行证;以及你源上的一个 回调,把已解通行证变成 cpt_gate Cookie。nginx 演练 从头到尾展示了这个形状;这一页给出其他代理、确切的回调契约,以及一个完整的 Authelia 例子。全程把 YOUR_SITE_KEY 换成你的公钥。

端点在各处都相同:

  • 授权方GET https://verify.caputchin.com/v1/gate/authz?site=YOUR_SITE_KEY,带着访客被转发的 Cookie 头。204 意味着允许,401 意味着挑战。
  • 挑战:把浏览器重定向到 https://verify.caputchin.com/v1/gate/challenge?site=YOUR_SITE_KEY&return=<原始 URL>

Traefik

给授权方用一个 forwardAuth 中间件。Traefik 默认转发进来的 Cookie 头。当中间件返回 401 时,把浏览器重定向到挑战(一个指向发出 302 的小路由的错误页中间件,或者在你的边缘处理 401)。

http:
  middlewares:
    cpt-gate:
      forwardAuth:
        address: "https://verify.caputchin.com/v1/gate/authz?site=YOUR_SITE_KEY"
        # The 401 from forwardAuth is your signal to send the browser to:
        #   https://verify.caputchin.com/v1/gate/challenge?site=YOUR_SITE_KEY&return=<original-url>
  routers:
    portal:
      rule: "Host(`auth.example.com`)"
      middlewares: ["cpt-gate"]
      service: your-app

Caddy

给授权方用 forward_auth,用 handle_errors401 变成一个重定向。这是一个起点;按你的 Caddy 版本调整指令语法。

auth.example.com {
  forward_auth https://verify.caputchin.com {
    uri /v1/gate/authz?site=YOUR_SITE_KEY
    copy_headers Cookie
  }
  handle_errors {
    @challenge expression {http.error.status_code} == 401
    redir @challenge https://verify.caputchin.com/v1/gate/challenge?site=YOUR_SITE_KEY&return={scheme}://{host}{uri} 302
  }
  reverse_proxy your-app:8080
}

回调

托管挑战页在结束时会向 https://<你的源>/__cpt/callback 自动提交一个 POST 表单,在 正文 里带两个字段:cpt_gate(通行证)和 to(把访客送去哪里)。把这一条路由加到你的源上(或者作为代理上一个专门的 location)。它必须:

  1. 从 POST 正文cpt_gateto,绝不从 URL 读(URL 里的通行证会泄漏到日志和引荐来源里)。
  2. 确认 to 在你自己的源上,并拒绝其他任何东西,这样回调就不能被变成一个开放重定向。
  3. 用下面的属性设置 Cookie。
  4. 重定向(303)到 to
// Any tiny handler on your origin works. Express shown; ~15 lines.
app.post("/__cpt/callback", express.urlencoded({ extended: false }), (req, res) => {
  const pass = String(req.body.cpt_gate || "");
  const to = String(req.body.to || "/");
  const target = new URL(to, `https://${req.headers.host}`);
  if (target.host !== req.headers.host) return res.status(400).end(); // same-origin only
  res.setHeader(
    "Set-Cookie",
    `cpt_gate=${pass}; Path=/; HttpOnly; Secure; SameSite=Lax; Max-Age=1800`
  );
  res.redirect(303, target.pathname + target.search);
});

Cookie 属性就是整个安全模型。因为通行证不绑定设备,HttpOnly(挡住脚本窃取)、Secure(只走 HTTPS)、SameSite=Lax,以及一个短的 Max-Age(与你的通行 TTL 匹配)就是保护它的东西。另外:不要在你的访问日志里记录 cpt_gate 的值,并在回调上保持 Referrer-Policy: no-referrer,这样通行证就绝不会搭在一个引荐来源头里一起走。

完整范例:Authelia

Authelia 的登录门户是一个编译好的单页应用,没有地方嵌入组件,也没有插件钩子,所以门禁就是保护它的办法。Authelia 本来就坐在一个反向代理之后(它自己的 ForwardAuth 就是这么工作的),而那个代理就是门禁所在之处。Authelia 本身被完全原封不动地留着。

把门禁放在门户的 vhost 上(例如 auth.example.com):

  • 门户 HTML 和第一因子登录端点(/api/firstfactor设门禁
  • 排除 Authelia 自己的 ForwardAuth verify 端点(/api/verify),代理为每个受保护的上游请求都会调用它。给它设门禁会弄坏 Authelia 之后的每个受保护应用。
  • 排除 健康检查和挑战页自身需要的静态资源。
  • 加上 上面那条 /__cpt/callback 路由。

门禁把机器人挡在门户之外;Authelia 内置的 Regulation(重试限制和封禁)仍然给通过的人限住凭据重试,所以两层相叠。门禁只保护交互式门户(登录和同意的界面);它不给、也不该给非交互式的令牌端点设门禁。

另见

  • 设置代理门禁:nginx 演练和预览模式优先的推出。
  • 总览:概念和通行 Cookie。
  • 统计:通过观察挑战对直接放行来确认接线。

本页内容