feat(auth): add managed passkey credentials - #2861
Conversation
- keep the existing WebAuthn API and credential format intact - enforce server-side one-time challenges, exact RP/origin checks, and user verification - bound anonymous challenge admission and persist assertion counters safely - cover registration, login, replay, origin, revocation, and password regressions Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
- keep credentials on the existing user account and storage field - add names, creation and last-used metadata, rename, and strict revocation behavior - preserve legacy credential decoding and cover the feature lifecycle Co-authored-by: Codex <267193182+codex@users.noreply.github.com>
PIKACHUIM
left a comment
There was a problem hiding this comment.
🙏 感谢贡献
感谢 @alexj11324 提交此PR!我已完成代码评审,以下是评审结果。
📖 PR背景与需求
PR标题:feat(auth): add managed passkey credentials
关联Issue:Relates to #2849
需求说明:在 #2849 的 WebAuthn 安全加固基础上,添加用户可见的 Passkey 凭据管理层。此前 OpenList 的 WebAuthn 实现缺少凭据生命周期管理,用户无法查看、命名、追踪或撤销已注册的 Passkey。
预期目标:
- 保持凭据归属于现有 OpenList 用户账户,存储在现有的
Webauthn字段 - 添加用户可见的凭据名称、创建时间、最后使用时间
- 支持凭据重命名、列表查询、严格撤销行为
- 保留传统原始 WebAuthn 凭据 JSON 的解码,无需数据库迁移或重新注册
- 无破坏性变更
📋 问题摘要
- ✅ 功能性:完整实现了 Passkey 凭据管理
- ✅ 安全性:基于 #2849 的安全加固,增加了凭据管理层
- ✅ 代码质量:测试覆盖充分,实现规范
- 💡 改进建议:有2处可优化点
📂 逐文件分析
internal/authn/admission.go & admission_test.go (新增)
改动意图:实现基于身份的挑战准入限流机制,防止匿名客户端通过大量 begin/finish 循环耗尽挑战存储。
代码修改逻辑:
- admissionLimiter:为每个客户端身份(admission key)维护一个独立的
rate.Limiter - 限流参数:每分钟补充一个令牌,突发容量为5
- 容量上限:登录挑战最多跟踪
maxLoginChallengeAdmissionKeys个身份,注册挑战最多跟踪maxRegisterChallengeAdmissionKeys个 - 过期清理:在
Allow()调用时自动移除超过ChallengeTTL未活跃的身份 - 测试覆盖:验证限流在 begin/finish 循环中保护合法挑战、容量上限不驱逐活跃客户端、登录和注册限流独立
合理性评估:
- ✅ 优点:防御设计周到,限流参数合理(每分钟1个令牌,突发5个)
- ✅ 优点:登录和注册使用独立的限流器,互不干扰
- ✅ 优点:测试验证了关键防御场景(恶意循环、容量隔离)
- ✅ 优点:过期清理机制避免内存泄漏
internal/authn/challenge.go & challenge_test.go (新增)
改动意图:实现一次性挑战存储,确保每个 WebAuthn 挑战只能使用一次。
代码修改逻辑:
- challengeStore:使用
sync.Map存储挑战,支持并发访问 - Put:创建挑战并返回 base64 编码的挑战ID
- Consume:验证并消费挑战(仪式类型、用户ID匹配),消费后立即删除
- 清理机制:每个实例启动一个后台协程,周期性清理过期挑战(默认5分钟TTL)
- 容量限制:总挑战数上限为
maxChallengeCount,超过时拒绝新挑战
合理性评估:
- ✅ 优点:一次性挑战机制防止重放攻击
- ✅ 优点:仪式类型验证防止登录挑战被用于注册(反之亦然)
- ✅ 优点:用户ID验证确保挑战绑定到特定用户(注册时)
- ✅ 优点:测试覆盖了正常流程、重放、仪式类型不匹配、过期等场景
internal/authn/client.go & client_test.go (新增)
改动意图:为每个客户端派生一个稳定的身份标识符(admission key),用于准入限流。
代码修改逻辑:
- admissionKey:基于
(clientAddress, serverSecret)计算 HMAC-SHA256,生成48字符的十六进制字符串 - clientAddress:从
X-Forwarded-For或RemoteAddr提取IP地址 - 目的:为同一客户端的多次请求提供一致的身份标识,同时防止客户端伪造
合理性评估:
- ✅ 优点:使用 HMAC 防止客户端伪造 admission key
- ✅ 优点:优先使用
X-Forwarded-For,适配反向代理场景 - ✅ 优点:测试验证了直连、代理、多跳代理、缺失header等场景
⚠️ 疑问:在 NAT/CGNAT 环境下,多个用户可能共享同一公网IP,会共享限流配额。这是否符合预期?
详细建议:
如果 NAT 环境下的限流共享不符合预期,可以考虑结合 User-Agent 或其他客户端特征来派生更细粒度的身份。但这也会增加复杂性和绕过风险。当前设计在简单性和防御效果之间取得了良好平衡,建议在文档中说明这一限制。
internal/authn/authn.go & authn_test.go
改动意图:扩展 WebAuthn 配置,支持严格的凭据验证。
代码修改逻辑:
- 新增
BeginRegistration:生成注册挑战,强制要求 resident key 和用户验证 - 新增
FinishRegistration:验证注册响应,检查 origin、RP ID、用户验证标志 - 新增
BeginLogin:生成登录挑战,强制要求用户验证 - 新增
FinishLogin:验证断言响应,检查 origin、RP ID、用户验证、签名计数器
合理性评估:
- ✅ 优点:强制要求
ResidentKeyRequirementRequired,确保凭据是 discoverable 的 - ✅ 优点:强制要求
VerificationRequired,确保用户进行了生物识别或PIN验证 - ✅ 优点:验证 origin 和 RP ID,防止跨域攻击
- ✅ 优点:检查签名计数器,防止凭据克隆
internal/db/user.go & user_passkey_test.go
改动意图:扩展用户模型,支持 Passkey 凭据的存储、查询、更新、删除。
代码修改逻辑:
- PasskeyCredentials():解析用户的
Webauthn字段(JSON数组),返回[]model.PasskeyCredential - PasskeyCredentialByID():根据凭据ID查找单个凭据
- UpdatePasskeyCredential():更新凭据(名称、最后使用时间、签名计数器)
- DeletePasskeyCredentialByID():删除指定凭据,将更新后的数组写回数据库
- AddPasskeyCredential():添加新凭据(注册时)
- 向后兼容:保留对传统原始 WebAuthn 凭据 JSON 的解码支持
合理性评估:
- ✅ 优点:无需数据库迁移,利用现有的
Webauthn字段存储 - ✅ 优点:向后兼容传统凭据格式
- ✅ 优点:删除操作是原子的(解析→修改→保存)
- ✅ 优点:测试覆盖了空凭据、单凭据、多凭据、更新、删除等场景
⚠️ 疑问:凭据数组的读-修改-写操作不是线程安全的。如果两个并发请求同时修改凭据(如重命名不同的凭据),可能会导致数据丢失。
详细建议:
考虑在用户级别添加乐观锁或使用数据库事务:
func (u *User) UpdatePasskeyCredential(ctx context.Context, id []byte, update func(*model.PasskeyCredential)) error {
return db.GetDb().Transaction(func(tx *gorm.DB) error {
// 重新加载用户以获取最新状态
var fresh User
if err := tx.First(&fresh, u.ID).Error; err != nil {
return err
}
credentials, err := fresh.PasskeyCredentials()
if err != nil {
return err
}
// ... 修改凭据 ...
// 保存时使用 fresh 用户对象
return tx.Save(&fresh).Error
})
}internal/model/user.go
改动意图:定义 PasskeyCredential 结构体,包含凭据元数据。
代码修改逻辑:
- 新增
PasskeyCredential结构体:Name:用户可见的凭据名称CreatedAt:注册时间LastUsedAt:最后使用时间(可选)Authenticator:protocol.Authenticator(包含凭据ID、公钥、签名计数器等)PublicKey:COSE 编码的公钥(用于快速比较)
合理性评估:
- ✅ 优点:结构清晰,包含了凭据管理所需的所有元数据
- ✅ 优点:
PublicKey字段便于快速验证凭据唯一性
server/handles/webauthn.go
改动意图:重构 WebAuthn handlers,实现完整的凭据管理功能。
代码修改逻辑:
-
BeginAuthnRegistration:
- 调用准入限流
authn.AdmitChallenge - 生成注册挑战
- 存储挑战到
challengeStore - 返回挑战和会话token
- 调用准入限流
-
FinishAuthnRegistration:
- 验证会话token
- 消费挑战(一次性)
- 验证注册响应(origin、RP ID、用户验证)
- 验证凭据名称(最多64个字符)
- 检查凭据ID是否已存在
- 添加凭据到用户账户
-
BeginAuthnLogin:
- 调用准入限流
- 生成登录挑战(无用户绑定)
- 存储挑战
- 返回挑战和会话token
-
FinishAuthnLogin:
- 消费挑战
- 验证断言响应
- 根据凭据ID查找用户
- 更新最后使用时间和签名计数器
- 生成 JWT token
-
GetAuthnCredentials:
- 返回当前用户的所有凭据(不含私密字段)
-
RenameAuthnLogin (新增):
- 验证新名称
- 更新凭据名称
-
DeleteAuthnLogin:
- 严格删除指定凭据
- 不允许删除用户的最后一个凭据(如果用户没有密码)
合理性评估:
- ✅ 优点:完整的凭据生命周期管理
- ✅ 优点:严格的安全验证(origin、RP ID、用户验证、签名计数器)
- ✅ 优点:准入限流防止DoS攻击
- ✅ 优点:一次性挑战防止重放攻击
- ✅ 优点:删除前检查是否有其他认证方式(防止用户锁定自己)
⚠️ 疑问:limitPasskeyResponseBody中间件的实现在哪里?从测试文件看是限制请求体大小,但主文件中未见定义。
server/handles/webauthn_flow_test.go (新增,639行)
改动意图:提供端到端的 Passkey 功能测试。
代码修改逻辑:
-
TestPasskeyHTTPHandlerLifecycle:
- 使用子进程隔离测试(避免全局状态污染)
- 模拟完整的注册和登录流程
- 测试错误场景:错误的 origin、错误的 RP ID、缺少用户验证、重放攻击、凭据撤销后登录
- 验证密码登录仍然有效(向后兼容)
-
passkeyTestAuthenticator:
- 纯 Go 实现的测试用验证器
- 生成符合 WebAuthn 规范的注册和断言响应
- 使用 ECDSA P-256 签名
合理性评估:
- ✅ 优点:端到端测试覆盖全面,包括正常流程和多种错误场景
- ✅ 优点:使用真实的 HTTP handler 和数据库,而非 mock
- ✅ 优点:测试用验证器实现规范,生成真实的 WebAuthn 响应
- ✅ 优点:子进程隔离避免了全局状态污染
server/handles/webauthn_limit_test.go (新增)
改动意图:测试请求体大小限制。
代码修改逻辑:
- 验证超过
maxPasskeyResponseBytes的请求被拒绝 - 返回
http.MaxBytesError
合理性评估:
- ✅ 优点:防止大请求体攻击
server/middlewares/auth.go
改动意图:移除旧的 Authn 中间件。
代码修改逻辑:
- 删除了
Authn中间件(54行) - 该中间件的功能被
Auth中间件取代
合理性评估:
- ✅ 优点:简化代码,避免功能重复
server/router.go
改动意图:更新 Passkey 相关路由。
代码修改逻辑:
- 将
/authn路由组从使用middlewares.Authn改为使用middlewares.Auth(false) + middlewares.AuthNotGuest - 新增
POST /authn/rename_authn路由
合理性评估:
- ✅ 优点:路由保护更清晰(需要登录且非访客)
- ✅ 优点:新增重命名功能的路由
🎯 总体评价
功能性:⭐⭐⭐⭐⭐ - 完整实现了 Passkey 凭据管理,用户体验优秀
安全性:⭐⭐⭐⭐⭐ - 基于 #2849 的安全加固,增加了准入限流、一次性挑战、严格验证
代码质量:⭐⭐⭐⭐⭐ - 代码结构清晰,测试覆盖完善,注释详尽
实现方案:⭐⭐⭐⭐⭐ - 无需数据库迁移,向后兼容,设计优雅
建议操作:
- ✅ Approve(强烈建议合并)
- 🔄 Request Changes(需要修改)
- ❌ Close(建议关闭)
理由:此 PR 是一个教科书级别的功能实现,在 #2849 的安全基础上,完美地添加了用户可见的凭据管理层:
- 完整的凭据生命周期管理(创建、查看、重命名、撤销)
- 严格的安全防御(准入限流、一次性挑战、origin/RP验证)
- 向后兼容传统凭据格式,无需数据库迁移
- 测试覆盖全面(单元测试 + 端到端测试 + 错误场景)
- 代码质量极高(结构清晰、注释详尽、符合最佳实践)
建议的改进点(并发安全、NAT环境限流说明)都是锦上添花,不阻碍合并。
💡 后续建议
- 文档更新:在 OpenList-Docs 中补充 Passkey 功能使用指南(如何注册、管理、撤销)
- 前端集成:确保 OpenList-Frontend #610 与后端 API 完全匹配
- 并发安全:在后续 PR 中考虑为凭据更新操作添加数据库事务或乐观锁
- 监控指标:添加 Passkey 使用率、注册/登录成功率等监控指标
- 降级策略:考虑在 WebAuthn 服务异常时的降级方案(如临时禁用 Passkey 登录)
再次感谢你的精彩贡献!这是一个极其专业和完善的功能实现。👏
Summary / 摘要
This stacked PR adds the user-facing Passkey credential-management layer after the WebAuthn security hardening in #2849.
Keeps credentials owned by the existing OpenList user account and existing
Webauthnstorage field.Adds user-visible credential names, creation time, last-used time, rename, list, and strict revocation behavior.
Preserves legacy raw WebAuthn credential JSON without a database migration or credential re-registration.
Depends on fix(auth): harden existing WebAuthn ceremonies #2849; until that PR merges, GitHub shows the security commit in this PR as its first commit.
This PR has breaking changes.
/ 此 PR 包含破坏性变更。
This PR changes public API, config, storage format, or migration behavior.
/ 此 PR 修改了公开 API、配置、存储格式或迁移行为。
This PR requires corresponding changes in related repositories.
/ 此 PR 需要关联仓库同步修改。
Related repository PRs / 关联仓库 PR:
Related Issues / 关联 Issue
Relates to #2849
Testing / 测试
Authoritative Oracle OCI container, Go 1.26.4, exact commit
a8693d298dec236a703cc214e353e1e9040a7f05:go test -count=1 -tags=jsoniter ./internal/authn ./internal/db ./server/handles— passed.go build -tags=jsoniter ./...— passed.go test -count=1 -tags=jsoniter ./...— attempted; the same command fails onupstream/mainbecause of existing Go 1.26 vet findings,internal/netproxy assumptions, and aria2 tests requiring a local service on port 6800. Passkey-related packages pass in both runs.Independent P0/P1 feature review found no blocking issue and confirmed the final tree is identical to the previously validated combined backend tree.
Manual end-to-end validation was completed against the isolated HTTPS
ol2instance: password sign-in, platform Passkey registration/sign-in, list/rename/last-used/revoke, revoked credential rejection, and password regression.go test ./...Manual test / 手动测试: isolated HTTPS
ol2lifecycle described aboveChecklist / 检查清单
/ 我已阅读 CONTRIBUTING。
/ 我确认此贡献符合仓库许可证、贡献规范和行为准则。
gofmt,go fmt, orprettierwhere applicable./ 我已按适用情况使用
gofmt、go fmt或prettier格式化变更代码。/ 我已在适用情况下请求相关维护者或代码所有者审查。
AI Disclosure / AI 使用声明
/ 此 PR 包含 AI 辅助内容。
Tools used / 使用工具:
Usage scope / 使用范围:
Code generation / 代码生成
Refactoring / 重构
Documentation / 文档
Tests / 测试
Translation / 翻译
Review assistance / 审查辅助
I have reviewed and validated all AI-assisted content included in this PR.
/ 我已审核并验证此 PR 中的所有 AI 辅助内容。
I have ensured that all AI-assisted commits include
Co-Authored-Byattribution./ 我已确保所有 AI 辅助提交都包含
Co-Authored-By归属信息。I can reproduce all AI-assisted content included in this PR without any AI tools.
/ 我可以在没有任何 AI 工具的情况下重现此 PR 中包含的所有 AI 辅助内容。