Skip to content

feat(auth)!: migrate WebAuthn to Passkey.#2161

Open
HappyDIY wants to merge 1 commit intoOpenListTeam:mainfrom
HappyDIY:feat/upgrade_webauthn_to_passkey
Open

feat(auth)!: migrate WebAuthn to Passkey.#2161
HappyDIY wants to merge 1 commit intoOpenListTeam:mainfrom
HappyDIY:feat/upgrade_webauthn_to_passkey

Conversation

@HappyDIY
Copy link

@HappyDIY HappyDIY commented Feb 23, 2026

feat(passkey)!: 迁移 WebAuthn 至 Passkey 并默认启用,统一后端 API 命名

… management

  • 迁移传统 WebAuthn 至 Passkey,支持云端同步与免用户名认证
  • 将后端所有 WebAuthn API 重命名为 Passkey,提升命名标准化程度
  • 默认启用 Passkey,以符合当前行业推广趋势
  • 在 Passkey 管理页面新增显示用户设备 IP 和 User-Agent(UA),便于设备区分
  • 新增升级流程,将旧版 WebAuthn 凭证迁移至新版 Passkey 格式
  • 在旧凭证未升级或删除前,禁止创建新的 Passkey,避免升级后的兼容性问题

Description / 描述

本次 PR 对现有 WebAuthn 认证体系进行整体升级,统一迁移至 Passkey 方案,并对相关接口与管理页面进行增强。

主要包括:

  1. 认证体系升级

    • 将传统 WebAuthn 迁移为 Passkey
    • 支持云端同步(multi-device credentials)
    • 支持免用户名输入(discoverable credentials)
  2. 接口命名标准化

    • 后端所有 webauthn 相关 API 重命名为 passkey
    • 同步更新相关调用与文档,避免语义混用
  3. 默认策略调整

    • 将 Passkey 设置为始终启用
    • 简化认证流程,统一安全策略
  4. 管理页面增强

    • Passkey 管理页面新增设备 IP 与 User-Agent 展示
    • 提升多设备场景下的可识别性与可管理性
  5. 旧凭证升级机制

    • 新增“升级”按钮,引导用户将旧版 WebAuthn 凭证升级为新版 Passkey
    • 若存在未升级的旧凭证:
      • 禁止创建新的 Passkey
      • 允许通过“升级”或“删除旧凭证”解除限制

Motivation and Context / 背景

随着主流科技公司全面推广 Passkey 体系,传统 WebAuthn 命名与使用方式已逐步过时。

本次升级解决以下问题:

  • 提升跨设备认证体验(支持云端同步)
  • 支持无用户名输入登录流程
  • 统一后端 API 命名,降低维护成本
  • 避免系统中长期并存两套凭证体系
  • 防止版本升级后旧凭证与新流程不兼容

How Has This Been Tested? / 测试

  • 本地环境验证:
    • 新用户注册与创建 Passkey 流程
    • 免用户名登录流程验证
  • 旧用户升级验证:
    • 存在旧 WebAuthn 凭证时升级流程测试
    • 未升级时创建新 Passkey 限制验证
    • 删除旧凭证后恢复创建能力验证
  • 多设备测试:
    • 不同浏览器 / 不同设备下创建与识别测试
  • API 回归测试:
    • 重命名接口的调用路径验证

Checklist / 检查清单

  • I have read the CONTRIBUTING document.
    我已阅读 CONTRIBUTING 文档。
  • I have formatted my code with go fmt or prettier.
    我已使用 go fmtprettier 格式化提交的代码。
  • I have added appropriate labels to this PR (or mentioned needed labels in the description if lacking permissions).
    我已为此 PR 添加了适当的标签(如无权限或需要的标签不存在,请在描述中说明,管理员将后续处理)。
  • I have requested review from relevant code authors using the "Request review" feature when applicable.
    我已在适当情况下使用"Request review"功能请求相关代码作者进行审查。
  • I have updated the repository accordingly (If it’s needed).
    我已相应更新了相关仓库(若适用)。

… management

- Migrate traditional WebAuthn to Passkey to support cloud synchronization and username-less authentication
- Rename all backend WebAuthn APIs to Passkey for improved standardization
- Enable Passkey by default to align with current industry adoption trends
- Display user device IP and User-Agent (UA) in Passkey management page for better device identification
- Add upgrade flow to migrate legacy WebAuthn credentials to new Passkey format
- Prevent creation of new Passkeys until legacy credentials are upgraded or removed to avoid post-upgrade incompatibility
@jyxjjj
Copy link
Member

jyxjjj commented Feb 25, 2026

For Reviewers:
Also see: OpenListTeam/OpenList-Frontend#393 (comment)

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR performs a breaking migration from legacy WebAuthn naming/behavior to a Passkey-first flow, including API renames, credential metadata tracking, and legacy-credential upgrade constraints.

Changes:

  • Renamed /authn/webauthn_* endpoints to /authn/passkey_* and added a legacy-status endpoint.
  • Updated authn credential persistence to a record-based JSON format (resident-key + creator IP/UA) with legacy fallback parsing.
  • Enforced “legacy credential must be upgraded/deleted before creating a new passkey” and required resident keys for new registrations.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.

Show a summary per file
File Description
server/router.go Renames WebAuthn routes to Passkey routes and adds passkey_legacy_status.
server/handles/webauthn.go Updates login/registration logic for discoverable credentials, legacy handling, and stores creator IP/UA.
internal/model/user.go Introduces WebAuthnCredentialRecord format and legacy parsing fallback helpers.
internal/db/user.go Writes/reads new authn record format; adds legacy-credential detection helper.
internal/conf/const.go Removes the WebauthnLoginEnabled setting key.
internal/bootstrap/patch/v3_32_0/update_authn.go Updates a historical comment associated with authn patching.
internal/bootstrap/data/setting.go Removes initialization of the WebauthnLoginEnabled setting item.
drivers/cloudreve_v4/util.go Updates a driver error message from “webauthn” to “passkey”.
Comments suppressed due to low confidence (1)

server/handles/webauthn.go:168

  • BeginAuthnRegistration also calls common.ErrorResp on BeginRegistration/json.Marshal failures but does not return, which can lead to sending a success response with nil/partial data or further panics. Return immediately after each error response (and ensure options/sessionData are not used when err != nil).
	options, sessionData, err := authnInstance.BeginRegistration(
		user,
		webauthn.WithResidentKeyRequirement(protocol.ResidentKeyRequirementRequired),
	)

	if err != nil {
		common.ErrorResp(c, err, 400)
	}

	val, err := json.Marshal(sessionData)
	if err != nil {
		common.ErrorResp(c, err, 400)
	}

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 151 to 154
authnInstance, err := authn.NewAuthnInstance(c)
if err != nil {
common.ErrorResp(c, err, 400)
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BeginAuthnRegistration calls common.ErrorResp when authn.NewAuthnInstance fails but does not return, so execution continues with a nil/invalid authnInstance and can panic. Add an early return after sending the error response.

Copilot uses AI. Check for mistakes.
var credentials []webauthn.Credential
err = json.Unmarshal([]byte(u.Authn), &credentials)
if err != nil {
fmt.Println(err)
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Avoid fmt.Println(err) inside WebAuthnCredentialRecords; it writes to stdout and can leak/produce noisy logs in production. Use the project's logger (e.g., utils.Log) at an appropriate level, or simply return an empty slice when parsing fails (possibly after metrics/logging at a higher layer).

Suggested change
fmt.Println(err)

Copilot uses AI. Check for mistakes.

// UpdateAuthnForOldVersion updates users' authn
// First published: bdfc159 fix: webauthn logspam (#6181) by itsHenry
// First published: bdfc159 fix: passkey logspam (#6181) by itsHenry
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This comment references a specific historical commit hash (bdfc159). Renaming the original commit message from "webauthn" to "passkey" can make it harder to trace the exact upstream change. Consider keeping the original message or mentioning both terms (e.g., "webauthn/passkey") for accuracy.

Suggested change
// First published: bdfc159 fix: passkey logspam (#6181) by itsHenry
// First published: bdfc159 fix: webauthn/passkey logspam (#6181) by itsHenry

Copilot uses AI. Check for mistakes.
Comment on lines +52 to +56
} else { // client-side discoverable login
requireUsername, err = db.HasLegacyAuthnCredentials()
if err == nil && !requireUsername {
options, sessionData, err = authnInstance.BeginDiscoverableLogin()
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

BeginAuthnLogin calls db.HasLegacyAuthnCredentials() on the unauthenticated login path when username is empty. That function loads and parses authn data for all users, which is expensive and can become a DoS vector under login traffic. Consider replacing this with a cached flag/TTL cache, a cheaper query, or moving the legacy check to a user-scoped flow rather than a full-table scan per request.

Copilot uses AI. Check for mistakes.
Comment on lines +80 to +86
func LegacyAuthnStatus(c *gin.Context) {
hasLegacy, err := db.HasLegacyAuthnCredentials()
if err != nil {
common.ErrorResp(c, err, 400)
return
}
common.SuccessResp(c, gin.H{"has_legacy": hasLegacy})
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LegacyAuthnStatus is exposed without auth and triggers db.HasLegacyAuthnCredentials(), which currently scans all users and parses authn JSON. This can be very expensive if polled by the UI; consider caching the result, rate-limiting, or restricting the endpoint to authenticated/admin users if possible.

Copilot uses AI. Check for mistakes.
Comment on lines +110 to +119
var users []model.User
err := db.Select("id", "authn").Where("authn <> '' AND authn <> '[]'").Find(&users).Error
if err != nil {
return false, err
}
for i := range users {
if users[i].HasLegacyWebAuthnCredential() {
return true, nil
}
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

HasLegacyAuthnCredentials loads every user with a non-empty authn field into memory and JSON-parses each record. This is O(N) over the user table and will be called during passkey login/status, so it can become a serious performance bottleneck. Consider a cached result, incremental tracking (e.g., maintain a counter/flag on update), or iterating rows/limiting to the first legacy match instead of loading all users.

Suggested change
var users []model.User
err := db.Select("id", "authn").Where("authn <> '' AND authn <> '[]'").Find(&users).Error
if err != nil {
return false, err
}
for i := range users {
if users[i].HasLegacyWebAuthnCredential() {
return true, nil
}
}
rows, err := db.Model(&model.User{}).
Select("id", "authn").
Where("authn <> '' AND authn <> '[]'").
Rows()
if err != nil {
return false, err
}
defer rows.Close()
for rows.Next() {
var u model.User
if err := db.ScanRows(rows, &u); err != nil {
return false, err
}
if u.HasLegacyWebAuthnCredential() {
return true, nil
}
}
if err := rows.Err(); err != nil {
return false, err
}

Copilot uses AI. Check for mistakes.
Comment on lines +269 to +285
func (u *User) WebAuthnCredentialRecords() []WebAuthnCredentialRecord {
var records []WebAuthnCredentialRecord
err := json.Unmarshal([]byte(u.Authn), &records)
if err == nil {
recordParsed := false
for i := range records {
if len(records[i].Credential.ID) > 0 {
recordParsed = true
if records[i].ResidentKey == "" {
records[i].ResidentKey = string(protocol.ResidentKeyRequirementDiscouraged)
}
}
}
if recordParsed || u.Authn == "[]" || u.Authn == "" {
return records
}
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

WebAuthnCredentialRecords attempts to json.Unmarshal(u.Authn) even when Authn is an empty string; this falls through to the legacy unmarshal and returns nil after printing an error. Treat empty Authn the same as "[]" (return an empty slice) so callers don't have to handle nil and to avoid noisy parse errors during upgrades.

Copilot uses AI. Check for mistakes.
Comment on lines 256 to +269
credentials := user.WebAuthnCredentials()
res := make([]WebAuthnCredentials, 0, len(credentials))
records := user.WebAuthnCredentialRecords()
res := make([]PasskeyCredentials, 0, len(credentials))
for _, v := range credentials {
credential := WebAuthnCredentials{
var creatorIP string
var creatorUA string
var isLegacy bool
for i := range records {
if string(records[i].Credential.ID) == string(v.ID) {
creatorIP = records[i].CreatorIP
creatorUA = records[i].CreatorUA
isLegacy = records[i].ResidentKey == string(protocol.ResidentKeyRequirementDiscouraged)
break
}
Copy link

Copilot AI Feb 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

GetAuthnCredentials builds passkey metadata by doing an inner loop over records for every credential and compares IDs via string conversions. This is O(n^2) and allocates; consider building a map from credential ID to record (and use bytes.Equal/base64 keying) to simplify and speed up the lookup.

Copilot uses AI. Check for mistakes.
@HappyDIY
Copy link
Author

@copilot open a new pull request to apply changes based on the comments in this thread

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants