跳转到内容

身份认证的设计原则

查看 Markdown

Sudomimus 把每一道协议边界都视为独立暴露面:浏览器、应用服务器、原生客户端、OIDC 接入方以及它们之间的网络,都可能分别被攻陷。因此,一条流程不应让任何单一参与方掌握足以冒充其他所有参与方的材料。

这些原则横跨 Connect、OIDC 与原生 direct-issue。部分章节以 Connect 作为具体例子;协议细节则放在各自的集成章节中。

绝大多数身份认证系统都依赖某个长期存在的共享密钥——OAuth 的 client_secret、API key、应用环境变量里那把固定签名密钥。一旦它泄露,过去和未来的所有交换全部沦陷。

Connect 不会把某个长期共享密钥本身当成兑换所有登录的充分证明。每次往返都会在 /establish 时生成全新的 hiddenKey,只在 /redeem 中使用一次,随后永久失效。某次会话的 hiddenKey 泄露只会影响这一次兑换,不会危及应用过去和未来的所有登录。

这是「持有证明(proof-of-possession)」的思路:短期、范围明确的密钥优于长期、被广泛共享的密钥。

由此放弃的做法: 永久使用同一个 client_secret 验证所有令牌交换。

Sudomimus 中的 账户(account) 记录的是「你是谁」——一个带名字的稳定身份。认证方式(authentication method) 记录的是「你怎么证明」——通行密钥、邮箱地址、Steam 身份、AccessKey 凭据。两者是不同的记录,一个账户可以挂多种认证方式。

这样做的好处:

  • 账户枚举攻击更难。 账户记录里不存邮箱。攻击者想试探 [email protected] 是否存在,没有可查询的地方。
  • 切换认证方式不影响身份。 用户在邮箱验证码账户上新增一把通行密钥,只是新增一条认证记录。账户本身不变,与之绑定的所有信息也都不变。
  • 整个数据库里没有「密码」这一列。 Sudomimus 不存密码。没有密码可泄露——因为根本就没有密码。目前支持通行密钥、邮箱验证码、社交登录(Google、GitHub、Discord、Battle.net、X)、Steam、AccessKey 凭据,未来还会接入更多。

由此放弃的做法: 把邮箱当作身份;以及继承自历史的密码库范式。

3. 身份是不透明的,而且对每个应用都不一样

Section titled “3. 身份是不透明的,而且对每个应用都不一样”

Sudomimus 永远不会把内部账户标识符交给你的应用。你拿到的是一个成对标识符(pairwise identifier)——一个稳定、不透明的 sub,它只在你这个应用范围内唯一。同一个人登录两个不同的应用,会拿到两个互不相关的标识符,且都无法反推回底层账户。

这样做的好处:

  • 无法跨应用关联。 两个应用即使串通,拿各自的标识符互相比对,也无法把「它们各自的用户」拼成同一个真人。
  • 标识符在契约上就是不透明的。 它是一个用于精确匹配的令牌,而不是一个供你解析的结构化值。Sudomimus 可以改动它的内部格式而不破坏你的接入——正因为你本来就不该从里面读出任何东西。
  • 令牌泄露只会暴露某个应用所看到的用户身份,不会暴露平台级身份。

由此放弃的做法: 把同一个全局用户 id 撒给每一个接入方,让每一次集成都变成一个追踪向量。

4. 用户决定每个应用能了解到什么

Section titled “4. 用户决定每个应用能了解到什么”

一个 access token 只携带用户已同意分享给那个特定应用的身份声明(claim)。邮箱、名、姓各自独立地被授予或拒绝,且授权随时可以撤销。是否包含某个声明,在每一次签发时都会实时重新判定——所以撤销会在下一个令牌上立即生效,而不是「过一阵子」。

这样做的好处:

  • 应用请求的声明可能不存在。 集成时必须考虑字段缺失。如果应用确实依赖某条声明,请将其设为必需;在用户同意前,Sudomimus 会阻止非交互式签发,而不是静默返回缺少该声明的令牌。
  • 你存得更少。 你从未收到的声明,就是你永远不必保护的声明。

由此放弃的做法: 默认认为应用一旦让用户登录,就有权拿到这个用户的完整资料。

5. 验证用密码学,而不是用数据库查询

Section titled “5. 验证用密码学,而不是用数据库查询”

当你的应用拿到 access token 时,它是一个签名后的 JWT。要判断要不要信任它,应用使用自己的 token-signing 公钥 校验签名——这把公钥只需通过 Connect 的 POST /info 拉取一次并缓存即可。你不需要每一次请求都打到 Sudomimus 问一句「这个用户还登录着吗」。

由此带来:

  • 每次已认证请求都没有额外延迟——验证完全本地完成。
  • 登录之后你的服务对 Sudomimus 不存在可用性依赖。
  • 令牌被刻意设计得很短命(access token 默认有效期是几个小时,不是几天)。过期时,对 /refresh 的一次 HTTPS 调用就能换到新的——远比让用户重新认证一次便宜。

OIDC 的 ID token 是另一种情况:接入方通过 oidc.sudomimus.com/.well-known/jwks.json 上的 JWKS 验证签名。完整说明见 令牌与验证

由此放弃的做法: 「会话保存在 IdP 上」的有状态模型,让每一次已认证请求都退化为一次远程查询。

6. 访问采用允许列表,默认拒绝

Section titled “6. 访问采用允许列表,默认拒绝”

谁可以完成认证、可以使用哪些方式、结果如何返回,都由显式允许列表控制。未配置规则的应用不会允许任何人登录。默认状态就是安全状态:访问权限必须主动开放,而不需要依赖管理员事后补上限制。

由此放弃的做法: 那种「在管理员记得去加限制之前一直敞开着」的系统。

一个常见的反模式叫账户锁定:错几次就把整个账户冻结一小时。它确实让暴力破解更难,但也让任何知道你邮箱的人都能轻易对你发起拒绝服务攻击。

Sudomimus 的处理方式不同。每次认证会话都有自己的尝试次数。失败的尝试只会消耗当前会话的次数,不会影响账户。次数用尽后,当前会话失效,用户可以重新开始;账户本身不会被锁定。

由此放弃的做法: 用合法用户的体验代价去惩罚攻击者的行为。

要伪造一次成功的 /redeem,攻击者必须同时持有:

  • 一把只存在于应用服务器上的密钥(hiddenKey
  • 一个只发送给特定那一个浏览器的引用(exposureKey
  • 一份只有 Sudomimus 在用户通过真实挑战后才会签发的证明(confirmationKey

任何单一的失误都凑不齐这三样东西。URL 泄露不会影响服务器的密钥;服务器被攻破不会牵连其他用户的会话;用户被钓鱼也不会拖累服务器。

机制细节在 三密钥模型 里讲清楚了;但更通用的原则是:把一份证明拆成三份,分散到三个独立的信任域里

由此放弃的做法: 把所有鉴权权限塞进一个单体的会话令牌——这种令牌一旦被偷,就是完全失陷。

9. 信任边界是被执行出来的,不是写在文档里的

Section titled “9. 信任边界是被执行出来的,不是写在文档里的”

Sudomimus 仅有五个公开服务——connect-api.sudomimus.comvia.sudomimus.comdevice-api.sudomimus.comnative-api.sudomimus.comoidc.sudomimus.com。集成方所能触达的一切,都经由这五者之一;其余皆为内部,而这里的「内部」意味着从平台外部根本不可达——这是在平台边缘强制执行的,不是写进某份文档、却离一次配置失误就暴露只有一步之遥的口头约定。

实际效果是:完成一次认证只有少数几条明确定义的通路,集成方无法绕过它们。

由此放弃的做法: 「这是内部 API,请不要调用」这种软约定。

接入 Sudomimus 后,你的应用会自动获得以下安全特性:

  • 你永远见不到密码,所以也不需要安全地保存任何密码。
  • 你拿到的是一个不透明、按应用区分的标识符——你不可能无意间变成一个跨站追踪向量,因为你从一开始就没拿到过任何可泄露的全局 id。
  • 你手里只会有用户同意分享给你的那些身份声明。
  • 你的应用本地验证令牌;Sudomimus 即便宕机也不会把已登录用户挡在你的后端之外。
  • 你应用中任何单一密钥的泄露都被限定在一次会话之内。
  • 你不需要自己写账户锁定逻辑。
  • 你不需要自己写防账户枚举的代码。

接下来可以选择接入方式,或直接查看具体的 Connect 流程