反向代理食谱与回调契约
每个反向代理都需要同样的两块:一个 授权方 检查,问 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-appCaddy
给授权方用 forward_auth,用 handle_errors 把 401 变成一个重定向。这是一个起点;按你的 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)。它必须:
- 从 POST 正文 读
cpt_gate和to,绝不从 URL 读(URL 里的通行证会泄漏到日志和引荐来源里)。 - 确认
to在你自己的源上,并拒绝其他任何东西,这样回调就不能被变成一个开放重定向。 - 用下面的属性设置 Cookie。
- 重定向(
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(重试限制和封禁)仍然给通过的人限住凭据重试,所以两层相叠。门禁只保护交互式门户(登录和同意的界面);它不给、也不该给非交互式的令牌端点设门禁。