在K3s上部署ZITADEL实现SSO单点登录
目录
背景#
我的 Homelab 由两个 K3s 集群组成:
| 集群 | 部署位置 | 服务 |
|---|---|---|
| homelab (k3s-homelab) | Proxmox 虚拟机 | Calibre-Web, Grafana, ArgoCD, Vault, Kopia, ZITADEL |
| oracle-k3s | Oracle Cloud | IT-Tools, Stirling-PDF, Squoosh, Homepage, Uptime Kuma, Miniflux |
之前所有服务各自管理登录——有的没有认证(公开访问),有的用内置用户名密码。这带来了几个问题:
- 安全性差:部分服务直接暴露在公网无认证
- 体验碎片化:每个服务都需要单独登录
- 管理困难:密码分散在各处,无法统一管控
目标是部署一个自托管的 OIDC 身份提供者(Identity Provider),让所有受保护服务共用一套认证,实现 一次登录,多处访问。
为什么选择 ZITADEL#
对比了几个方案:
| 方案 | 优点 | 缺点 |
|---|---|---|
| Keycloak | 功能最全,社区大 | 太重(Java),内存 512MB+,配置复杂 |
| Authentik | 现代 UI,功能丰富 | Python,内存需求 ~500MB |
| Authelia | 轻量,Go 编写 | 不是完整的 IdP,更偏向认证中间件 |
| ZITADEL | Go 编写,轻量(~100MB),内置多租户,完整的 OIDC 支持 | 相对较新,社区较小 |
ZITADEL 是用 Go 编写的云原生身份管理平台,资源占用低,提供完整的 OIDC/OAuth2 协议支持,非常适合 Homelab 场景。
整体架构#
最终实现的 SSO 架构如下:
Internet → Cloudflare DNS → Cloudflare Tunnel
│
┌─────────────────────┼─────────────────────┐
│ │ │
oracle-k3s homelab k3s homelab k3s
│ │ │
Traefik Traefik Traefik
│ │ │
┌─────────┴──────┐ ZITADEL 其他服务
│ ForwardAuth │ (auth.meirong.dev) (book/grafana/...)
│ Middleware │ │
│ ↓ │ │
│ oauth2-proxy │──OIDC──→│
│ (auth-system) │ │
└────────────────┘ │
│ │
Protected Services │
(tool/pdf/squoosh/home/...) │
核心组件:
| 组件 | 集群 | 命名空间 | 作用 |
|---|---|---|---|
| ZITADEL | homelab | zitadel |
OIDC 身份提供者 |
| oauth2-proxy | oracle-k3s | auth-system |
ForwardAuth 认证代理 |
| Traefik Middleware | oracle-k3s | 各业务命名空间 | 请求拦截,调用 ForwardAuth |
认证流程:
- 用户访问
tool.meirong.dev - Traefik 拦截请求,通过 ForwardAuth 检查 oauth2-proxy
- 无有效 session → 302 重定向到 ZITADEL 登录页 (
auth.meirong.dev) - 用户输入用户名密码完成认证
- ZITADEL 回调 oauth2-proxy → 设置
.meirong.dev域 Cookie - 后续访问同域名下的任何服务无需再次登录
第一步:部署 ZITADEL (homelab 集群)#
1.1 前置准备 — 密钥管理#
ZITADEL 需要三组密钥。我使用 HashiCorp Vault + External Secrets Operator (ESO) 管理:
# 在 Vault 中创建 ZITADEL 密钥
vault kv put secret/homelab/zitadel \
master-key=$(openssl rand -hex 16) \
db-password=$(openssl rand -base64 24)
对应的 ExternalSecret 定义:
# zitadel-masterkey — ZITADEL 数据加密主密钥(32字符)
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: zitadel-masterkey
namespace: zitadel
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: zitadel-masterkey
creationPolicy: Owner
template:
type: Opaque
data:
masterkey: "{{ .masterkey }}"
data:
- secretKey: masterkey
remoteRef:
key: secret/homelab/zitadel
property: master-key
---
# zitadel-postgres-auth — PostgreSQL 认证密码
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: zitadel-postgres-auth
namespace: zitadel
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: zitadel-postgres-auth
creationPolicy: Owner
template:
type: Opaque
data:
postgres-password: "{{ .db_password }}"
password: "{{ .db_password }}"
data:
- secretKey: db_password
remoteRef:
key: secret/homelab/zitadel
property: db-password
---
# zitadel-config — ZITADEL 数据库连接密码(config secret 格式)
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: zitadel-config
namespace: zitadel
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: zitadel-config
creationPolicy: Owner
template:
type: Opaque
data:
config-yaml: |
Database:
Postgres:
User:
Password: "{{ .db_password }}"
Admin:
Password: "{{ .db_password }}"
data:
- secretKey: db_password
remoteRef:
key: secret/homelab/zitadel
property: db-password
如果你没有 Vault + ESO,也可以直接用
kubectl create secret手工创建这三个 Secret。关键是三个 Secret 中的数据库密码要一致。
1.2 部署 PostgreSQL#
ZITADEL 需要 PostgreSQL 作为后端数据库。我使用 K3s 的 HelmChart CRD 来管理 Helm release:
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata:
name: zitadel-db
namespace: kube-system # K3s HelmChart CRD 必须在 kube-system
spec:
repo: https://charts.bitnami.com/bitnami
chart: postgresql
version: 12.10.0
targetNamespace: zitadel
createNamespace: true
valuesContent: |-
image:
repository: bitnamilegacy/postgresql
architecture: standalone
auth:
enablePostgresUser: true
username: zitadel
database: zitadel
existingSecret: zitadel-postgres-auth
secretKeys:
adminPasswordKey: postgres-password
userPasswordKey: password
primary:
persistence:
enabled: true
storageClass: nfs-client
size: 8Gi
重要:必须等 PostgreSQL Pod 进入
Running状态后再部署 ZITADEL。ZITADEL 的 init/setup Jobs 会立即尝试连接数据库,如果 DB 还没就绪,Job 会失败。
# 等待 DB 就绪
kubectl get pod zitadel-db-postgresql-0 -n zitadel -w
1.3 部署 ZITADEL#
apiVersion: helm.cattle.io/v1
kind: HelmChart
metadata:
name: zitadel
namespace: kube-system
spec:
repo: https://charts.zitadel.com
chart: zitadel
version: 9.24.0
targetNamespace: zitadel
createNamespace: true
valuesContent: |-
zitadel:
masterkeySecretName: zitadel-masterkey
configSecretName: zitadel-config
configmapConfig:
ExternalSecure: true
ExternalDomain: auth.meirong.dev
ExternalPort: 443
TLS:
Enabled: false
Database:
Postgres:
Host: zitadel-db-postgresql
Port: 5432
Database: zitadel
MaxOpenConns: 20
MaxIdleConns: 10
MaxConnLifetime: 30m
MaxConnIdleTime: 5m
User:
Username: zitadel
SSL:
Mode: disable
Admin:
Username: postgres
SSL:
Mode: disable
ingress:
enabled: false
login:
ingress:
enabled: false
service:
appProtocol: "" # 关键!见下方踩坑记录
关键配置说明:
ExternalSecure: true+ExternalPort: 443:ZITADEL 本身不启用 TLS,但告诉它外部访问使用 HTTPS(由 Cloudflare 提供证书)TLS.Enabled: false:集群内部通信不加密,TLS 在 Cloudflare 层终止ingress.enabled: false:不使用 ZITADEL 自带的 Ingress,改用 Gateway API HTTPRoutelogin.service.appProtocol: "":这是一个关键设置,后面会详细解释
部署后会自动运行两个 Job:
$ kubectl get jobs -n zitadel
NAME STATUS COMPLETIONS DURATION
zitadel-init Complete 1/1 5s # 初始化数据库 schema
zitadel-setup Complete 1/1 64s # 创建管理员用户和密钥
Setup Job 完成后会自动创建三个重要的 Secret:
$ kubectl get secrets -n zitadel | grep -E "iam-admin|login-client"
iam-admin Opaque 1 # 管理员用户信息
iam-admin-pat Opaque 1 # 管理员 Personal Access Token
login-client Opaque 1 # Login UI 的 service account token
1.4 配置 Gateway 路由#
ZITADEL v9+ 将登录 UI 拆分为独立的 Next.js 应用(zitadel-login 服务)。因此需要两条路由规则:
# ReferenceGrant — 允许 Gateway 路由到 zitadel 命名空间
apiVersion: gateway.networking.k8s.io/v1beta1
kind: ReferenceGrant
metadata:
name: allow-gateway-to-zitadel
namespace: zitadel
spec:
from:
- group: gateway.networking.k8s.io
kind: HTTPRoute
namespace: zitadel
to:
- group: ""
kind: Service
---
# HTTPRoute — 拆分路由
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: zitadel
namespace: zitadel
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: homelab-gateway
namespace: kube-system
port: 8000
hostnames:
- "auth.meirong.dev"
rules:
# 登录 UI 路由到 zitadel-login (Next.js, 端口 3000)
- matches:
- path:
type: PathPrefix
value: /ui/v2/login
backendRefs:
- group: ""
kind: Service
name: zitadel-login
port: 3000
weight: 1
# 其他所有请求路由到 zitadel 主服务 (gRPC/REST API, 端口 8080)
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- group: ""
kind: Service
name: zitadel
port: 8080
weight: 1
1.5 配置 Cloudflare DNS#
在 Cloudflare Tunnel 中添加 auth 子域名的 ingress 规则:
# cloudflare/terraform/terraform.tfvars
homelab_ingress_rules = {
"auth" = { service = "http://traefik.kube-system.svc:80" }
# ...其他规则
}
执行 terraform apply 后,auth.meirong.dev 的流量将通过 Cloudflare Tunnel 转发到集群内的 Traefik。
1.6 验证 ZITADEL#
# 检查 OIDC Discovery 端点
$ curl -s https://auth.meirong.dev/.well-known/openid-configuration | jq .issuer
"https://auth.meirong.dev"
# 获取管理员 PAT
$ PAT=$(kubectl get secret iam-admin-pat -n zitadel \
-o jsonpath='{.data.pat}' | base64 -d)
# 查询组织信息
$ curl -s -H "Authorization: Bearer $PAT" \
https://auth.meirong.dev/management/v1/orgs/me | jq .org.name
"ZITADEL"
第二步:创建 OIDC 客户端#
ZITADEL 运行后,需要创建一个 OIDC 客户端给 oauth2-proxy 使用。
2.1 创建项目#
PAT=$(kubectl get secret iam-admin-pat -n zitadel \
-o jsonpath='{.data.pat}' | base64 -d)
# 创建项目
curl -s -X POST \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{"name":"Homelab SSO","projectRoleAssertion":true}' \
https://auth.meirong.dev/management/v1/projects | jq .
返回:
{ "id": "361912262883016747" }
2.2 创建 OIDC 应用#
PROJECT_ID="361912262883016747"
curl -s -X POST \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{
"name": "oauth2-proxy",
"redirectUris": ["https://oauth.meirong.dev/oauth2/callback"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE"],
"appType": "OIDC_APP_TYPE_WEB",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_BASIC",
"postLogoutRedirectUris": ["https://oauth.meirong.dev"],
"accessTokenType": "OIDC_TOKEN_TYPE_BEARER",
"idTokenRoleAssertion": true,
"idTokenUserinfoAssertion": true
}' \
"https://auth.meirong.dev/management/v1/projects/${PROJECT_ID}/apps/oidc" | jq .
返回:
{
"appId": "361912276724219947",
"clientId": "361912276724285483",
"clientSecret": "4ViESg68axzkOWodNGePgjIr52BPgu0S3U7JZgFGUH27v2CE1n35luKln182vHb3"
}
注意:
clientSecret只在创建时返回一次,务必立即保存!如果丢失,需要通过 API 调用_generate_client_secret重新生成。
2.3 创建登录用户#
# 使用 v2 API 创建用户
curl -s -X POST \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{
"userName": "admin",
"profile": {
"givenName": "Matthew",
"familyName": "Admin",
"displayName": "Matthew Admin"
},
"email": {
"email": "[email protected]",
"verification": {"returnCode":{}}
},
"password": {
"password": "YourSecurePassword!",
"changeRequired": false
}
}' \
https://auth.meirong.dev/v2/users/human | jq .
# 验证邮箱(跳过邮件验证)
USER_ID="<返回的 userId>"
curl -s -X PUT \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","isEmailVerified":true}' \
"https://auth.meirong.dev/management/v1/users/${USER_ID}/email"
# 将用户授权到项目
curl -s -X POST \
-H "Authorization: Bearer $PAT" \
-H "Content-Type: application/json" \
-d "{\"userId\":\"${USER_ID}\",\"projectId\":\"${PROJECT_ID}\"}" \
"https://auth.meirong.dev/management/v1/users/${USER_ID}/grants"
2.4 存储凭据到 Vault#
vault kv put secret/oracle-k3s/oauth2-proxy \
client-id=361912276724285483 \
client-secret='4ViESg68axzkOWodNGePgjIr52BPgu0S3U7JZgFGUH27v2CE1n35luKln182vHb3' \
cookie-secret=$(openssl rand -hex 16)
第三步:部署 oauth2-proxy (oracle-k3s 集群)#
3.1 ExternalSecret — 从 Vault 同步凭据#
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: oauth2-proxy-secret
namespace: auth-system
spec:
refreshInterval: "1h"
secretStoreRef:
name: vault-backend
kind: ClusterSecretStore
target:
name: oauth2-proxy-secret
creationPolicy: Owner
data:
- secretKey: client-id
remoteRef:
key: oracle-k3s/oauth2-proxy
property: client-id
- secretKey: client-secret
remoteRef:
key: oracle-k3s/oauth2-proxy
property: client-secret
- secretKey: cookie-secret
remoteRef:
key: oracle-k3s/oauth2-proxy
property: cookie-secret
3.2 Deployment — oauth2-proxy 配置#
apiVersion: apps/v1
kind: Deployment
metadata:
name: oauth2-proxy
namespace: auth-system
spec:
replicas: 1
selector:
matchLabels:
app: oauth2-proxy
template:
metadata:
labels:
app: oauth2-proxy
spec:
containers:
- name: oauth2-proxy
image: quay.io/oauth2-proxy/oauth2-proxy:v7.7.1
ports:
- containerPort: 4180
name: http
args:
- --provider=oidc
- --oidc-issuer-url=https://auth.meirong.dev
- --scope=openid profile email
- --email-domain=*
- --upstream=static://202
- --http-address=0.0.0.0:4180
- --cookie-domain=.meirong.dev
- --cookie-secure=true
- --cookie-samesite=lax
- --cookie-expire=168h
- --redirect-url=https://oauth.meirong.dev/oauth2/callback
- --skip-provider-button=true
- --pass-authorization-header=true
- --set-xauthrequest=true
- --reverse-proxy=true
- --whitelist-domain=.meirong.dev
- --silence-ping-logging=true
env:
- name: OAUTH2_PROXY_CLIENT_ID
valueFrom:
secretKeyRef:
name: oauth2-proxy-secret
key: client-id
- name: OAUTH2_PROXY_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: oauth2-proxy-secret
key: client-secret
- name: OAUTH2_PROXY_COOKIE_SECRET
valueFrom:
secretKeyRef:
name: oauth2-proxy-secret
key: cookie-secret
resources:
requests:
cpu: 10m
memory: 32Mi
limits:
memory: 64Mi
---
apiVersion: v1
kind: Service
metadata:
name: oauth2-proxy
namespace: auth-system
spec:
type: ClusterIP
selector:
app: oauth2-proxy
ports:
- port: 4180
targetPort: 4180
name: http
关键参数说明:
| 参数 | 说明 |
|---|---|
--upstream=static://202 |
纯 ForwardAuth 模式,oauth2-proxy 不反向代理后端服务 |
--cookie-domain=.meirong.dev |
Cookie 域覆盖所有子域名,实现跨子域 SSO |
--cookie-expire=168h |
Session 有效期 7 天 |
--redirect-url |
认证回调地址,需要一个专门的子域名 |
--whitelist-domain=.meirong.dev |
允许重定向到任意 *.meirong.dev 子域 |
--reverse-proxy=true |
信任反向代理传递的 X-Forwarded-* 头 |
第四步:配置 Traefik ForwardAuth#
4.1 定义 Middleware#
apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
name: sso-forwardauth
namespace: personal-services # 需要和 HTTPRoute 在同一命名空间
spec:
forwardAuth:
address: http://oauth2-proxy.auth-system.svc.cluster.local:4180/
trustForwardHeader: true
authRequestHeaders:
- Cookie
- X-Forwarded-Host
- X-Forwarded-Uri
- X-Forwarded-Proto
- X-Real-Ip
authResponseHeaders:
- X-Auth-Request-User
- X-Auth-Request-Email
- X-Auth-Request-Groups
注意:Traefik 的 Middleware CRD 需要和引用它的 HTTPRoute 在同一命名空间。如果有多个命名空间的 HTTPRoute 需要保护,需要在每个命名空间创建一份 Middleware。
4.2 在 HTTPRoute 中引用 ForwardAuth#
以 IT-Tools 为例:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: it-tools
namespace: personal-services
spec:
parentRefs:
- group: gateway.networking.k8s.io
kind: Gateway
name: oracle-gateway
namespace: kube-system
port: 8000
hostnames:
- "tool.meirong.dev"
rules:
- matches:
- path:
type: PathPrefix
value: /
filters:
# 添加 ForwardAuth 过滤器
- type: ExtensionRef
extensionRef:
group: traefik.io
kind: Middleware
name: sso-forwardauth
backendRefs:
- group: ""
kind: Service
name: it-tools
port: 80
weight: 1
只需在 filters 中添加 ExtensionRef 引用 sso-forwardauth Middleware,该服务就纳入了 SSO 保护。
验证 SSO 流程#
部署完成后,验证整个认证链路:
# 1. 访问受保护服务 → 应该 302 重定向到 ZITADEL
$ curl -s -o /dev/null -w "%{http_code}" https://tool.meirong.dev/
302
# 2. 检查重定向目标包含正确的 client_id
$ curl -s -D - https://tool.meirong.dev/ 2>&1 | grep location
location: https://auth.meirong.dev/oauth/v2/authorize?client_id=361912276724285483&...
# 3. ZITADEL authorize 端点应返回 302(重定向到登录页),而不是 400
$ curl -s -o /dev/null -w "%{http_code}" \
"https://auth.meirong.dev/oauth/v2/authorize?client_id=361912276724285483&redirect_uri=https%3A%2F%2Foauth.meirong.dev%2Foauth2%2Fcallback&response_type=code&scope=openid+profile+email&state=test"
302
# 4. 登录页应该可以正常加载
$ curl -s -o /dev/null -w "%{http_code}" \
"https://auth.meirong.dev/ui/v2/login/healthy"
200
在浏览器中打开 tool.meirong.dev,应该会看到:
- 自动跳转到
auth.meirong.dev的登录页面 - 输入用户名(
admin或[email protected])和密码 - 认证成功后自动跳回
tool.meirong.dev - 此时访问
pdf.meirong.dev、home.meirong.dev等同域名服务无需再次登录
踩坑记录#
坑 1:ZITADEL v9 的 Login UI 拆分#
ZITADEL v9 之前,登录 UI 内嵌在主服务中。从 v9 开始,登录 UI 被拆分为独立的 Next.js 应用 (zitadel-login),运行在端口 3000。
如果只配置一条 / → zitadel:8080 的路由,登录页面会返回 404,因为 /ui/v2/login/* 路径需要路由到 zitadel-login:3000。
解决方案:在 HTTPRoute 中配置两条规则——/ui/v2/login 路径优先匹配到 login 服务,其余路由到主服务。
坑 2:Traefik 不支持 appProtocol#
ZITADEL Helm chart 为 login 服务的 Service 设置了 appProtocol: kubernetes.io/http。Traefik 的 Gateway API 实现不识别这个字段,会导致路由解析失败:
Cannot load HTTPBackendRef zitadel/zitadel-login: unsupported application protocol kubernetes.io/http
所有经过 login 服务的请求都返回 500。
解决方案:在 Helm values 中设置 login.service.appProtocol: "" 清除该字段。
坑 3:数据库初始化顺序#
ZITADEL 的 init 和 setup Job 在 Helm release 安装时立即创建。如果 PostgreSQL 还没有就绪,Job 会连接失败并进入 Error 状态。Job 失败后不会自动重试(默认 backoffLimit 较低)。
解决方案:分两步部署——先 apply PostgreSQL HelmChart,等 Pod Ready 后再 apply ZITADEL HelmChart。
坑 4:client_secret 换行问题#
通过 API 创建 OIDC 应用时,返回的 clientSecret 在终端中可能因为行宽被拆成多行。如果直接复制粘贴到 vault kv put 命令中,可能会引入换行符,导致 oauth2-proxy 在 token exchange 时报错:
oidc_error.parent="passwap: password does not match hash"
oidc_error.description="invalid secret"
oidc_error.type=invalid_client
解决方案:用 jq 或 python3 提取 secret 值,避免手动复制:
NEW_SECRET=$(curl -s -X POST ... | python3 -c "import sys,json; print(json.load(sys.stdin)['clientSecret'], end='')")
如果已经出现 invalid_client 错误,可以重新生成 secret:
curl -s -X POST \
-H "Authorization: Bearer $PAT" \
"https://auth.meirong.dev/management/v1/projects/${PROJECT_ID}/apps/${APP_ID}/oidc_config/_generate_client_secret"
坑 5:密钥一致性#
ZITADEL 需要三个 Secret 中的数据库密码完全一致:
zitadel-postgres-auth:PostgreSQL 使用的认证密码zitadel-config:ZITADEL 连接数据库使用的密码- PostgreSQL PVC 中已初始化的密码
如果 PVC 中的数据是用密码 A 初始化的,但 Secret 被改成密码 B,ZITADEL init Job 会报 password authentication failed。
解决方案:如果密码不一致,最干净的方式是删除命名空间(包括 PVC),重新从头部署。使用 Vault + ESO 可以从根本上避免这个问题——所有 Secret 引用同一个 Vault 路径的同一个 key。
总结#
最终实现的效果:
| 效果 | 说明 |
|---|---|
| 统一登录入口 | auth.meirong.dev 作为唯一身份提供者 |
| 跨域 SSO | 一次登录覆盖所有 *.meirong.dev 子域名 |
| 异步保护 | 新增服务只需在 HTTPRoute 添加 ForwardAuth Filter |
| 密钥管理 | Vault → ESO → K8s Secret,全自动同步 |
| 资源开销 | ZITADEL ~100MB,oauth2-proxy ~32MB,总计不到 200MB 内存 |
后续计划(Phase 2):
- Grafana 原生 OIDC 集成(
auth.generic_oauth) - ArgoCD 原生 OIDC 集成(Dex config)
- Miniflux 原生 OAuth2 集成(
OAUTH2_*env vars) - Vault OIDC auth method