外观
投放授权界面
授权界面由源站 nginx 在 auth.natlan.io 同源托管,不在 Cloudflare 上、没有自动 构建。任何改动(包括只改了 ui/)都要走这份流程。
停机:不需要 | 预计耗时:10 分钟(含验收)。
托管形态的理由见 internals/auth-hosting.md。
发布顺序不能反
如果这次同时要改前端产物和 nginx 配置,必须先发前端、验一次、再发 nginx。
只发 nginx 不发前端会直接打死人机验证:新配置下发的 CSP 依赖新 index.html 里的 __CSP_NONCE__ 占位符,旧产物没有那个占位符,验证码会转 20 秒后报超时。
反过来(只发前端不发 nginx)是安全的:那时页面上还没有 CSP,占位符原样留着不起作用。
日常只更新前端时,只做「步骤 1」即可。
前置检查
三项都是既有状态,正常情况下不用改,但发之前请确认:
| 检查 | 期望 | 怎么看 |
|---|---|---|
nginx 带 http_sub_module | 有 | docker compose -p docker-compose exec nginx nginx -V 2>&1 | tr ' ' '\n' | grep sub_module |
没开 gzip_static | 没开 | 开了的话 sub_filter 改不动预压缩文件,占位符会原样发给浏览器 |
Cloudflare natlan.io 的 Rocket Loader / Auto Minify | 都是 off | Rocket Loader 会改写脚本标签,一开 CSP 必挂 |
另外,Cap 实例 challenges.natlan.io 的 CORS 白名单需含 https://auth.natlan.io。
步骤 1:构建并投放前端产物
在仓根(分支 main):
bash
npm ci
npm run build:auth # 产物在 apps/auth/dist/发布前自检两条,都不满足就别上传:
bash
grep -c __CSP_NONCE__ apps/auth/dist/index.html # 必须是 2
grep -rl jsdelivr apps/auth/dist/ # 必须无输出
ls apps/auth/dist/favicon.svg # 必须存在第一条守的是 nonce 注入点(占位符出现在 script 标签属性与那行赋值,共两处); 第二条守的是「验证码不从 CDN 取东西」这个 CSP 前提。
上传到 nginx 的静态根目录(宿主机路径,容器内只读挂载为 /usr/share/nginx/www/auth):
bash
rsync -av --delete apps/auth/dist/ <部署机>:/opt/.../docker/nginx/www/auth/
--delete是必要的:产物文件名带内容 hash,不清理会越堆越多;index.html不带 hash,会被正常覆盖。
投放后立刻验一次:
bash
curl -s https://auth.natlan.io/authorize | grep -c __CSP_NONCE__- nginx 已经在注 nonce(常态)→ 期望 0,说明新产物就位且替换正常;
- nginx 还没配 sub_filter(首次上线)→ 期望 2,占位符原样出现。
然后用一个完整的授权链接在浏览器里走一遍,人机验证应当正常通过。
步骤 2:nginx(只在首次上线或改策略时做)
编辑部署机上的 docker/nginx/conf.d/auth-natlan.conf。
⚠️ 这个文件不在版本库里(后端仓只跟踪了
control-server.conf)。以服务器上那份 为准,手工应用下面的改动,不要整文件覆盖。
找到 location = /authorize:
nginx
location = /authorize {
add_header Cache-Control "no-store" always;
# index.html 里有两处 __CSP_NONCE__(script 标签属性 + 那行赋值),所以 once off
sub_filter_once off;
sub_filter '__CSP_NONCE__' '$request_id';
# ⚠ 'unsafe-eval' 不是可选项:Cap 的 instrumentation 挑战脚本用 eval/Function,
# 不放行它会静默死在沙箱 iframe 里、20 秒后超时,而主文档一条违规都不报。
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-$request_id' 'unsafe-eval' https://static.cloudflareinsights.com; worker-src 'self' blob:; style-src 'self' 'unsafe-inline'; img-src 'self' data:; font-src 'self'; connect-src 'self' https://challenges.natlan.io https://cloudflareinsights.com; frame-src 'self'; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'none'" always;
try_files /index.html =404;
}其余位置(location /assets/、location = /favicon.svg、各 /api 反代块) 不要动。
校验并热加载(配置是只读挂载,不用重建容器):
bash
docker compose -p docker-compose exec nginx nginx -t
docker compose -p docker-compose exec nginx nginx -s reloadnginx -t 不过就停在这里,别 reload。
验收
四条,全部要过。
① 响应头有 CSP,且不缓存
bash
curl -sI https://auth.natlan.io/authorize | grep -iE 'content-security-policy|cache-control'期望 Cache-Control: no-store,以及一条含 'nonce-<32 位十六进制>' 的 Content-Security-Policy。
② 头里的 nonce 与页面里的完全一致,占位符零残留
bash
curl -s -D /tmp/h.txt https://auth.natlan.io/authorize -o /tmp/b.html
grep -o "nonce-[a-f0-9]*" /tmp/h.txt | head -1 # 头里的
grep -o 'nonce="[a-f0-9]*"' /tmp/b.html | sort -u # 页面里的(应当只有一个值)
grep -c __CSP_NONCE__ /tmp/b.html # 必须是 0两个 nonce 值不同、或占位符还在 = 立刻回滚。
③ 每次请求的 nonce 都不一样
bash
for i in 1 2 3; do curl -sI https://auth.natlan.io/authorize | grep -io "nonce-[a-f0-9]*"; done三行必须互不相同。相同说明响应被缓存了,no-store 没生效。
④ 人工走一遍真实授权流
这条自动化替代不了。 用一个完整的 /authorize?client_id=… 链接打开,输入 ASN, 点人机验证,确认方块变成橙色对勾。同时开着 DevTools Console,不应出现任何 Refused to … 开头的报错。
如果账号里有 passkey 用户,顺带让其中一位真人试一次 passkey 登录。
页面里
content_main.js之类的报错是浏览器扩展产生的,与本站无关,可忽略。
验证码卡在转圈超过 20 秒后报错,就是 CSP 拦了东西——回滚,并按 troubleshoot-csp.md 排查。
回滚
前端产物:投放上一版 dist/。产物文件名带内容 hash,回滚不会撞缓存。
nginx:只回 nginx 即可,前端产物可以留着(没有 CSP 时它照常工作)。删掉 location = /authorize 里新增的三行(sub_filter_once、sub_filter、 add_header Content-Security-Policy),保留 Cache-Control 与 try_files,然后:
bash
docker compose -p docker-compose exec nginx nginx -t
docker compose -p docker-compose exec nginx nginx -s reload回滚后 curl -s https://auth.natlan.io/authorize | grep -c __CSP_NONCE__ 应当回到 2。