一个密码,一次登录,整个站群 30 天不再问第二遍。它是一层跑在 nginx 里的基础设施 —— 真实的它没有可以点的界面,所以这页把它的判定逻辑照着搬成了一个能点的模拟器: 四个假子站,一枚假 cookie,右边同步显示每一步到底发生了什么。
一个人维护着十几个子站:控制台、后台、私有文章、健身教练端……都不该对公网敞开,但也都不值得各自写一套登录。难点不在「加个密码框」,在下面这几条:
| 每个站各自做登录 | =十几份会话逻辑、十几处可能写错的地方,而且换一个站就要再输一次密码。要的是一处登录、全子域通行。 |
| 现成的边缘方案没有「密码」 | Cloudflare Access 的登录方式只有邮箱验证码 / OAuth / SSO,配不出一条「输对密码就放行」的策略。而日常最想要的恰恰是「懒得去翻邮箱,直接输密码进去」。 |
| 但邮箱验证码也不想丢 | 在别人的电脑上、或忘了密码时,收验证码仍是最好的那条路。所以不是二选一,是两条通道并存。 |
| 下游应用不能信任「上游说他验过了」 | 反代注入的头是可伪造面。所以会话凭证做成自校验的:任何下游都能拿同一把密钥自己验,不需要相信中间经过了谁。 |
| 门坏了 = 所有站一起进不去 | 包括你自己。所以部署链每一步 fail-closed,且必须先确认闸真的能验通,再让 nginx 开始拦 —— 顺序反了就是把自己锁在门外。 |
| 密码不能是唯一的防线 | 一个固定密码就是共享秘密,挡不住耐心。真正让它成立的是限速:一层在 nginx(连 Python 都不进),一层在应用并落盘(重启不清零)。 |
Access 拦在边缘。它罩住哪条路径,源站的密码页在那条路径上就永远轮不到出场。
第一版的结论是「必须取代 Access」:既然它挡在前面,密码页就永远没机会渲染,那就把它全撤掉。 这条结论后来作废了,因为它把「不能叠在同一条路径上」错当成了「不能共存」。
真正的解法是让两者各占一条路径:边缘策略精确绑定在验证码入口那一条 URL 上,密码页那条不受任何影响。 两条路走完都签发同一枚会话 cookie,殊途同归。
| 入口 | 谁在守 | 用户看到 | 邮件谁发 |
|---|---|---|---|
/_gate/login | 无 —— 源站自己的公开页 | 密码输入框 | — |
/_gate/access | Cloudflare 边缘 | 和以前一模一样的邮箱验证码流程 | Cloudflare,零 SMTP 凭证 |
/_gate/email/* | 无 | 自建验证码(后备实现,线上未启用) | 自己的 SMTP relay |
POST /_gate/api/login | 无(同密码那条路) | —(给没有 cookie jar 的客户端,如小程序) | — |
/_gate/access 缩短成 /_gate 或整个域名,
密码登录当场死 —— 而现象是「密码框不见了」,几乎不可能归因到某个控制台里的一行路径配置。
所以有三处机器在盯它:SSOT 审计里一条专门的规则(存在性 + 禁止更宽)、公网验收脚本对两条路径各打一发、
部署脚本读健康端点自述这条入口是否点亮。人记不住的约束,交给机器。
左边是一个模拟浏览器,四个合成子站;右边是这枚 cookie 的当前状态,以及
nginx auth_request 视角下每一步发生了什么。全程纯 JS 状态机,一个请求都不发。
exp 已过;篡改 = 有人把 exp 改到一百年后
却签不出对应的 HMAC —— 这正是「自校验 cookie 不需要服务端会话表」的原因:
改一个字节,签名就对不上,任何一个下游都能自己算出来。
图中蓝色 = 密码通道,绿色 = 邮箱通道,红色 = 拒绝与回跳。两条通道的终点是同一枚 cookie。 上面模拟器右栏打出来的每一条,就是这张图里的一步。
整套接入只有两个 include,但它们不能写在同一个文件里:
server {
include snippets/authgate-endpoints.conf; # server 级,恰好一次
location /coach {
include snippets/authgate-protect.conf; # location 级,每个受保护 location 各一次
proxy_pass ...;
}
}
| 文件 | 里面是什么 | 出现次数的约束 |
|---|---|---|
authgate-endpoints.conf |
/_gate/verify(internal,子请求目标)+ @gate_login(401 时带 next 回跳)+ /_gate/ 反代(登录页与样式表,不受闸保护) |
每个 server 恰好一次(重复定义 nginx 直接不认) |
authgate-protect.conf |
只有两行:auth_request /_gate/verify; 和 error_page 401 = @gate_login; |
每个要保护的 location 各重复一次 |
最容易犯的错是图省事把保护指令写在 server 级——「反正整个站都要保护」。
后果是 /_gate/ 自己也被保护了:想看登录页 → 未登录 → 跳登录页 → 还是未登录 → 无限跳转,
整个站群连同你自己一起进不去,而且这时候你已经没有任何入口能改回来。
把「端点」和「保护」拆成两个文件之后,这个错误在语义上就不成立了: 你手里的两样东西,一样叫「把闸的端点装到这个 server 上」,一样叫「保护这个 location」。 没有一个叫「保护整个 server」的零件可拿。让错误的写法根本没有对应的积木,比在文档里写一行「注意不要……」有效得多。
配套的第二行同样不能省:auth_request 拿到子请求的 401 后,nginx 自己会返一个 401。
少了 error_page 那行,用户看到的是一块白板 401 —— 没有报错、没有入口、没有任何提示,
是那种「功能其实是好的,但用户以为站挂了」的失败。
| 现象 | 根因 | 留下的防线 |
|---|---|---|
| 登录页一点样式都没有,输完密码点提交 404。而所有测试和公网验收脚本全绿。 | 应用路由挂在内部前缀上,nginx 用带路径的 proxy_pass 把对外前缀改写过去。nginx 这侧没错 —— 但页面渲染 HTML 时写的是它自己的路径,浏览器照字面去请求,落到主站兜底。curl 和浏览器走的不是同一条路:测试打的都是「我知道的端点」,没有一条走过页面自己给出的地址。 |
① 对外前缀与应用路由字面相同,nginx 纯透传,前缀漂移结构上不可能再发生;② 前缀单点派生,源码里禁写前缀字面量(有测试扫);③ 一条测试渲染真页面,把每个 href / action 抽出来实际请求一遍;④ 公网验收脚本把同样的事在真域名上再做一遍。 |
| 限速看着配了,实际等于没配(或者反过来,专门误伤自己)。 | 全部流量经 CDN 回源,nginx 看到的对端地址永远是边缘节点,就那么十几个段轮着来。拿它当限速 key,等于把全世界的攻击者和你自己算成同几个「用户」。 | key 改用真实来源 IP 头;该头缺失时兜底到对端地址而不是「没有 key 就不限速」—— 缺省行为必须是更严,不是更松。 |
密码哈希在本地直接抛 ValueError: memory limit exceeded。 |
选定的 scrypt 参数需要约 33.5 MiB 内存,而 OpenSSL 有一道默认 32 MiB 的闸 —— 就差这一点点。 | 显式给足 maxmem 并设上限(上限是防「构造一个参数极大的哈希串把进程 OOM 掉」,哪怕哈希串来自自家配置)。校验时先按参数算内存需求,超限直接判失败。 |
| 配置里明明写了邮箱白名单,登录页却什么都不多出来,而且不报任何错。 | 白名单被两条邮箱通道共用,只配它、两条通道一条都没配 = 静默无效。这种「看起来配了、其实啥也没开」比缺配难查得多。 | 三条互相独立的启动闸,宁可起不来也不半开:任一通道配一半 → 拒绝启动;只配白名单两条都没开 → 拒绝启动。且每条闸都以「你动了这条通道」为前提,否则「只配 A」会被 B 那条误杀 ——这个对称性两边各踩过一次。 |
| 公开端点延迟从 8.7 ms 掉到 573 ms(66 倍),发起者甚至不需要登录。 | JWT 库在密钥缓存里找不到某个 kid 时会强制出网重拉公钥集,而它的缓存不缓存异常 —— 于是每个随机 kid 都必然触发一次真实的 HTTPS 往返。翻到严格档也不解决:拒绝发生在出网之后,钱已经花了。 | 三道闸,缺一不可:① 零网络的结构预检放在最前(垃圾串 / 错算法 / 无 kid 根本进不来);② 未知 kid 负缓存(同一个坏 kid 重放,第二次起零网络);③ 出网预算滑窗(随机 kid 洪水绕得过 ②,绕不过 ③)。另外把出网超时从默认的 30 秒压到 3 秒 —— 这条路径由未认证请求触发,默认值意味着上游一慢,每发请求占住一个线程半分钟。 |
| 登录页有可能变成钓鱼跳板。 | 登录后要跳回你原来那一页,所以 next 参数必须支持跨子域的绝对 URL。而用字符串前缀判断域名是会被骗的。 |
判定一律走 URL 解析后的 hostname(它会剥掉 userinfo),不用裸串匹配;攻击形态逐条钉成测试,并做过反向验证(把判定改错,确认测试真的变红)。下一节可以自己试。 |
这段判定决定「登录成功后允许跳到哪」—— 也就是上面模拟器里 next 参数的去向。
判错一个字符,登录页就成了钓鱼跳板。跑在你的浏览器里,随便输。
| 输入 | 真实目的地 | 判定 | 为什么 |
|---|
https://tianli.cyou@evil.example/ 里 @ 之前那一段是 userinfo(用户名),不是域名,
真实目的地是 evil.example。用 startsWith("https://tianli.cyou") 判就会被它整个骗过去 ——
URL 解析出来的 hostname 会把 userinfo 剥掉,所以正确写法不是「小心一点」,是换一个不会被骗的判据。/\evil.example 看着像站内路径,但浏览器在 Location 头里会把反斜杠当斜杠处理,
于是它等价于协议相对 URL。所以判定前先做一次反斜杠归一化,再用「以 / 开头但不以 // 开头」这条判据。
固定密码是共享秘密,本身没有强度可言 —— 让它成立的是攻击者每猜一次要付出多少时间。 下面按限速参数算给你看(密码空间是说明用的假设值)。
| 在哪一层 | 规则 | 挡的是 | 特点 |
|---|---|---|---|
nginx limit_req | 登录提交口每分钟 12 次;子请求口每秒 120 次 | 洪水 | 连 Python 都不进;key 用真实来源 IP |
| 应用层滑动窗口 | 同一 IP 5 分钟内最多 8 次失败 | 单点慢速爆破 | — |
| 应用层全局指数退避 | 连续失败超过 3 次起,锁 2ⁿ 秒,封顶 1 小时 | 换 IP 的分布式爆破 | 落 sqlite:重启不清零 —— 存内存的话,攻击者只要能触发一次重启(或等一次部署)就免费重置 |
cookie 的值是自校验的三段结构:版本号、过期时间戳、以及对前两者的 HMAC 签名 —— 上面模拟器右栏显示的就是这三段(签名是演示用的假值)。没有服务端会话表,也不需要。
| 因此得到 | 说明 |
|---|---|
| 下游应用可以各自独立验 | 教练端拿同一把密钥自己验这枚 cookie,完全不需要相信 nginx 传来的任何头。信任「上游说他验过了」正是之前栽过的跟头 —— 头是可伪造的,而 cookie 是自校验的,中间经过谁都不影响结论。 |
| 但这是跨仓契约 | 两个仓库互不 import,所以格式靠两侧各钉一组相同的固定向量锁死。改格式必须同时改两处,否则拿着合法 cookie 的人会被下游判成伪造。 |
| 作用域是域级的 | cookie 写在站群的父域上,所以在任一子域登录一次,全部子域通行 —— 这正是「一次登录」的实现方式,也是模拟器里第二个站直接进去的原因。副作用:边缘验证码那条只需要建一个策略绑在主域上,不必每个子域各建一个(那样每个策略各有各的标识,配置面凭空多几倍)。 |
| 代价:不能单枚吊销 | 没有会话表就没有黑名单。唯一的全局吊销手段是轮换签名密钥 —— 所有人当场掉线,包括你自己。这条如实写在文档里,不粉饰。 |
| 给非浏览器客户端的那条签 7 天,不是 30 天 | 小程序没有 cookie jar,凭证必然落在客户端的明文存储里,离开了 HttpOnly 的保护壳。失窃半径 = 全部受闸子域且无法单枚吊销,所以唯一能压的变量就是有效期。代价只是每周多输一次密码。 |
| 形态 | nginx auth_request 后端。一个只听环回口的 Python 进程,由 systemd 托管;对公网没有任何直接入口。 |
| 后端 | FastAPI + uvicorn。核心模块只用标准库(hashlib / hmac / sqlite3)——「这台机器三年后还要能跑」的前提是不依赖任何会腐烂的东西。 |
| 分层 | 核心层 = 密码哈希 / cookie 签发验证 / 限速计数,纯函数 + 一个 sqlite,零 HTTP;路由层零业务判断;边缘 JWT 验签独立成一个模块。分层不是审美,是为了让核心逻辑能被大量测试直接驱动。 |
| 密码 | scrypt(抗 GPU),配置里存的是 scrypt$n$r$p$salt$hash。明文密码绝不入库入配置 —— 填明文会被启动闸直接拒绝。比对用恒定时间函数。 |
| 会话 | HMAC-SHA256 自校验 cookie,域级作用域,30 天;HttpOnly + Secure + SameSite=Lax。 |
| 限速 | 两层:nginx limit_req(洪水)+ 应用层滑窗与全局指数退避(慢速爆破,落盘,跨重启记账)。 |
| 登录页 | 服务端拼 HTML,零 JS;两个 tab 用 input:checked ~ 纯 CSS 切换。页面带 noindex + no-store + no-referrer。 |
| 进程加固 | systemd 单元里 NoNewPrivileges / PrivateTmp / ProtectSystem=strict / ProtectHome,可写路径只开一个。密钥走 0600 的环境文件,不写进单元文件(那个文件是世界可读的)。 |
| 测试 | 97 条用例:哈希往返与畸形串、cookie 篡改各形态、过期、换密钥即失效、线格式钉死、限速滑窗、全局退避对新 IP 同样生效、锁定跨重启存活、配置 fail-closed、HTTP 层状态码、开放重定向各形态、边缘 JWT 验签(真 RSA 签真验,攻击者自己的钥 / 错 aud / 错 iss / 过期 / 缺 exp / alg=none 逐条拒,且每条都带合法对照组)。 |
| 部署 | 8 步全程 fail-closed:线上语言版本闸 → 本地测试门(不绿不发)→ 密钥就位检查(只查存在性,不生成不覆盖——部署脚本顺手轮换密钥 = 每次上线把自己锁在外面)→ 同步代码 → 起服务 → 确认闸真能验通一枚自签 cookie → 才 nginx -t 并 reload。 |
| 验收 | 打真公网,每一发强制断言经过了 CDN(响应里有边缘 ray id)。原因:本机走透明代理时出口就是这台服务器自己,打裸地址的包会走环回被防火墙放行 —— 客户端状态码在那台机器上不能证明外部可达性。这个坑制造过一次「闸可被绕过」的高危误报。 |
线上运行中 站群多个子站接在这道闸后 —— 教练端、服务器控制台、主站的后台与私有文章等,一次登录 30 天全通。
| 残余风险 | 它的强度弱于被它替换掉的「邮箱验证码 + JWT 验签」:那是密码学身份,这是共享秘密。这是明确的拿安全性换便利,不是「新方案更安全」。真要补强,只需换一个更长的密码 —— 改一个哈希串,其余零改动。(具体的密码长度不写在这页上。) |
| 红线 | 任何让限速失效的改动,包括为了调试临时放开,都等于把门拆了。这是唯一把「共享秘密」撑起来的东西。 |
| 已知的技术债 | 有一个 vhost 故意不进代码仓:它的配置文件里内嵌着订阅串明文,进版本库就是把它泄出去。「无仓库副本」这笔漂移债是有意保留的,写在文档里防止有人顺手来「修」。 |
| 明确不做 | 多用户 / 角色 / 注册、2FA、OAuth、服务端会话存储(自校验 cookie 就是为了不存)、密码找回、图形验证码、自建邮件服务器(发信一律走 relay —— 直连大厂 MX 逐条被拒,最后卡在服务商侧才能改的反向解析记录上,那不是这边能做的事)。 |
demo.example)、
演示密码、cookie 里的过期时间戳与签名串,全部是本页现编的假值,与任何真实系统的凭证无关;
限速一节的密码空间是说明用的假设值。真实的闸跑在站群源站的 nginx 后面,没有对外的演示入口 ——
这也正是本页要把它模拟出来的原因。