Traefik 自动 HTTPS 与 Authelia 统一登录门户:入口层认证实践

系列第二篇。承接上一篇的全容器化改造,这篇聚焦入口层:为什么选 Traefik、Let's Encrypt 证书怎么自动签发,以及如何从原始的 BasicAuth 弹窗升级成一个像样的统一登录门户 Authelia,中间常见的几个坑一并记下。文中域名、路径均为示例。

为什么是 Traefik

容器化之后,反向代理的选型可以优先考虑 Traefik,理由三条:

示例环境用的是 Traefik v3.7.12(从 v3.4.5 升上来的,正是这次升级解决了 Docker Provider 的 API 版本兼容问题——后面细说)。

Let's Encrypt 证书自动签发

Traefik 的 ACME 支持 HTTP-01 和 DNS-01 两种挑战。若域名为泛解析(*.example.com 指向服务器),则:

证书统一存到 ACME 的 storage 文件(或存进 Traefik 挂载的卷),续期由 Traefik 在到期前自动完成,完全不用人管。关键前提:域名的 A/AAAA 记录(含泛解析)必须先正确指向服务器公网 IP,否则挑战失败、证书签不下来。

从 BasicAuth 弹窗到 Authelia 登录门户

最初给受保护的管理面板(Traefik Dashboard、日志查看器)加认证,用的是最省事的 Traefik basicAuth 中间件——就是浏览器弹一个原生用户名/密码框。能用,但体验差、也不安全:

更稳的做法是上 Authelia v4.39.22 作为统一登录门户,部署在 auth.example.com,用 Traefik 的 ForwardAuth 中间件把所有受保护服务的认证请求转发给它。架构变成:

浏览器 ──▶ Traefik ──(ForwardAuth)──▶ Authelia (auth.example.com)
                │                          │ 认证通过
                └──────────────────────────┘
                           ▼
                     后端服务(Dozzle / Dashboard ...)
                     并注入 Remote-User / Remote-Groups / Remote-Email

未登录访问受保护地址时,Traefik 返回 302 跳到 auth.example.com/?rd=<urlencoded 原地址>&rm=GET;用户在门户登录成功后,Authelia 回跳原地址,Traefik 放行并把用户身份注入后端。

密码哈希用 argon2id。这里有个坑值得单独讲。

坑一:users.yml 密码哈希与明文不匹配

Authelia 的用户配置写在 users.yml,密码字段存的是 argon2id 哈希。如果手写/复制的哈希和实际明文密码对不上,登录就会一直失败。正确做法是用官方 CLI 重新生成哈希:

# 注意子命令是 crypto hash generate argon2,变体用 --variant argon2id
authelia crypto hash generate argon2 --variant argon2id
# 校验某个明文是否匹配已有哈希(没有 hash check 这个子命令)
authelia crypto hash validate --password 'YOUR_PASSWORD' -- '$argon2id$...'

把生成的哈希填回 users.yml 即可。

这里还嵌套了上一篇提过的 sed 陷阱:users.yml 里的哈希以 $argon2id$ 开头,满是 $ 特殊字符。如果用 sed -i 's/旧哈希/新哈希/' 去替换,$ 会被 sed 当成正则锚点,替换静默失败或结果错乱。推荐的处理是改用 Python 正则替换(对特殊字符做 re.escape),避开 sed 的转义地狱:

import re
path = '/srv/stacks/authelia/config/users.yml'
old, new = OLD_DIGEST, NEW_DIGEST
text = open(path, encoding='utf-8').read()
text = re.sub(re.escape(old), new, text)   # re.escape 处理 $ 等特殊字符
open(path, 'w', encoding='utf-8').write(text)

通用教训:凡是替换内容里含 $ ' \ & 这类正则/替换元字符的,别用 sed s///;要么行号定位,要么换 Python 之类的工具做转义处理。

坑二:登录失败先分清「被封禁」还是「密码错」

调试时很容易误以为哈希又配错了——用正确密码也返回 401。查 Authelia 容器日志才会发现是 regulation(暴力破解防护) 触发了:默认 2 分钟内失败 3 次就临时封禁 5 分钟,封禁期内即使密码正确也返回 401 KO。短时间反复跑自检脚本,很容易把管理员账号临时锁死,然后误判成配置故障。

排查登录失败,第一步永远是看容器日志区分 banned(被封禁)和 do not match(密码不匹配)这两种截然不同的原因。

关键点:regulation 的封禁状态是内存态,重启 authelia 容器即可清除。 被自己锁住时不用干等 5 分钟:

docker compose restart authelia

坑三:session.inactivity 调优

Authelia 的会话有个 inactivity(不活动超时)参数,控制多久没操作就让会话过期。默认值偏短,会导致在受保护面板里看一会儿日志、填一会儿表单,回来就被踢去重新登录。根据实际使用节奏把它调长一些,体验明显改善。这属于纯配置调优,没有对错,按自己的使用习惯来。

ForwardAuth 端到端验证方法

配完之后怎么验证真的通了?可以固化一套 curl 流程(DNS 未生效时可用 --resolve 本机预演):

# 1. 未登录访问受保护地址,应 302 到门户
curl -s -o /dev/null -w '%{http_code}\n' https://logs.example.com/   # 期望 302/401,绝不能是 200

# 2. 打开门户拿会话 Cookie
curl -s -c jar 'https://auth.example.com/?rd=<rd>&rm=GET'

# 3. 登录:POST /api/firstfactor,body 必须带 targetURL
curl -s -b jar -c jar 'https://auth.example.com/api/firstfactor?rd=<rd>' \
  -H 'Content-Type: application/json' \
  -H 'X-Original-URL: https://logs.example.com/' \
  -H 'Referer: https://auth.example.com/?rd=<rd>&rm=GET' \
  --data '{"username":"u","password":"p","targetURL":"https://logs.example.com/"}'

# 4. 用同一 jar 重新请求原地址,应得 200
curl -s -b jar -o /dev/null -w '%{http_code}\n' https://logs.example.com/

这里有几个极易踩的细节:

坑四:Traefik 双 Provider 路由竞争绕过认证

这是整个认证体系里最危险、最隐蔽的一个坑,单独强调。

现象:明明给受保护域名配了 forwardAuth 中间件,但公网无凭据访问却直接返回 200——认证形同虚设,服务在公网裸奔。

根因:Traefik 同时启用了 Docker Provider 和 File Provider。它会给同一个域名生成两条规则完全相同的路由——xxx@docker(来自 compose 里的 traefik.http.* 标签,通常不带任何认证中间件)和 xxx@file(来自动态配置文件,带 forwardAuth)。两条 rule 长度相同即优先级相同,Traefik 按 provider 加载顺序取其一,实测 @docker 会胜出,于是认证中间件被静默绕过。

这个坑是在 Traefik 从 v3.4.5 升到 v3.7.12、Docker Provider 因 API 版本不兼容失效的问题被修复后立刻暴露的——之前 Docker Provider 压根没生效,反而「歪打正着」没有竞争。

修复:每个受保护域名只保留一条路由来源。可以把路由统一收敛到 File Provider(services.yml),删掉服务 compose 里的全部 traefik.http.* 标签并在原处留警告注释,然后 docker compose up -d 重建容器让 @docker 幽灵路由消失。

验证:

# 无凭据必须 302/401,绝不能 200
curl -s -o /dev/null -w '%{http_code}\n' https://<受保护域名>/

# 检查是否还有 @docker 幽灵路由(示例:受保护域名同时存在 @docker 与 @file 两条路由)
docker exec traefik wget -qO- http://127.0.0.1:8080/api/http/routers

建议把这条检查固化成回归护栏脚本 authelia-check.sh,专门检测 logs@docker 这类幽灵路由。核心教训:混用多个 Traefik Provider 时,「配了认证中间件」绝不等于「认证生效」,必须用无凭据请求实测状态码来验证。

版本固定,别用 latest

最后一条通用经验:Authelia 镜像版本务必固定成 authelia/authelia:4.39.22 而不是 latest。它的配置 schema 会随 minor 版本演进,一次随手 docker compose pull 就可能让登录门户起不来——而门户一挂,你就会被锁在所有受保护服务之外,连进去看日志都费劲。同理适用于所有承载认证/入口的关键组件。

下一篇讲一个更烧脑的问题:Dozzle 的 Service Worker 是怎么和 ForwardAuth 撞车、把登录页搞成死循环的,以及为什么 curl 全绿却依然是坏的。