---
type: how-to
title: 反向代理食谱与回调契约
summary: 在 Traefik 或 Caddy 上接好代理门禁，托管那个把已解通行证变成 Cookie 的回调，并在不触碰 Authelia 的前提下给它设门禁。
---

每个反向代理都需要同样的两块：一个 **授权方** 检查，问 Caputchin 一个请求是否带着有效通行证；以及你源上的一个 **回调**，把已解通行证变成 `cpt_gate` Cookie。[nginx 演练](/docs/proxy-page-gate/set-up) 从头到尾展示了这个形状；这一页给出其他代理、确切的回调契约，以及一个完整的 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`）。

```yaml
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_errors` 把 `401` 变成一个重定向。这是一个起点；按你的 Caddy 版本调整指令语法。

```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_gate` 和 `to`，绝不从 URL 读（URL 里的通行证会泄漏到日志和引荐来源里）。
2. 确认 `to` 在你自己的源上，并拒绝其他任何东西，这样回调就不能被变成一个开放重定向。
3. 用下面的属性设置 Cookie。
4. 重定向（`303`）到 `to`。

```js
// 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（重试限制和封禁）仍然给通过的人限住凭据重试，所以两层相叠。门禁只保护交互式门户（登录和同意的界面）；它不给、也不该给非交互式的令牌端点设门禁。

## 另见

- [设置代理门禁](/docs/proxy-page-gate/set-up)：nginx 演练和预览模式优先的推出。
- [总览](/docs/proxy-page-gate/overview)：概念和通行 Cookie。
- [统计](/docs/proxy-page-gate/statistics)：通过观察挑战对直接放行来确认接线。
