- TypeScript 52.9%
- Rust 25.8%
- Python 20.7%
- CSS 0.3%
- Shell 0.1%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
| auth-web | ||
| docs | ||
| edge | ||
| hydra | ||
| kratos | ||
| postgres | ||
| tests | ||
| web | ||
| .dockerignore | ||
| .env.example | ||
| .envrc | ||
| .gitignore | ||
| .muxrun.toml | ||
| CONTEXT.md | ||
| devenv.lock | ||
| devenv.nix | ||
| devenv.yaml | ||
| docker-compose.yml | ||
| LICENSE | ||
| README.md | ||
gjz010/auth
gjz010/auth 是一个可让用户用一个账号登录多个服务(OIDC 接入)的鉴权网关。身份与会话由
Ory Kratos 负责,OIDC 由
Ory Hydra 负责;auth-web(Rust/Axum)只做薄协调层与邀请/
管理逻辑,React 前端提供界面,均不自行实现密码学原语。
功能
- 签名初始化首个 root:站主用离线 OpenPGP 私钥签署一次性 challenge 来创建首个管理员。 库内不提供任何站主密钥,也不接受从网络自动拉取公钥。
- 邀请制注册:root 创建邀请码,受邀者以“用户名 + 密码 + 邀请码”注册。
- 多凭据登录:密码与 passkey(WebAuthn discoverable)并存;同一身份可添加/移除多把 passkey。
- 资料 / 会话 / 恢复:显示名与密码设置;中央会话列表与撤销;无邮件场景下的管理员恢复 链接。
- OIDC 接入:为下游应用(如 Forgejo)签发 OIDC 身份;
groupsclaim 输出所有有效普通组,root永不出现在 token/UserInfo。 - 管理员:查看/禁用/启用用户、管理动态群组(创建、描述与继承关系)与邀请码,并从网页创建 OIDC 应用,以及维护面向用户的独立应用程序入口。
架构
浏览器 / 接入应用
│ HTTPS(唯一入口)
▼
Cloudflare Tunnel ──► edge (Caddy)
├─► auth-web (Rust/Axum) ─┐
└─► Kratos / Hydra ────────┤
▼
PostgreSQL
生产 docker-compose.yml 不发布任何 host port:唯一入口是 Cloudflare Tunnel →
edge:8080。Kratos/Hydra Admin、PostgreSQL 与 auth-web 的 /internal/* 都不对公网暴露。
部署前置
- Docker Engine + Docker Compose。凭据文件叠加层用到较新的 override 合并语义,建议保持 Compose 较新版本。
- OpenSSL(生成随机密钥)。
- 一个 Cloudflare 账号与 Tunnel;把
PUBLIC_ORIGIN的主机名 ingress 指向http://edge:8080。HTTPS 由 Cloudflare 终结。 - 若要初始化首个 root,还需要你自己的 OpenPGP 密钥对(公钥用于配置,私钥离线保留)。
配置 .env
cp .env.example .env
chmod 600 .env
.env 已被 .gitignore 忽略,绝不提交。至少填写:
PUBLIC_ORIGIN=https://auth.example.com:不带结尾斜杠;Kratos/Hydra 的本服务 URL (issuer、login/consent/return URL 等)从它派生。下游集成的域名另见 OIDC 一节,不都由它 自动覆盖。PASSKEY_RP_ID=auth.example.com:裸主机名(不带 scheme/路径/端口),必须等于PUBLIC_ORIGIN的主机名。两者必须成对修改。CLOUDFLARE_TUNNEL_TOKEN:token 模式下从 Cloudflare Dashboard 获取;凭据文件模式下留空。FIRST_SETUP_PUBLIC_KEY_PATH:使用edge/compose.first-setup.yml初始化首个 root 时 必填,指向你自己的 OpenPGP 公钥文件(放在仓库之外)。写进.env,或先export再运行 compose;Compose 只对已导出的 shell 变量插值,普通赋值不会被读到。
用 OpenSSL 生成随机密钥并私下写入 .env:
openssl rand -hex 32 # POSTGRES_PASSWORD / KRATOS_DB_PASSWORD / HYDRA_DB_PASSWORD /
# AUTH_WEB_DB_PASSWORD / KRATOS_COOKIE_SECRET / WEBHOOK_SECRET /
# HYDRA_SYSTEM_SECRET
openssl rand -hex 16 # KRATOS_CIPHER_SECRET(必须恰好 32 字符)
数据库密码会被直接插值进 DSN,只能使用 URL 安全字符(建议十六进制随机串)。docker compose up 对必需变量使用 ${VAR:?} 快速失败。FORGEJO_OIDC_CLIENT_SECRET 只服务于旧
bootstrap-client CLI,网页创建应用不再需要。
启动
Tunnel 有两种模式,二选一;第一次启动还必须叠加签名初始化层,因此请先按上一节设置
FIRST_SETUP_PUBLIC_KEY_PATH(否则叠加层会因该变量缺失而拒绝启动)。
token 模式(remotely-managed Tunnel):
docker compose -f docker-compose.yml -f edge/compose.first-setup.yml up -d --build
在 Cloudflare Dashboard 把主机名指向 http://edge:8080,并把 token 填入 .env 的
CLOUDFLARE_TUNNEL_TOKEN。
本地凭据文件模式(不使用 token):
export CLOUDFLARE_TUNNEL_ID=<tunnel-uuid>
export CLOUDFLARE_CREDENTIALS_FILE=/absolute/path/outside/repo/<tunnel-uuid>.json
docker compose -f docker-compose.yml -f edge/compose.first-setup.yml \
-f edge/compose.cloudflared-credentials.yml up -d --build
凭据 JSON 是高价值 secret:放在仓库之外并保持只读。凭据叠加层以固定的 user 运行
cloudflared;请把该 user 调整为凭据 JSON 的实际所有者,而不是放宽文件权限。首个 root
创建完成后,日常运行去掉 first-setup 叠加层即可;凭据文件模式仍需保留凭据叠加层。
初始化首个 root
root 是授权角色/组名,不是用户名;首个 root 的用户名由你在 setup 页自选。
-
准备你自己的 OpenPGP 公钥:把用于签名的密钥公钥导出到仓库外,并按上一节把
FIRST_SETUP_PUBLIC_KEY_PATH指向该文件(写进.env或先export)。私钥只留在本机, 不要提供给部署环境:gpg --armor --export <your-fingerprint> > /absolute/path/outside/repo/public-key.asc -
用上面的启动命令(含
edge/compose.first-setup.yml)启动,然后打开https://auth.example.com/setup。页面会显示所配置公钥的完整指纹,请先核对。 -
填写你自选的用户名,点“获取 challenge”,用页面下载按钮保存
challenge.txt。challenge 是精确 UTF-8 且带末尾换行;不要把它复制成 shell 变量或用printf/命令替换转写,否则 会丢失换行并导致验签失败。 -
在本机对下载下来的文件做 detached ASCII 签名:
gpg --armor --detach-sign \ --local-user <your-fingerprint> \ --output challenge.asc challenge.txt主密钥仅具认证(cert-only)能力时,gpg 会自动选用合法签名子密钥;用主密钥完整指纹即可。
-
打开
challenge.asc,把内容粘贴进页面的 signature 输入框,并在页面设置你自己的密码: 初始化 root 至少 10 个字符(该 UI 强制;Kratos 本身默认策略最短 8 位)。密码随这次 HTTPS 请求交给 Kratos,由 Kratos 负责存储与校验;私钥始终留在本机,不会上传。 -
提交成功后用该用户名与密码登录。初始化完成后移除
edge/compose.first-setup.yml叠加层; 完成状态持久在数据库。
关于 pending:若 Kratos 在创建身份时失败或超时,状态会保留为 pending,不会自动
重试或释放。请人工核对 Kratos 中是否已创建该身份后再处理,不要反复重建数据库或开放公开
reset。这条约束用于保证“首个管理员只能创建一次”,请按通用安全原则谨慎对待。
日常操作
- passkey:登录页可直接用 discoverable passkey 登录;在安全页可新增、移除或重命名自己的多把
passkey。自定义名称仅用于展示,保存在本人 Kratos identity 的
metadata_admin.passkey_aliases(键为与passkey_remove节点一致的凭据 ID 十六进制字符串),不写入或改动任何凭据材料、不新增 数据库表,也只对本人的现存凭据生效。 - 会话:
/sessions列出中央会话并可撤销。撤销中央会话不等于撤销接入应用内的会话。 - 恢复(无邮件):在
/admin/users或 CLIrecovery-link为忘记密码的用户生成恢复 链接。链接有效期 15 分钟;请先带外核实用户身份,只经可信渠道交付,不写入日志。首发不需要 SMTP。 - 邀请:root 在
/admin/invites创建/停用邀请码,并可配置注册后授予的直接群组。群组配置仅影响之后开始注册的用户;已开始注册和已注册用户不变。CLI 子命令与入参见docs/API.md。
OIDC 接入(OIDC 应用管理)
新用户接入下游应用全部通过网页完成,不需要改动源码。以 root 登录后进入 管理 → OIDC 应用
(/admin/apps)并点「新建应用」。这里管理的是用于登录的 OIDC 客户端,与上节独立启动器条目
(/admin/applications)是两套互不绑定的记录:
- 选模板。Forgejo:填站点 URL(如
https://git.example.com)与认证源名(默认gjz010),Client ID 默认forgejo;系统自动生成回调https://git.example.com/user/oauth2/gjz010/callback。通用 OIDC:每行一个填写回调 地址。 - 创建成功后页面只显示一次
client_secret,并列出 Client ID、Issuer、Discovery 与 scopes;把它们复制到第三方管理页,关闭后无法再次查看。
Forgejo 侧认证源字段对照:
| Forgejo 字段 | 值 |
|---|---|
| 认证类型 / 提供方 | OAuth2 / OpenID Connect |
| 源名 | 认证源名(默认 gjz010) |
| Client ID | 应用页显示的 Client ID(默认 forgejo) |
| Client Secret | 创建/重置时一次性显示的 secret |
| Discovery / Issuer | 应用页显示(默认 https://auth.example.com) |
| Scopes | openid profile email groups |
- 高级选项:scopes 固定为
openid profile email groups(openid必选);访问范围为all(全部用户)或指定组,指定组从群组目录选择,属于任一所选组即可(OR 匹配)。Forgejo 模板 默认指定组(需从目录选择),通用模板默认all,均可修改。 - 下游认证请求经
PUBLIC_ORIGIN(默认https://auth.example.com)公网发起;Hydra Admin 只在 backend 网络内,永不外露。登录/同意时后端每次从 Hydra 现客户端重读策略,不缓存旧值。 - 编辑不改 secret;「重置密钥」输入 Client ID 确认后生成新 secret,旧 secret 立即失效, 第三方须同步更新。删除应用停止后续认证,但不保证终止第三方已建立的会话。
- 图标:默认(名称首字母)、外部 URL 或上传,与独立启动器的三择一致,并在 OIDC 授权同意页展示。 创建时会先保存应用再上传图标:上传失败仍保留已创建的 Client ID 与一次性 secret,可在创建成功 面板内只重试图标(不重复创建、不轮换 secret)。编辑时选「默认」清除、选「外部 URL」替换、保留 已上传的托管图标则在提交时省略该字段,绝不把本站托管地址当外部 URL 回发。上传需后端配置对象 存储;未配置时上传控件禁用,外部 URL 与默认仍可用。
- 修改策略或 scopes 会立即影响后续与尚未完成的授权(包括已记住的 consent)。
/admin/apps页面与 API 只允许root,后端每个请求独立校验管理员;仅支持服务端 confidential 应用(client_secret_basic/client_secret_post),不含 SPA/public client。 旧bootstrap-clientCLI 仅兼容保留在docs/API.md。
派生邮箱域由后端环境变量 DERIVED_EMAIL_DOMAIN 配置(默认 users.example.com,只接受
裸 DNS 域名,拒绝 scheme、@、路径与空白)。后端在 /api/me、管理端用户视图与 OIDC
email claim 中共用该配置;前端直接显示服务端返回的 email,不再本地拼接。
应用程序入口(独立启动器)
/applications 是面向用户的独立服务入口列表,与 OIDC 客户端不绑定、也不一一对应:它只是
站主配置的名称、URL 与图标,点击后由浏览器直接以新标签页打开,不发起 OAuth/OIDC 授权,也不
自动登录目标站。用户只会看到后端按其身份过滤后可见的条目;可见性由后端决定,前端不重新判断。
- 管理:root 在 管理 → 应用程序(
/admin/applications)创建、编辑或删除。列表内用对话框 完成,没有单独详情页。 - 字段:名称(1–120 字符)、服务 URL(必须 https,本机回环可用 http,允许 query/hash,不接受
user:pass@)、可见范围(全部用户,或从群组目录任选一个及以上,OR 匹配)。 - 图标:默认、外部 URL 或上传。上传需后端配置对象存储;未配置时上传控件禁用,外部 URL 与默认 仍可用,已托管的图标可保留或移除。上传只接受 PNG/JPEG/WebP 且 ≤ 2 MiB,成功后由本站同源 地址提供,浏览器不直连对象存储。
- 该页面与
/api/applications、/api/admin/applications只允许已登录用户;管理端每个请求 仍独立校验 root。可见性只决定入口是否显示,不代表目标服务已授权或会接受该用户。
头像
用户可在账户设置页上传、更换或删除自己的头像。原图不直接公开:auth-web 解码后统一 转为 PNG(512×512 外接框内等比缩放、最多 512 边长,只接受 PNG/JPEG/WebP,拒绝 SVG/GIF/ 伪造内容),重编码丢弃 EXIF 等元数据,存入一个私有 S3 兼容 bucket,再由 auth-web 自己 的同源网关按当前指针提供给浏览器。浏览器永远拿不到 bucket 凭据,也不存在公开 bucket 或指向对象存储的直链。
- 配置全部通过环境变量。六个必填项:
AVATAR_S3_ENDPOINT、AVATAR_S3_REGION、AVATAR_S3_BUCKET、AVATAR_S3_PREFIX、AVATAR_S3_ACCESS_KEY_ID、AVATAR_S3_SECRET_ACCESS_KEY;可选AVATAR_S3_FORCE_PATH_STYLE(默认true)。 - 全有或全无:六个必填值为空即禁用(
GET /api/me/avatar返回{enabled:false,picture:null},上传/删除返回503 avatar_unavailable);只设置了其中 一部分则服务启动失败,不静默降级。FORCE_PATH_STYLE的默认值不算作已配置。 端点必须 HTTPS;仅回环地址允许 HTTP,供隔离的本地测试使用,没有生产 insecure 开关。 - 可复用共享 bucket:
AVATAR_S3_PREFIX指定本应用在 bucket 内独占的相对前缀(首尾斜杠会 被归一化,拒绝空、./..路径段与控制字符,常规 B2 前缀如team/app都可用)。请创建一个 只对该前缀具putObject/getObject/deleteObject权限的受限 key(例如 Backblaze B2 应用密钥),不必授予整桶权限,也不必强制单独 bucket。不要把 key 写进仓库;本仓库不预置 任何个人部署值。 - 指针是 Kratos identity 的
metadata_admin.avatar.object_id,不新增数据库表或迁移;每次 成功上传使用新的对象 UUID,对象键为<prefix>/<identity_uuid>/<object_uuid>.png,不再 额外加avatars/层,调用方无法自选键或 identity。metadata_admin损坏时拒绝覆盖。 修改AVATAR_S3_PREFIX不会迁移已有对象,只影响新写入与查找;变更前请自行处理旧前缀下 的文件(用户尚未部署头像时无需迁移)。 - 公开图片地址仍为
PUBLIC_ORIGIN/avatars/<identity_uuid>/<object_uuid>(无扩展名),匿名 可读;只有对应 identity 当前 active 且指针正好等于该对象的请求才返回图片,否则一律 404, 也不代理任意键或重定向到对象存储。成功响应为image/png、nosniff、Cache-Control: public, max-age=300;删除/换图后旧地址最长 5 分钟内仍可能被缓存,之后 失效。所有错误响应沿用no-store。 - OIDC 仅在授予
profilescope 且用户确有头像时附带pictureclaim;无profile不发送, 也不接受客户端自报 URL。 - 上传解码上限 2 MiB;该路由的 JSON body 上限为 3 MiB,其余所有路由仍保持 32 KiB。
HTTPS 与 passkey RP ID
- 生产必须经 HTTPS 访问
PUBLIC_ORIGIN;Cloudflare 侧保持 Always Use HTTPS。 PASSKEY_RP_ID必须等于浏览器地址栏的主机名(即PUBLIC_ORIGIN的主机名)。只改其一 会导致浏览器拒绝或 passkey 校验失败。- WebAuthn 允许
http://localhost上的 HTTP,仅用于本地调试;此时 RP ID 也必须设为localhost。不要用 IP 地址(如127.0.0.1)充当 RP ID。 - 迁移域名后已有 passkey 不能跨 RP ID 迁移:旧 passkey 在新域名下不可用,用户需要重新 绑定。这是 WebAuthn 的协议约束。
开发与测试
direnv allow # 或 devenv shell
前端:
cd web
yarn install --immutable
yarn ci # typecheck + lint + format check + vitest + build
yarn e2e # Playwright(浏览器边界 mock,离线可跑)
Rust:
cargo fmt --manifest-path auth-web/Cargo.toml --check
cargo clippy --manifest-path auth-web/Cargo.toml --locked --all-targets -- -D warnings
DATABASE_URL='<test-postgres-connection-string>' \
cargo nextest run --manifest-path auth-web/Cargo.toml --locked
SQLx 测试需要连接到一个独立的临时 PostgreSQL 测试库(不要指向生产或开发库),并确认
所用角色可 CREATE DATABASE。
tests/system/ 承载真实 Rust/Ory/Postgres/edge 的全栈集成测试。接口细节见
docs/API.md。
许可
本仓库自有代码以 Apache License 2.0 授权,见 LICENSE;第三方依赖保留其各自许可。