Skip to content

Auth Server API

源码:apps/auth-server/authserver/api/。生产基址 https://auth.natlan.io

两组挂载点:

挂载点内容
业务端点/api/v1/*ASN 归属验证、Passkey、OAuth 授权页上下文、运维面
OIDC 协议端点//.well-known/openid-configuration/.well-known/jwks.json/authorize/token/userinfo

协议端点只挂根:issuer 是 https://auth.natlan.io,它写进每一枚 ID Token,而 OIDC Discovery 规定发现文档位于 <issuer>/.well-known/openid-configuration。业务端点保持 /api/v1——本服务独占一个主机名,主机名本身已完成服务分段,再叠一层 /auth 是冗余。

设计与安全基线见 认证服务内部


身份模型

身份主体是验证过的 ASN,与控制面的管理员账号是两个互不相通的信任域。

人机验证提供方 = 自建 Cap 实例 https://challenges.natlan.ioCap,基于工作量证明)。[cap] api_endpoint 的内置默认即该实例,生产只需配 site_keysecret_key。Cloudflare Turnstile 仅作迁移期回落,[cap] 未配齐时生效。

对外契约不因提供方而变:验证失败时 400detail 恒为 turnstile verification failed(措辞是历史锁定的稳定契约);请求体的 token 字段 turnstile_token 与中性名 captcha_token 等价收取。Cap 无 action 概念——token 烘进 site key 维度即天然隔离,下文提到的「widget action」约束仅在 Turnstile 回落时适用。widget 的 endpoint 与提供方由 GET /api/v1/oauth/authorize-contextcaptcha 字段运行时下发,前端不必硬编码。


ASN 归属验证

方法路径鉴权说明
POST/api/v1/verification-options人机验证报 ASN → 可选验证方式列表
POST/api/v1/challenge人机验证签发一条归属验证挑战,201
POST/api/v1/challenge/submit挑战本身提交答案换会话 JWT
GET/api/v1/session会话 JWTintrospection:返回令牌承载的身份
GET/.well-known/jwks.json会话 JWT 与 ID Token 的验签公钥集(Ed25519 / OKP)

POST /verification-options

请求 { asn, captcha_token },返回该 ASN 可用的验证方式(email / pgp / ssh),带来源 mntner 与跨同步稳定的选项 id。

动作顺序是先查收录、后验人机:ASN 语法非法 422(口径为共享包的 validate_asn[1, 2³²-1]);未收录 404不消耗 token(存在性是半公开信息,手滑零成本重试);收录后才消耗一个一次性 token(Turnstile 回落时 widget action 须为 peering-verify,与登录用的 widget 互不通用),失败 400 并计 IP 限速(独立键池,5 次 / 15 分钟后 429)。

邮箱 value 打码(形如 ki***@yahoo.com,发起挑战时引用选项 id 而非明文)。响应带 Cache-Control: no-store

POST /challenge

请求 { asn, option_id, captcha_token }201 返回 { challenge_id, method, nonce?, instructions, sent_to?, expires_at, reused, resend_available_in }

  • nonce 仅 ssh / pgp 方式有——待签随机串,含 ASN 前缀;
  • sent_to 仅 email 方式有——打码地址。验证码只出现在邮件里,响应与库中都没有明文。

选项由服务端按 option_id 回查 registry 决定,客户端报的方法与值不被信任(伪造 id 404)。email 方式在未配置邮件发送器的部署上 503(fail-closed,不静默假装发出)。挑战 TTL 15 分钟。

连点保护与发信封顶(email 方式每次签发都真发一封信,这两道闸缺一不可):

  • 同一 (asn, option_id)冷却期 60 秒内重复请求一律复用在途挑战——201 返回同一个 challenge_idreused: trueresend_available_in 给出剩余秒数,不建新挑战、不重发邮件。
  • 冷却过后的重发会把同选项的旧挑战置为 superseded,只有最新验证码有效,旧码提交回 409
  • 单 ASN 每小时签发上限 10 次,超出 429。冷却只挡得住同一选项连点,挡不住换着选项刷;没有这层封顶等于把本服务借出去对 registry 登记邮箱做轰炸,还会烧穿上游邮件配额。

POST /challenge/submit

请求 { challenge_id, answer, public_key? }answer 为 SSHSIG armor(ssh)、PGP clearsign(pgp)或 6 位验证码(email);public_key 仅 pgp 用。

成功 200 返回 { token, asn, mntner, method, expires_at, passkey_enrollment_available }tokenEd25519 签名的会话 JWT(证明控制该 ASN,TTL 2 小时;明文只此一见,库中只存 sha256 与 jti)。passkey_enrollment_availabletrue 表示该维护者名下还没有 Passkey,前端据此在成功页提示添加。

本端点刻意不设人机验证:挑战本身即一次性、带 TTL 与尝试上限的凭据,暴力面已由上限封死。

错误:挑战不存在 404、已终态 409、过期 410、答案错 400、尝试超限(5 次)429 且挑战置 failed 须重签、pgp 找不到公钥 422、pgp 不可用 503

会话 JWT claims

编解码单点在共享包 dn42_common.sessiontoken,签发方与消费方同一实现。

claim语义
subsha256("<mntner>/<asn>") 小写 hex——「维护者 × AS」二元组的稳定标识。同一个 AS 挂多个维护者时各是独立身份
asnnumber
mnt维护者名
methodemail / pgp / ssh / passkey
jti / iss / aud / iat / exp标准字段

算法只收 EdDSA,不信任 JWT 头自报的其他 alg

消费方(控制面)经 JWKS 本地无状态验签,验证热路径不打网络。签名钥轮换时新旧钥并存,存量令牌在 TTL 内仍可验。

核验实现要点

sshauthserver/services/ssh.py)是纯 Python 实现(镜像内无 ssh-keygen):按 OpenSSH PROTOCOL.sshsig 解析 armored 签名,要求 namespace 恰为 dn42-autopeer(域分离,别处签的字节打不进本流程),且签名内嵌公钥逐字节等于 registry auth 登记的公钥——信任锚是 registry,不是签名自述。支持 ed25519、ecdsa-nistp{256,384,521}、rsa-sha2-{256,512};sk-* 硬件钥暂不支持并明确报错。

pgpauthserver/services/pgp.py)调镜像内的 gpg 二进制:每次核验用临时 homedir(用完即删)、--batch --no-tty --trust-model always、禁用自动网络取钥。四步都要过——导入公钥后重算指纹并与 registry auth 登记值比对GOODSIGVALIDSIG 的主钥指纹匹配、被签明文等于挑战 nonce(防重放)。

PGP 公钥回退链:registry key-cert 对象(同步已收,按指纹索引)→ keys.openpgp.org(按指纹精确取,不可达时静默降级不中断流程)→ 用户随签名一并提交的 public_key。三处都没有时 422 并提示附上公钥。无论公钥从哪来,信任锚始终是 registry 登记的指纹。

用户视角的操作步骤见 ASN 归属验证


Passkey(WebAuthn)

前缀 /api/v1/passkeys。凭据绑定维护者(mntner)而非 ASN:一个 mntner 名下多个 ASN 共享同一枚 Passkey。注册须持有效会话 JWT(即刚完成一次归属验证);登录时该 mntner 管哪些 ASN 向 registry 现查——ASN 转手后自动签不出会话,无需吊销。

方法路径鉴权说明
POST/register/begin会话 JWT开注册 ceremony:返回 { ceremony_id, options }options 直接喂 navigator.credentials.create)。会话无 mnt claim 时 400
POST/register/finish会话 JWT{ ceremony_id, credential, label? }credential = create 结果的 toJSON()):核验 attestation 落库,201 返回凭据摘要
GET`` (即 /api/v1/passkeys会话 JWT当前维护者名下凭据列表 { mntner, passkeys: [{ credential_id, label, aaguid, created_at, last_used_at, disabled }] }
DELETE/{credential_id}会话 JWT删除自己名下的一枚凭据,204;他人的视同不存在 404
POST/login/beginIP 限速{ asn }:现查 registry 该 ASN 的维护者 → 名下可用凭据 → { ceremony_id, options }(喂 navigator.credentials.get
POST/login/finishIP 限速{ ceremony_id, credential }:核验 assertion → 再次现查 registry 确认凭据的 mntner 仍 auth 该 ASN → 签发会话 JWT

前端契约要点

  • login/begin404 是正常分支,不是错误:ASN 未收录,或维护者名下无可用凭据。前端应静默回落到挑战验证流程,不展示任何失败提示。registry 不可用 503
  • login/finish 成功响应与 challenge/submit 同形,method 恒为 passkey。授权已失效 403(mntner 已从 registry 消失时顺带惰性回收其全部凭据)。
  • ceremony 单次使用(重放 404)、TTL 5 分钟(410);同一凭据重复注册 409
  • 核验失败统一 400,细节只进日志——不给探测者当预言机。
  • WebAuthn 是 secure context 专属 API:页面须跑在 HTTPS 或 localhost,且 origin 必须在后端 [passkey] origins 白名单内(生产同源托管天然满足)。

OAuth 2.0 / OIDC Provider

auth-server 同时是面向第三方 DN42 应用的 OIDC Provider:授权码 + 强制 PKCE S256。这里的「登录」就是上面的 ASN 归属验证。

方法路径鉴权说明
GET/.well-known/openid-configurationOIDC discovery(RP 自动配置)
GET/authorize无(页面内登录)授权入口,RP 把用户送到这里
GET/api/v1/oauth/authorize-context授权页的渲染上下文(JSON,no-store),参数校验在此完成
POST/api/v1/oauth/approve会话 JWT授权页提交:签发授权码,返回 {redirect_to}
POST/tokenclient 认证令牌端点(RFC 6749 表单)
GET / POST/userinfoaccess token实时 claims
POST/api/v1/admin/oauth-clients静态 admin token注册应用
GET / DELETE/api/v1/admin/oauth-clients[/{client_id}]静态 admin token列出 / 注销应用

discovery 的关键声明(线上实测):id_token_signing_alg_values_supported=["EdDSA"]code_challenge_methods_supported=["S256"]response_types_supported=["code"]grant_types_supported=["authorization_code","refresh_token"]token_endpoint_auth_methods_supported=["client_secret_basic","client_secret_post","none"]scopes_supported=["openid","profile","email","dn42"]resource_indicators_supported=true

/authorize 与 authorize-context

/authorize 必带 response_type=codecode_challenge(S256),scope 须含 openid;可带可重复resource(RFC 8707)声明本次授权面向哪些资源服务,须在 [oauth] resources 白名单内。

授权页本身是同源静态 SPA(由 nginx 提供,后端不渲染 HTML),它把收到的 query 原样透传给 authorize-context 取渲染数据。该端点有三种结果:

结果含义前端动作
200 + {client, scopes, params, captcha}参数合法照此渲染。params 是服务端规范化后的回显值,原样喂给 approve
200 + {redirect_to, error}参数错,但 redirect_uri 已验证error 带回第三方应用(如 invalid_scope / invalid_request / invalid_target
400 + {error, fatal: true}client_idredirect_uri 本身不合法只能显示错误页,绝不可跳转——未验证的回跳地址是不可信输入

POST /oauth/approve 参数二次校验后签发授权码(120s,单次使用),返回 {redirect_to}(含 codestate);deny=trueerror=access_denied 回跳。

/token

grant_type=authorization_code(须带 code_verifier,PKCE 强制)或 refresh_token轮换式:旧枚立即吊销)。

可选 resource(RFC 8707)指定 access token 的目标受众——省略则 aud=issuer(打 /userinfo 用),指定则 aud 为该资源(须在授权时已批准);响应回显 audience

client 认证收 client_secret_basic / client_secret_post / 公共客户端 none。授权码重放 → invalid_grant,并吊销其换出的全部 refresh token。

响应含 access_token(1h)、id_token(1h)、refresh_token120 天滑动上限,与对等门户的会话 TTL 对齐)。

/userinfo

实时 claims:subpreferred_usernameAS<asn>);profilenameemail → registry 登记邮箱,dn42{asn, active_mnt, route, route6, timestamp}(现查 registry,建议 RP 周期刷新)。会话 JWT 打不进本端点(audience 区隔)。

ID Token 专属字段dn42 scope):在 UserInfo 子集之外额外携带 mnt_by(AS 全部维护者)、auth_methodemail / pgp / ssh)、active_name / active_person(registry person 的展示名与句柄)。sub 与会话 JWT 同口径。registry 的 telephony 对象未同步,该字段暂缺。

应用注册

POST /api/v1/admin/oauth-clients 请求 {name, redirect_uris, confidential}。回调必须 https(loopback http 例外)、精确匹配;机密客户端的 client_secret 明文只在本响应出现一次(库中存 sha256)。注销连带吊销该应用的全部 refresh token。

运维面路径 /api/v1/admin/* 在 nginx 侧另有 IP 白名单(见 deploy/docker/nginx/conf.d/auth-natlan.conf)。


registry 副本运维

方法路径鉴权说明
POST/api/v1/registry/sync静态 admin token手动触发一轮 registry 同步;未配置 server.admin_token403
GET/api/v1/registry/statusregistry 副本状态(commit、各对象计数、同步时刻,无敏感载荷)

auth-server 经 HTTP 客户端查询 registry-server,本组端点是对其同步能力的转发。副本服务本身见 registry-server API


健康探针

GET /healthz——存活 + DB 连通性。