外观
认证服务(auth-server)
apps/auth-server 是从控制面拆出的第一块微服务:DN42 身份的唯一权威。它承载自动对等门户的 ASN 归属验证(email / pgp / ssh 挑战),核验通过后签发 Ed25519 会话 JWT;所有消费方(控制面、门户前端)只凭 JWKS 无状态验签,不再持有任何验证逻辑或会话状态。
端点契约见 API 参考 · 自动对等验证流。
为什么拆
- PGP 核验依赖外部验签二进制:
pgpy已死于 Python 3.13 移除imghdr,且没有维护中的纯 Python 替代,所以核验必须调容器内的gpg。这条依赖把验证逻辑钉死在能跑子进程的运行时里。独立成容器服务后,消费方只需要无状态验签(JWKS),不必背上这条依赖。 - 消费方不止一个:自动对等门户、控制面(两个)、以及未来可能像 oauth.dn42 那样开放的第三方应用。签发/验证分离 + 标准化令牌,让新消费方接入只是"拉 JWKS + 验签"。
- 安全边界收窄:registry 解析、签名核验、邮件验证码等重逻辑集中在一个小服务里,审计面清晰;管理员账号登录(人的身份)刻意不迁,仍留在控制面——控制面管理入口不依赖新服务的可用性,两个信任域继续互不相通。
边界与依赖
- 独立数据库(compose 内置 PG 的第二个 database
dn42_auth):挑战/会话、签名钥都只在这里。控制面与 auth-server 之间没有共享表、没有运行时 RPC(运行时耦合只有两条:控制面拉 JWKS,以及两者各自查 registry-server;都带 fail-closed 降级)。 - registry 数据在独立的 registry-server(见 registry-server 内部实现):同步循环与副本表只有一份,auth-server 经共享包
dn42_registry.RegistryClient(HTTP + service token)查询验证物料(verification-options / key-cert 指纹回查 / identity 富化)。registry-server 不可达时验证链路 fail-closed 503,OAuth claims 富化软失败(令牌照发,富化字段回落空)。验证选项的确定性 id(sha256(kind|value|mntner)前 16 hex)由 registry-server 统一计算,管理 UI 预览与门户枚举天然一致。 - 共享包新增
dn42_common.sessiontoken:会话 JWT 的编解码契约(EdDSA 单算法、claims 形状、JWKS 表示)。签发方(auth-server)与消费方(控制面)import 同一实现。
会话令牌设计
- 形态:标准 JWT,
alg=EdDSA(Ed25519)。解码端只接受 EdDSA,不信任 JWT 头自报的其他算法(算法混淆攻击的老路);头里只信kid。 - claims:
sub = sha256("<mntner>/<asn>")小写 hex——「维护者×AS」二元组的稳定标识(借鉴 oauth.dn42 的 sub 设计,同 AS 挂多个维护者时各是独立身份,为将来平滑升级 OIDC 预留);另有asn(number)、mnt、method、jti、iss、aud、iat、exp。TTL 默认 2h。 - 签名钥:首次启动生成 Ed25519 钥对落
signing_keys表(kid = 公钥 sha256 前 16 hex,跨重启稳定);轮换 = 插新行置 active、旧行保留——JWKS 同时发布新旧公钥,存量令牌在 TTL 内仍可验。 - 落库纪律:令牌明文只在签发响应里出现一次;库中只存
jti+ 整串 sha256(审计/吊销面)。消费方验签不查库;revoked_at列为将来的吊销接口预留(短 TTL 下默认不启用)。 - 消费方验签(控制面
services/peering_identity.py):JWKS 缓存 TTL 5min;遇未知kid强制刷新一次再重验(钥轮换的正常路径);auth-server 不可达时沿用旧缓存,首次即拉不到 →503(语义是"验证器不可用"而非"令牌无效")。
部署形态
- 镜像
deploy/docker/Dockerfile.auth-server(python-slim 加gpg,与控制面同一套构建纪律)。compose 服务auth-server监听 8100,加入控制面同一编排。 - 对外入口
auth.natlan.io:Cloudflare 代理 → 源站 nginx(conf.d/auth-natlan.conf)→ 容器auth-server:8100,前后端同源挂在一起。 - 库
dn42_auth(PostgreSQL 同实例、独立 database)。schema 初始化用create_all——全新小库、无历史包袱,表结构变更遵循「新列可空或带默认」纪律;规模长出来再引入独立迁移链。 - 配置载体
auth-server.toml,DN42_AUTH_*环境变量覆盖。键位见 Auth Server 配置。生产必配:token.issuer(对外公开基址)、registry.service_url与service_token、人机验证提供方、[mail]三件套(email 验证方式)。 - 控制面接入:
[auth_service] base_url,容器网内http://auth-server:8100即可。
nginx 访问策略
整站公开——每个端点都设计为面向公众,鉴权在应用内自理(人机验证、挑战凭据、会话 JWT、静态 admin token)。
唯一收窄的是运维面 /api/v1/admin/*(OAuth 应用注册与手动 registry 同步),只放行管理 IP。非白名单来源返回伪装的 200 {"status":"ok"} 而不是 403——不暴露此处存在管理面。
IP 判定基于 nginx realip:set_real_ip_from 列 Cloudflare 官方网段、real_ip_header CF-Connecting-IP。只有经 Cloudflare 到达的请求才采信该头,直连源站伪造头无效。
人机验证
提供方是自建 Cap 实例 https://challenges.natlan.io(Cap,基于工作量证明)。它随控制面主 compose 一起编排(cap 与 valkey 两个服务),由 nginx conf.d/challenges-natlan.conf 反代。
cap.api_endpoint 的内置默认即生产实例,生产只需配 site_key 与 secret_key。
两个地址的分工要分清:
| 配置项 | 给谁用 | 说明 |
|---|---|---|
cap.api_endpoint | 浏览器 | 授权页 widget 直连解题,必须是公网可达地址 |
cap.internal_endpoint | 服务端 | siteverify 用。与 Cap 同机部署时指到容器网内的服务名,免得服务器验证自己签的票要绕一圈公网与 CDN |
生产 site key 开启了 instrumentation——第二重浏览器校验:PoW 全对但缺浏览器执行结果的自动化提交会被 403 拒。这也是授权页 CSP 必须放行 'unsafe-eval' 的原因,见授权页静态资产。
Cap 实例的 CORS 白名单须包含 auth.natlan.io 与门户来源。它的管理面同样走 IP 门禁:widget 端点公开,面板与静态资源仅放行运维 IP。
Cloudflare Turnstile 仅作回落,[cap] 未配齐时生效。两者共享同一 verify 签名与错误契约——detail 措辞恒为 turnstile verification failed,不因提供方而变;请求体字段 captcha_token 与历史名 turnstile_token 等价收取。
widget 的样式与文案由前端仓库持有,后端只下发 captcha.endpoint(已含 site key 与必需的尾斜杠)。
日志
服务自身的 INFO 日志(挑战签发、核验通过、OAuth 应用注册、签名钥生成、registry 同步结果)是这个服务的审计线,必须显式配置根 handler 才落得进容器日志:Python 无根 handler 时只有 WARNING 及以上靠 lastResort 漏到 stderr,而 uvicorn 只配 uvicorn* 命名空间。main._configure_logging() 负责这件事,级别用 DN42_AUTH_LOG_LEVEL 调。
授权界面(独立前端)
/authorize 的界面是一个纯静态 SPA,源码在前端仓库 natlan-web,同源托管在 auth.natlan.io 下:nginx 把 /authorize、/assets/、/favicon.svg 走静态,其余一律回后端。
同源是刻意的:授权页是安全敏感界面,用户地址栏应始终显示他正在授权的那个域名;顺带免掉 CORS 与第三方 Cookie。
nginx 的静态白名单必须显式列举、默认回后端——反过来做(默认静态 + 列举后端路由)会在后端新增端点时把它悄悄 404 掉。构建产物文件名带内容 hash 可长缓存,但 /authorize 的 HTML 必须 no-store(每次授权的 query 参数都不同)。
前端零配置:API 走同源相对路径,验证码提供方与 site key 由 GET /api/v1/oauth/authorize-context 的 captcha 字段运行时下发——换 Cap key 或换提供方都不必重新构建前端。
后端不出 HTML:早期与独立前端并存的那份服务端自渲染授权页已随前端上线一并删除。除了消灭「两处各拼各的、改了一处忘另一处」的漂移,更重要的是关掉一整类注入面——服务端手工拼 HTML、把 state 之类的 query 参数塞进 <script> 上下文(json.dumps 不转义 <,一个字面 </script> 就能提前闭合脚本块),落在 IdP 源上就是反射型 XSS,而 IdP 会话一失守,全部 RP 的授权都能被代签。现在 auth-server 的授权入口只有 GET /api/v1/oauth/authorize-context 一个 JSON 端点,/authorize 在后端是 404(生产由 nginx 交给静态 SPA);测试里有一条断言守着这件事,防止将来又长回来。
回跳 URL(参数错误时把 error 带回已验证的 redirect_uri)统一走 _error_redirect() 用 urlencode 组装:state 是客户端原样回声,手工拼串遇到值里含 & / # 会往回调地址注入额外参数。
接口契约见 Auth Server API,产物投放与 nginx 约束见授权页静态资产。
OAuth 2.0 / OIDC Provider
在挑战验证内核之上,auth-server 同时是完整的 OAuth 2.0 / OIDC Provider(生产域名 auth.natlan.io,对标 oauth.dn42):第三方 DN42 应用经标准授权码流程接入,「登录」= 完成 ASN 归属验证(会话 JWT 即登录态),没有独立的用户名密码体系。实现是自研最小面(services/oauth.py + api/oauth_routes.py,约六百行),刻意不引 Authlib——只支持一种流程时框架的抽象成本大于收益。
端点:/.well-known/openid-configuration(discovery)、GET /api/v1/oauth/authorize-context(授权页渲染上下文:校验 authorize 参数并给出 client / scope / 回显参数 / 验证码配置;/authorize 这个地址由同源静态 SPA 提供)、POST /api/v1/oauth/approve(授权页提交,Bearer 会话 JWT)、POST /token、GET|POST /userinfo、/api/v1/admin/oauth-clients*(应用注册运维面,静态 admin token;client_secret 明文只在创建响应出现一次)。
安全基线(刻意收紧):只有 response_type=code + 强制 PKCE S256(公共/机密客户端一律);redirect URI 精确匹配注册值且强制 https(loopback http 例外);授权码 120s 单次使用、条件 UPDATE 消耗(并发双兑换只有一方赢)、重放即吊销由它换出的全部 refresh token;refresh token 轮换式(120d 滑动上限,与对等门户 120 天会话对齐);code / refresh token / client secret 只存 sha256;令牌签名复用会话 JWT 的 Ed25519 钥与 JWKS(EdDSA 单算法)。access token 的 aud = issuer 本身、会话 JWT 的 aud = dn42-peering——两类令牌互相打不进对方的验证面。
claims(对齐 oauth.dn42):sub 同会话 JWT 口径;dn42 claim 在 UserInfo 给 asn / active_mnt / route / route6 / timestamp(现查 registry,RP 建议周期刷新),ID Token 额外给 mnt_by / auth_method / active_name / active_person;profile → name(registry person)、email → registry 登记邮箱。route/route6 物料来自 registry 副本新增的 route 对象同步(registry_route 表,多 origin 展开;telephony 对象未同步,该字段暂缺)。
资源指示器(RFC 8707):access token 的 aud 默认是 issuer(只够打 /userinfo)。带 resource 授权时该资源被批进授权码,换令牌时指定它、aud 即该资源——这是把用户身份透传给别的服务的地基:资源服务按自己的标识校 aud,别处签的令牌打不进来。两道闸:resource 必须在 [oauth] resources 白名单内(否则任何 client 都能给自己签 aud 任意的令牌,资源侧的校验形同虚设),且换令牌阶段只能在授权时批准的集合内挑,不得凭空扩权。一枚 access token 只有一个受众,需要多个受众就用同一枚 refresh token 分别换(/userinfo 一枚、目标资源一枚)。原为独立部署的自动对等门户透传控制面而建;门户并入控制面后暂无消费方,能力保留(通用 OAuth 特性)。
事务纪律教训:Database.session 在异常时回滚——「吊销/消耗」这类必须落库的副作用绝不能与随后的 raise 同事务(重放吊销曾因此被静默回滚),一律独立事务提交后再抛错。
PGP 核验的判定基础
签名核验用 Sequoia 的 sqv(专职验签器)而非 gpg。gpg 是有状态的(homedir / 钥环 / agent),验签要先「导入」再「解密」,判定散落在多个子进程的输出解析里——这条路上出过两次生产事故:VALIDSIG 状态行按字段位置解析(各版本字段数不同,有效签名恒判失败);以及无 User ID 的公钥 gpg 打印 no user ID - skipped 却仍以 rc=0 退出,钥环实际是空的。sqv 无状态:证书与明文都当文件传,退出码即判定,stdout 是签名者主钥指纹。
判定与提示语严格分工:密码学判定只看退出码与 stdout 指纹;stderr 文本只用于把失败分类成人话提示(缺公钥 / 证书解析不了 / 签名不过)。sqv 各失败模式退出码同为 1,分不开;若其措辞变动,最坏结果是提示语变笼统,不会把失败判成通过。
这条边界依赖 sqv 的一个契约:stdout 只列实际验通了签名的那把证书的主钥指纹,钥环里放了什么都不会泄进来,且子钥必须有成立的绑定签名。DN42 registry 公开可编辑、任何人的公钥都公开可下载,因此该契约经实测钉过(对应用例在 apps/auth-server/authserver/tests/test_pgp.py 的「冒充」一组):
| 冒充手法 | 结果 |
|---|---|
| 受害者证书与攻击者证书拼进同一钥环,用攻击者私钥签 | 只打印攻击者指纹 |
| 攻击者签名子钥嫁接到受害者主钥下 | Signing key … is not bound 拒 |
| 攻击者主钥改包头伪装成受害者子钥 | 同上,拒 |
| 受害者证书尾部追加垃圾字节 | Malformed certificate 拒 |
| 攻击者证书的 UID 冒充受害者 | 只打印攻击者指纹 |
注册表本身可公开编辑不构成绕过:信任链是 aut-num → mnt-by → mntner.auth,谁能改 mntner 谁就拥有该 ASN,这是 DN42 自身的授权模型;本服务忠实执行该模型的裁定。
公钥来源是回退链(registry key-cert → 公钥服务器 → 随签名提交),逐个候选独立调用 sqv 试到验通为止。逐个而非一次全喂,是因为语法上无法解析的候选会让整次调用硬失败(用户粘错内容是常态),独立调用可让坏候选不影响其余。回退链是必须的:keys.openpgp.org 在钥主验证邮箱之前只发布剥掉 User ID 与全部绑定签名的副本,这种副本密码学上不完整、验不了任何东西——曾导致「按提示把钥传上公钥服务器」反而堵死原本能走通的粘贴公钥路径。
Passkey(WebAuthn)
挑战验证的痛点是每次登录都要 PGP/SSH 签一遍 nonce(或收一封验证码邮件)。Passkey 把「验证一次归属」升级为「以后免签名登录」:完成任意一次挑战验证后,成功响应带 passkey_enrollment_available 提示,前端引导用户经 navigator.credentials.create 注册凭据,此后登录只需碰一下认证器。
信任模型是这个功能唯一的设计难点:email/pgp/ssh 挑战证明的是此刻对 registry 登记凭据的控制权,passkey 证明的是「曾经证明过归属的那个人」。因此:
- 凭据绑定 mntner,不绑 ASN——registry 授权模型里持有认证凭据的本来就是 mntner,ASN 归属由
mnt-by派生。一个 mntner 名下多个 ASN 共享同一枚凭据,凭据表里不存任何 ASN 快照(没有会过期的副本)。 - 登录时授权关系现查:
login/begin按 ASN 现查 registry 取维护者、login/finish核验 assertion 后再查一次确认凭据的 mntner 仍 auth 该 ASN。ASN 转手 = 自动签不出会话,不需要吊销机制。 - 注册须持有效会话 JWT:只有刚完成归属验证(或 passkey 登录)的人能给对应 mntner 绑/删凭据,信任链闭合。
- 签出的会话 JWT 与挑战流程完全同构(仅
methodclaim 为passkey),消费方(控制面、OAuth 流程)无感。
实现(services/passkeys.py + api/passkey_routes.py,核验用 py_webauthn):ceremony 服务端挑战落 webauthn_ceremonies 表(单次使用——finish 先删行再核验,重放不可能;TTL 5 分钟);凭据落 webauthn_credentials(COSE 公钥、sign_count、transports,均 base64url)。核验失败一律同一句 400 文案,差异只进日志——不给探测者当预言机。RP ID / origin 白名单默认从 token.issuer 派生(授权 SPA 与本服务同源),[passkey] 段可显式覆盖(本地联调放行 Vite dev server 用)。
mntner 删除的凭据回收(三层,防止持久表积累永久死数据):
- 登录现查即拒:mntner 不再 auth 目标 ASN →
403;若 mntner 整个从 registry 消失,顺带惰性删除其名下全部凭据(已永久失效); - 周期对账(
MaintenanceRunner,日频):凭据表里的 distinct mntner 逐个查 registry 存在性——消失先置mntner_missing_since软禁用(登录侧排除),连续缺席超过 30 天宽限期才物理删除,期间回归自动解禁。registry 不可用时整轮跳过:宁可多留一轮,不在数据源不可信时动手(同步事故不误杀); - 刻意不做不活跃过期(按
last_used_at回收):凭据长期不用不等于失效,回收只跟 registry 事实走。
周期维护任务
services/maintenance.py 的 MaintenanceRunner 是本服务唯一的垃圾回收线(lifespan 拉起的单个背景协程,先睡后干),每小时一轮:过期挑战、过期 WebAuthn ceremony、过期 OAuth 授权码 / refresh token(revoked_at 非空但未过期的行保留——轮换链的重放吊销靠它们)、过期超 90 天的会话审计行(peering_sessions 保留期);passkey↔registry 对账按日频搭车执行。任何一步失败只记日志不中断循环,下一轮自然重试。在此之前 purge_expired 只有定义没有调用方,TTL 物料会无限积累——这条回收线别删。
发信副作用的两道闸
email 验证方式每次签发都会向 registry 登记邮箱真发一封信,这是本服务唯一一个「一次请求 = 一次对外不可撤销动作」的路径,因此不能只靠通用限速:
- 在途去重 + 重发冷却(
RESEND_COOLDOWN_SECONDS,60s):同一(asn, option_id)在冷却期内重复请求复用在途挑战,不建新行、不重发邮件;响应带reused与resend_available_in供前端做倒计时按钮。冷却过后的重发把旧挑战置superseded——只有最新验证码有效。 - 单 ASN 每小时封顶(
MAX_CHALLENGES_PER_ASN_PER_HOUR,10):冷却按选项计,挡不住换着选项刷;没有这层就等于把服务借出去做邮件轰炸,也会烧穿 Cloudflare Email Sending 的日配额。
这两道闸是生产实测倒逼出来的:上线首日库里就出现了同选项、间隔 86ms 的两条挑战——邮件同步发导致响应慢,用户以为没点上就连点,于是收到一堆验证码。服务端修完的同时,授权页也补了「在途禁用按钮 + 改文案」(withPending),从源头掐掉重复提交;邮件发送耗时已插桩进 INFO 日志,用于判断慢的到底是上游邮件 API 还是别处。