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

> 作者: Lazybones | 日期: 2026-09-05

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

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

## 为什么是 Traefik

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

- **自动 HTTPS**：内置 ACME 客户端，对接 Let's Encrypt，证书的申请、部署、续期全自动，不用像 Nginx 那样手动维护 certbot 的 cron 和证书路径。
- **原生理解 Docker**：Docker Provider 能直接读容器标签生成路由；File Provider 又能用 YAML 静态描述路由，两种方式灵活组合。
- **动态配置热加载**：改 `services.yml` 不用重启代理，路由、中间件即时生效。

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

## Let's Encrypt 证书自动签发

Traefik 的 ACME 支持 HTTP-01 和 DNS-01 两种挑战。若域名为泛解析（`*.example.com` 指向服务器），则：

- 单域名证书走 HTTP-01 即可，Traefik 在 80 端口应答挑战，签发后自动跳转到 443。
- 如果要签**泛域名证书**（`*.example.com` 一张证书覆盖所有子域），必须走 DNS-01——因为 Let's Encrypt 规定通配符证书只能通过 DNS 挑战验证。这需要 DNS 服务商提供 API，Traefik 通过对应的 DNS provider 插件自动写入 `_acme-challenge` TXT 记录完成验证。

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

## 从 BasicAuth 弹窗到 Authelia 登录门户

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

- 原生弹窗丑且无法定制，不支持二次验证。
- 每个服务各配一份 `htpasswd`，凭据分散、难统一管理。
- 没有登出概念，会话管理原始。

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

```text
浏览器 ──▶ 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 重新生成哈希：

```bash
# 注意子命令是 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 的转义地狱：

```python
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 分钟：

```bash
docker compose restart authelia
```

### 坑三：session.inactivity 调优

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

## ForwardAuth 端到端验证方法

配完之后怎么验证真的通了？可以固化一套 curl 流程（DNS 未生效时可用 `--resolve` 本机预演）：

```bash
# 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/
```

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

- **登录后回跳地址由 body 里的 `targetURL` 决定，URL query 上的 `rd` 根本不参与 firstfactor 逻辑。** 只传 `rd` 不传 `targetURL`，会回跳到配置里的 `default_redirection_url`，让人误以为「ForwardAuth 回跳坏了」。浏览器 SPA 本身就发 `targetURL`，所以真实用户无感知，只有自动化脚本会踩。用 `redirect_uri` 之类的字段名同样无效。
- `/api/verify` 在 v4.39 已废弃返回 404，Traefik 集成要用 `/api/authz/forward-auth`。
- 判断「是否彻底摆脱了 BasicAuth 弹窗」有个干净判据：不带 `Authorization` 头访问被拒时，响应里**不会**出现 `WWW-Authenticate` 头——所以浏览器不会弹原生框。只有客户端主动发 Basic 头才会触发 Authelia 的一阶 basic 认证。

## 坑四：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` 幽灵路由消失。

**验证**：

```bash
# 无凭据必须 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 全绿却依然是坏的。
