站群统一登录门

一个密码,一次登录,整个站群 30 天不再问第二遍。它是一层跑在 nginx 里的基础设施 —— 真实的它没有可以点的界面,所以这页把它的判定逻辑照着搬成了一个能点的模拟器: 四个假子站,一枚假 cookie,右边同步显示每一步到底发生了什么。

页内三处工具全在你自己的浏览器里算,不连任何服务器:登录流程模拟器(合成的子站与密码)、 回跳白名单判据、限速账本。演示用的子域、密码、参数都是编的; 真实的闸在站群源站上运行,本页不是它的入口。

1 · 这套系统解决什么问题

一个人维护着十几个子站:控制台、后台、私有文章、健身教练端……都不该对公网敞开,但也都不值得各自写一套登录。难点不在「加个密码框」,在下面这几条:

每个站各自做登录=十几份会话逻辑、十几处可能写错的地方,而且换一个站就要再输一次密码。要的是一处登录、全子域通行
现成的边缘方案没有「密码」Cloudflare Access 的登录方式只有邮箱验证码 / OAuth / SSO,配不出一条「输对密码就放行」的策略。而日常最想要的恰恰是「懒得去翻邮箱,直接输密码进去」。
但邮箱验证码也不想丢在别人的电脑上、或忘了密码时,收验证码仍是最好的那条路。所以不是二选一,是两条通道并存
下游应用不能信任「上游说他验过了」反代注入的头是可伪造面。所以会话凭证做成自校验的:任何下游都能拿同一把密钥自己验,不需要相信中间经过了谁。
门坏了 = 所有站一起进不去包括你自己。所以部署链每一步 fail-closed,且必须先确认闸真的能验通,再让 nginx 开始拦 —— 顺序反了就是把自己锁在门外。
密码不能是唯一的防线一个固定密码就是共享秘密,挡不住耐心。真正让它成立的是限速:一层在 nginx(连 Python 都不进),一层在应用并落盘(重启不清零)。

2 · 支点结论:边缘和源站只能各占一条路径

Access 拦在边缘。它罩住哪条路径,源站的密码页在那条路径上就永远轮不到出场

—— 这句话是踩出来的,也是整套设计的支点。

第一版的结论是「必须取代 Access」:既然它挡在前面,密码页就永远没机会渲染,那就把它全撤掉。 这条结论后来作废了,因为它把「不能叠在同一条路径上」错当成了「不能共存」。

真正的解法是让两者各占一条路径:边缘策略精确绑定在验证码入口那一条 URL 上,密码页那条不受任何影响。 两条路走完都签发同一枚会话 cookie,殊途同归。

入口谁在守用户看到邮件谁发
/_gate/login无 —— 源站自己的公开页密码输入框
/_gate/accessCloudflare 边缘和以前一模一样的邮箱验证码流程Cloudflare,零 SMTP 凭证
/_gate/email/*自建验证码(后备实现,线上未启用)自己的 SMTP relay
POST /_gate/api/login无(同密码那条路)—(给没有 cookie jar 的客户端,如小程序)
这条支点结论的反面是一枚地雷:只要有人把边缘策略的路径从 /_gate/access 缩短成 /_gate 或整个域名, 密码登录当场死 —— 而现象是「密码框不见了」,几乎不可能归因到某个控制台里的一行路径配置。 所以有三处机器在盯它:SSOT 审计里一条专门的规则(存在性 + 禁止更宽)、公网验收脚本对两条路径各打一发、 部署脚本读健康端点自述这条入口是否点亮。人记不住的约束,交给机器。

3 · 试一下:点着走完整个登录流程

左边是一个模拟浏览器,四个合成子站;右边是这枚 cookie 的当前状态,以及 nginx auth_request 视角下每一步发生了什么。全程纯 JS 状态机,一个请求都不发。

怎么玩: ① 未登录先点任意一个站,看它怎么被拦下 ② 登录后回到原站 ③ 再点其它站 —— 不再问第二遍 ④ 清 cookie / 让它过期 / 篡改它,再点一次
模拟浏览器
Cookie 状态
发生了什么
    三个破坏按钮各自触发闸的一种拒绝理由,可以对着右栏看清区别:清除 = cookie 缺失; 过期 = 签名仍然有效但 exp 已过;篡改 = 有人把 exp 改到一百年后 却签不出对应的 HMAC —— 这正是「自校验 cookie 不需要服务端会话表」的原因: 改一个字节,签名就对不上,任何一个下游都能自己算出来。

    4 · 刚才那一遍,在真实链路上是这样

    浏览器 Cloudflare 边缘 nginx(源站) 闸进程(只听环回口) 未登录时 ① GET 某个子站的页面(没有 cookie) ② nginx 发一条 internal 子请求:auth_request /_gate/verify(不带 body) ③ 401 —— cookie 缺失 / 签名不符 / 已过期,一律 fail-closed ④ error_page 401 = @gate_login → 302 到 /_gate/login?next=你原来要去的那一页 通道 A · 密码(源站公开页,边缘不拦这条) ⑤ GET /_gate/login —— nginx 纯透传,不改写前缀 ⑥ 返回登录页:零 JS,两个 tab(密码 / 邮箱验证码)纯 CSS 切换 ⑦ POST 密码 → 限速闸 → scrypt 恒定时间比对 ⑧ 302 回原页 + Set-Cookie tlz_gate(域级作用域,30 天,HttpOnly + Secure + SameSite=Lax) 通道 B · 邮箱验证码(边缘只罩这一条路径) ⑨ GET /_gate/access —— 请求还没到源站就被边缘接管,发验证码 ⑩ 验完身份才回源,请求带上一枚签名 JWT ⑪ 源站不信「能到这儿就说明过了」:独立验签名 + aud + iss + exp + 邮箱白名单 (环回口上任何进程都能直接打这个端口,边缘之外还有别的到达方式) ⑫ 签发同一枚 tlz_gate cookie —— 两条通道到这里合流 此后 30 天 ⑬ 重放 ①,这次带着 cookie → 子请求返 204 → nginx proxy_pass 到真正的应用 站群任何一个子域都不再问第二遍 —— cookie 是域级的。

    图中蓝色 = 密码通道,绿色 = 邮箱通道,红色 = 拒绝与回跳。两条通道的终点是同一枚 cookie。 上面模拟器右栏打出来的每一条,就是这张图里的一步。

    5 · nginx 接法:两个 snippet 分文件,是有意的

    整套接入只有两个 include,但它们不能写在同一个文件里

    server {
        include snippets/authgate-endpoints.conf;   # server 级,恰好一次
    
        location /coach {
            include snippets/authgate-protect.conf; # location 级,每个受保护 location 各一次
            proxy_pass ...;
        }
    }
    文件里面是什么出现次数的约束
    authgate-endpoints.conf /_gate/verifyinternal,子请求目标)+ @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 —— 没有报错、没有入口、没有任何提示, 是那种「功能其实是好的,但用户以为站挂了」的失败。

    6 · 踩过的坑(每一条都留下了一道结构性防线)

    现象根因留下的防线
    登录页一点样式都没有,输完密码点提交 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),不用裸串匹配;攻击形态逐条钉成测试,并做过反向验证(把判定改错,确认测试真的变红)。下一节可以自己试。

    7 · 试一下:跳转白名单是怎么判的

    这段判定决定「登录成功后允许跳到哪」—— 也就是上面模拟器里 next 参数的去向。 判错一个字符,登录页就成了钓鱼跳板。跑在你的浏览器里,随便输。

    预置攻击向量:
    再试几个:
    输入真实目的地判定为什么
    两处关键:
    https://tianli.cyou@evil.example/@ 之前那一段是 userinfo(用户名),不是域名, 真实目的地是 evil.example。用 startsWith("https://tianli.cyou") 判就会被它整个骗过去 —— URL 解析出来的 hostname 会把 userinfo 剥掉,所以正确写法不是「小心一点」,是换一个不会被骗的判据
    /\evil.example 看着像站内路径,但浏览器在 Location 头里会把反斜杠当斜杠处理, 于是它等价于协议相对 URL。所以判定前先做一次反斜杠归一化,再用「以 / 开头但不以 // 开头」这条判据。

    8 · 试一下:限速为什么是这套方案的命根子

    固定密码是共享秘密,本身没有强度可言 —— 让它成立的是攻击者每猜一次要付出多少时间。 下面按限速参数算给你看(密码空间是说明用的假设值)。

    次/秒

    折算口径:穷尽空间所需时间 = 空间大小 × 每次尝试的最小间隔。 期望时间取一半(平均猜到一半时命中)。三档的每次间隔分别是 1 / 攻击速率60 / 12 秒、3600 秒。

    两层限速各挡什么

    在哪一层规则挡的是特点
    nginx limit_req登录提交口每分钟 12 次;子请求口每秒 120 次洪水连 Python 都不进;key 用真实来源 IP
    应用层滑动窗口同一 IP 5 分钟内最多 8 次失败单点慢速爆破
    应用层全局指数退避连续失败超过 3 次起,锁 2ⁿ 秒,封顶 1 小时换 IP 的分布式爆破落 sqlite:重启不清零 —— 存内存的话,攻击者只要能触发一次重启(或等一次部署)就免费重置

    连续失败第 n 次会被锁多久

    前 3 次不锁 —— 那是留给手滑的。第 4 次起才开始收费,而且是指数收费。 上面模拟器里把密码输错几次,右栏会按同一张表报数。

    另外两条容易被忽略的分工:验证码验错和密码错共用同一个计数器(否则邮箱那条路就是绕开密码限速的后门); 但边缘验证失败刻意不计数 —— 那种失败来自配置或边缘本身,不是有人在猜密码, 把它混进同一个计数器,会让一次配置错误顺手把密码登录也一起锁死。

    9 · 会话 cookie:为什么没有服务端会话表

    cookie 的值是自校验的三段结构:版本号、过期时间戳、以及对前两者的 HMAC 签名 —— 上面模拟器右栏显示的就是这三段(签名是演示用的假值)。没有服务端会话表,也不需要。

    因此得到说明
    下游应用可以各自独立验教练端拿同一把密钥自己验这枚 cookie,完全不需要相信 nginx 传来的任何头。信任「上游说他验过了」正是之前栽过的跟头 —— 头是可伪造的,而 cookie 是自校验的,中间经过谁都不影响结论。
    但这是跨仓契约两个仓库互不 import,所以格式靠两侧各钉一组相同的固定向量锁死。改格式必须同时改两处,否则拿着合法 cookie 的人会被下游判成伪造。
    作用域是域级的cookie 写在站群的父域上,所以在任一子域登录一次,全部子域通行 —— 这正是「一次登录」的实现方式,也是模拟器里第二个站直接进去的原因。副作用:边缘验证码那条只需要建一个策略绑在主域上,不必每个子域各建一个(那样每个策略各有各的标识,配置面凭空多几倍)。
    代价:不能单枚吊销没有会话表就没有黑名单。唯一的全局吊销手段是轮换签名密钥 —— 所有人当场掉线,包括你自己。这条如实写在文档里,不粉饰。
    给非浏览器客户端的那条签 7 天,不是 30 天小程序没有 cookie jar,凭证必然落在客户端的明文存储里,离开了 HttpOnly 的保护壳。失窃半径 = 全部受闸子域且无法单枚吊销,所以唯一能压的变量就是有效期。代价只是每周多输一次密码。

    验证一律 fail-closed:段数不对、版本号不对、时间戳不是数字、签名不符、已过期 —— 任何异常形态一律判失败,不给调用方「顺手 except 掉」的机会。

    10 · 技术上怎么做的

    形态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)。原因:本机走透明代理时出口就是这台服务器自己,打裸地址的包会走环回被防火墙放行 —— 客户端状态码在那台机器上不能证明外部可达性。这个坑制造过一次「闸可被绕过」的高危误报。

    11 · 现状与残余风险

    线上运行中 站群多个子站接在这道闸后 —— 教练端、服务器控制台、主站的后台与私有文章等,一次登录 30 天全通。

    残余风险它的强度弱于被它替换掉的「邮箱验证码 + JWT 验签」:那是密码学身份,这是共享秘密。这是明确的拿安全性换便利,不是「新方案更安全」。真要补强,只需换一个更长的密码 —— 改一个哈希串,其余零改动。(具体的密码长度不写在这页上。)
    红线任何让限速失效的改动,包括为了调试临时放开,都等于把门拆了。这是唯一把「共享秘密」撑起来的东西。
    已知的技术债有一个 vhost 故意不进代码仓:它的配置文件里内嵌着订阅串明文,进版本库就是把它泄出去。「无仓库副本」这笔漂移债是有意保留的,写在文档里防止有人顺手来「修」。
    明确不做多用户 / 角色 / 注册、2FA、OAuth、服务端会话存储(自校验 cookie 就是为了不存)、密码找回、图形验证码、自建邮件服务器(发信一律走 relay —— 直连大厂 MX 逐条被拒,最后卡在服务商侧才能改的反向解析记录上,那不是这边能做的事)。
    本页三处工具的计算全部在你的浏览器里完成,不向任何服务器发送数据,也不写任何本地存储。 合成数据说明:模拟器里的四个子站(站点 A / B / C / D,域名一律用保留测试域 demo.example)、 演示密码、cookie 里的过期时间戳与签名串,全部是本页现编的假值,与任何真实系统的凭证无关; 限速一节的密码空间是说明用的假设值。真实的闸跑在站群源站的 nginx 后面,没有对外的演示入口 —— 这也正是本页要把它模拟出来的原因。