我在 https://home.yaoyun.xyz 的自建服务的安全认证程度参差不齐:

  • dufs / miniflux 只有 HTTP 认证
  • Portainer / Photoprism 有完整的用户系统,前者支持内部的用户名 - 密码认证、OAuth 和 LDAP,后者支持 OIDC 和 OAuth
  • Traefik 的 Dashboard、Netdata 的监控无认证,还会暴露敏感信息。

因此,管理多个自建服务时,自建/使用第三方 SSO 就能提供同一水平、集中控制的访问控制。Authentik 是一个开源 SSO,采用免费/开源自建 + 付费支持的商业模式,自称提供最丰富的接口支持,同类替代还有:

  • Keycloak:红帽开发。半年前试用过、对于个人服务太重了,难以维护。开源 + 卖商业支持和进阶 feature。
  • Authenlia:强调轻量,正在筹款做安全审计。
  • GitHub - casdoor/casdoor,国人开发。

使用 CF Tunnel + Traefik 反向代理 Authentik

在使用 Authentik 之前,我的自建服务架构是:公网用户 -(https, yaoyun.xyz)-> Cloudflare 反代 -(https, *.cfargotunnel.com)-> CF Tunnel -(…)-[LAN[-> Cloudflared -(http)-> traefik at 80 -(http)-> services ]]。由于 CF Tunnel 访问 Traefik 的 HTTP Entrypoint,送往 Authentik 的 HTTP 请求头中会包含 X-Forwarded-Proto: http。Authentik 网页中的 URL 并非协议相对 URL(protocol-relative URLs),而是根据请求头中的(X-Forwarded-Proto)决定,此处即 HTTP。同时我启用了 CF 的 Always Use HTTPS 和 Edge Certificate,因此公网用户始终使用 HTTPS 访问。此外,CF 的 Automatic HTTPS Rewrites 对 Authentik 还不起效。这一切就成了 Mixed Content Error 的典型例子……

为了使 Traefik 转发的请求头中 X-Forwarded-Protohttps,有两种方案:

  1. 粗暴的使用 headers 中间件,覆写之
  2. 配置 Traefik 使用自签的 localhost TLS 证书和 443 Entrypoint,使 Cloudflared 转发 Tunnel 流量至 https://localhost:443

为了一劳永逸,这里我使用了第二种方案。注意在 Cloudflare Tunnel 设置中启用 No TLS Verify 以信任自签的 localhost 证书。由于使用该证书加密的流量只通过本地回环,可以不担心中间人攻击。

此外,Authentik 的推荐 Compose File 中包含四个容器:作为数据库存储状态的 postgresqlredis,和无状态的 serverworkerserver 对外提供服务,也是唯一需要连接至 traefik Docker 网络的。

配置 Traefik 转发认证(Forward Authentication)

缺乏认证支持的服务可通过反代 + 转发认证来接入 Authentik。由于 Traefik 是我这台 All-in-boom 的主要路由反代,我使用它的转发认证,而非 Authentik 自己的反代。

Authentik 对转发认证的支持实现如下:假设需要认证的服务位于 SERVICE_HOST = srv.example.com

services:
  whoami:
    image: traefik/whoami
    restart: unless-stopped
    networks:
      - traefik
    labels:
      - traefik.enable=true
      - traefik.http.routers.whoami.rule= Host(`${SERVICE_HOST}`)
      
networks:
  traefik:
    external: true

一个额外的 Proxy 服务需要监听 srv.example.com/outpost.goauthentik.io/ 目录。这一 Proxy 服务作为一个哨站(Outpost)与 Authentik 主站连接,交换具体的用户、访问控制和授权信息。这是为了将 Authentik 提供的会话 Token 存储在浏览器 srv.example.com 的 Cookies 中、以在会话生命周期内实现无感认证。给这个路由设置较高的优先级、以覆盖需认证服务的路由。

services:
  whoami: ...
  auth-whoami:
    image: ghcr.io/goauthentik/proxy
    networks:
      - traefik
    environment: ...
    labels:
      traefik.enable: true
      traefik.http.services.auth-whoami.loadbalancer.server.port: 9000
      traefik.http.routers.auth-whoami.rule: Host(`${SERVICE_HOST}`) && PathPrefix(`/outpost.goauthentik.io/`)
      traefik.http.routers.auth-whoami.tls: true
      traefik.http.routers.auth-whoami.priority: 15
      ...

networks: ...

在 Authentik 主站管理面板中新建一个 Proxy Outpost,复制供 Outpost 访问 Authrntik 的 Token ${AUTHENTIK_TOKEN}。为了提升性能和安全性,令这一 Proxy 服务使用 Docker 内部网络来访问 Authentik 主站 ${AUTHENTIK_INTERNAL_URL},并无条件信任主站的 SSL 证书。此外配置用户访问主站时的外部 URL ${AUTHENTIK_URL}

services:
  whoami: ...
  auth-whoami:
    image: ...
    networks: ...
    environment:
      AUTHENTIK_HOST: ${AUTHENTIK_INTERNAL_URL}
      AUTHENTIK_INSECURE: "true"
      AUTHENTIK_TOKEN: ${AUTHENTIK_TOKEN}
      AUTHENTIK_HOST_BROWSER: ${AUTHENTIK_URL}
    labels: ...

networks: ...

然后,声明一个 Traefik 的转发认证中间件,指向 Outpost 监听的 srv.example.com/outpost.goauthentik.io/、转发返回的 Header。最后,令需认证服务使用该中间件。

services:
  whoami:
    ...
    labels:
      - ...
      - traefik.http.routers.whoami.middlewares=auth-whoami@docker
  auth-whoami:
    image: ...
    networks: ...
    environment: ...
    labels:
      ...
      traefik.http.middlewares.auth-whoami.forwardauth.address: http://auth-whoami:9000/outpost.goauthentik.io/auth/traefik
      traefik.http.middlewares.auth-whoami.forwardauth.trustForwardHeader: true
      traefik.http.middlewares.auth-whoami.forwardauth.authResponseHeaders: X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version

networks: ...

完整 Compose file 示例见 附录

配置 OAuth 及其他协议

OAuth 和其他认证协议的支持几乎开箱即用,在 Authentik 面板中新建一对绑定的 Application - Provider,选择对应协议即可。Authentik 也提供了 分应用的具体指导,故不详述。

其他

配 Traefik TLS 和面板的时候花了一天半排查这个问题:Traefik 的 api@internal 需要一个假端口配置才能正常 routing,不然就会报 port is missing 并且拒绝创建 router…… Seriously, WTF?

- traefik.http.services.justAdummyService.loadbalancer.server.port=1337

这是一个有五年多历史的 Open Issue,而且在我看过的所有博客和文档里都没有提及…… Docker swarm configuration ignored with “port is missing” · Issue #5732 · traefik/traefik · GitHub

此外,静态配置(启动时的变量,如 --entrypoints=…,和 /etc/traefik/traefik.yaml 等标准位置的配置文件)应仅包含 Entrypoints、Provider 等项;而路由、服务、中间件和证书应当由一个动态配置 Provider 提供,比如 Docker 标签或指定的动态配置目录(文件)。

附录

Forward Auth 示例

services:
  whoami:
    image: traefik/whoami
    restart: unless-stopped
    networks:
      - traefik
    labels:
      - traefik.enable=true
      - traefik.http.routers.whoami.rule= Host(`${SERVICE_HOST}`)
      - traefik.http.routers.whoami.tls=true
      - traefik.http.routers.whoami.middlewares=auth-whoami@docker

  auth-whoami:
    image: ghcr.io/goauthentik/proxy
    networks:
      - traefik
    environment:
      AUTHENTIK_HOST: ${AUTHENTIK_INTERNAL_URL}
      AUTHENTIK_INSECURE: "true"
      AUTHENTIK_TOKEN: ${AUTHENTIK_TOKEN}
      AUTHENTIK_HOST_BROWSER: ${AUTHENTIK_URL}
    labels:
      traefik.enable: true
      traefik.http.services.auth-whoami.loadbalancer.server.port: 9000
      traefik.http.routers.auth-whoami.rule: Host(`${SERVICE_HOST}`) && PathPrefix(`/outpost.goauthentik.io/`)
      traefik.http.routers.auth-whoami.tls: true
      traefik.http.routers.auth-whoami.priority: 15
      traefik.http.middlewares.auth-whoami.forwardauth.address: http://auth-whoami:9000/outpost.goauthentik.io/auth/traefik
      traefik.http.middlewares.auth-whoami.forwardauth.trustForwardHeader: true
      traefik.http.middlewares.auth-whoami.forwardauth.authResponseHeaders: X-authentik-username,X-authentik-groups,X-authentik-email,X-authentik-name,X-authentik-uid,X-authentik-jwt,X-authentik-meta-jwks,X-authentik-meta-outpost,X-authentik-meta-provider,X-authentik-meta-app,X-authentik-meta-version

networks:
  traefik:
    external: true