Skip to content

feat(passkey): support multiple passkeys per user#4922

Closed
v1xingyue wants to merge 1 commit into
QuantumNous:mainfrom
v1xingyue:feat/multi-passkey-support
Closed

feat(passkey): support multiple passkeys per user#4922
v1xingyue wants to merge 1 commit into
QuantumNous:mainfrom
v1xingyue:feat/multi-passkey-support

Conversation

@v1xingyue
Copy link
Copy Markdown

@v1xingyue v1xingyue commented May 17, 2026

Description

Previously, each user could only register one Passkey credential. The database had a unique index on user_id, and the UI only showed a single passkey status. This PR removes that limitation and allows users to register multiple Passkeys (e.g., one on their phone, one on their laptop, one hardware key).

Backend Changes

  • Database (model/passkey.go):

    • Removed uniqueIndex on PasskeyCredential.UserID, replaced with plain index
    • GetPasskeyByUserID now returns []*PasskeyCredential
    • UpsertPasskeyCredential now inserts new credentials instead of replacing the old one (updates by credential_id if already exists)
    • Added DeletePasskeyByCredentialID for per-credential removal
    • Kept DeletePasskeyByUserID for admin reset
  • Controller (controller/passkey.go):

    • RegisterBegin: exclusions now include all existing credentials
    • RegisterFinish: inserts new credential without deleting old ones
    • Delete: changed to DELETE /api/user/passkey/:credential_id for targeted removal
    • Status: returns a list of credentials with metadata (created_at, last_used_at, backup info, attachment type)
    • VerifyBegin / VerifyFinish: supports multiple allowCredentials
    • LoginFinish: finds and updates the matched credential's LastUsedAt
  • Migration (model/main.go):

    • Added migratePasskeyUserIDUniqueIndex() to safely drop the old unique index on user_id across SQLite / MySQL / PostgreSQL

Frontend Changes

  • Default Theme:

    • PasskeyCard now shows a list of registered credentials
    • Each item displays device type, last used time, creation time, and backup status
    • Added "Add Passkey" button to register additional credentials
    • Delete action now targets a specific credential_id
  • Classic Theme:

    • AccountManagement shows credential list with per-item delete
    • PersonalSetting updated to call new DELETE /api/user/passkey/:credential_id API

API Breaking Change

Before After
DELETE /api/user/passkey DELETE /api/user/passkey/:credential_id

Checklist

  • Backend compiles successfully
  • Supports SQLite, MySQL, PostgreSQL
  • Both Default and Classic themes updated
  • Database migration included
  • Admin reset passkey still works (deletes all user credentials)

Related

Closes the limitation discussed in #3490 (Passkey 只能绑定一个设备).

Summary by CodeRabbit

  • New Features
    • Added support for multiple passkeys per user account.
    • Enhanced passkey management interface displaying all registered credentials with device type, creation date, and last-used timestamp.
    • Individual passkey deletion: users can now remove specific passkeys instead of deleting all at once.
    • Improved passkey verification handling for accounts with multiple credentials.

Review Change Stack

Previously, each user could only register one Passkey credential.
The database had a unique index on user_id, and the UI only showed
a single passkey status. This change removes that limitation.

Backend changes:
- Remove uniqueIndex on PasskeyCredential.UserID, add plain index
- UpsertPasskeyCredential now inserts new credentials instead of
  replacing the old one (update by credential_id if exists)
- Add DeletePasskeyByCredentialID for per-credential removal
- GetPasskeyByUserID now returns []*PasskeyCredential
- Controller endpoints updated to handle credential lists:
  * Register: exclusions include all existing credentials
  * Delete: requires credential_id param
  * Status: returns list of credentials with metadata
  * Verify: supports multiple allowCredentials
  * Login/Verify: find and update the matched credential
- Add DB migration to drop old unique index on user_id

Frontend (default theme):
- PasskeyCard shows a list of registered credentials
- Each credential displays device type, last used, created at
- Add 'Add Passkey' button to register additional credentials
- Delete action now targets a specific credential_id

Frontend (classic theme):
- AccountManagement shows credential list
- PersonalSetting delete API updated to use credential_id path

Breaking change:
- DELETE /api/user/passkey -> DELETE /api/user/passkey/:credential_id
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

Walkthrough

This PR enables multiple passkeys per user by refactoring database constraints, backend credential handling, and frontend state management. The schema removes the unique constraint on user ID, model methods now return and operate on credential collections, and all passkey endpoints adapt to load full credential lists. Frontend components updated to display and delete individual credentials by credential ID instead of operating on a single passkey.

Changes

Multi-Passkey Support

Layer / File(s) Summary
Database Schema and Model Layer
model/passkey.go, model/main.go
Schema removes unique constraint on PasskeyCredential.UserID enabling multiple credentials per user. GetPasskeyByUserID returns credential list; UpsertPasskeyCredential implements update-or-create by credential_id preserving creation time; new DeletePasskeyByCredentialID deletes by credential ID. Migration helper drops legacy unique index.
WebAuthn Service Layer
service/passkey/user.go
WebAuthnUser refactored to store credentials slice instead of single credential. WebAuthnCredentials() builds from slice; new PasskeyCredentials() exposes full list; backward-compatible PasskeyCredential() returns first credential.
Backend Registration and Status
controller/passkey.go (register, status), router/api-router.go
PasskeyRegisterBegin/Finish load all credentials and construct WebAuthn exclusion from full slice. PasskeyStatus returns multi-credential response: empty credentials array when none exist, full credentials array with metadata (id, timestamps, backup/attachment info) when passkeys present. PasskeyDelete now validates and deletes by credential_id from parameterized route.
Backend Verification and Login
controller/passkey.go (verify, admin reset, gating)
PasskeyVerifyBegin loads all credentials; PasskeyVerifyFinish base64-encodes returned credential ID to match and update only that credential's LastUsedAt from loaded slice. AdminResetPasskey and requirePasskeyDeleteVerification check for empty credential list instead of error-based detection. UniversalVerify checks for non-empty credentials list.
Frontend Type Contract
web/default/src/features/auth/passkey/types.ts
Introduces PasskeyCredential interface: credential_id, created_at, optional last_used_at, optional backup fields, optional attachment. PasskeyStatus updated to enabled flag plus credentials array.
Frontend API and Hooks
web/default/src/features/auth/passkey/api.ts, web/default/src/features/auth/passkey/hooks/use-passkey-management.ts
deletePasskey(credentialId) constructs credential-specific DELETE endpoint. Hook tracks removing as credential ID (or null); exposes credentials list; tightens enabled to require both status.enabled and non-empty credentials; remove(credentialId) deletes specific credential.
Frontend Classic Components
web/classic/src/components/settings/PersonalSetting.jsx, web/classic/src/components/settings/personal/cards/AccountManagement.jsx
passkeyStatus state extended with credentials array. removePasskey refactored to accept credentialId and perform credential-specific deletion via credential-scoped API call. AccountManagement derives credentials from status, replaces single passkey action with per-credential list UI showing device type, timestamps, and individual "unbind" buttons via confirmation modal.
Frontend Default PasskeyCard Component
web/default/src/features/profile/components/passkey-card.tsx
Tracks confirmCredentialId for credential-specific removal dialog. handleRemove enforces verification method selection and calls remove(credentialId). UI refactored: disabled state shows simplified "no passkeys" message; enabled state displays credential count and maps credentials through new PasskeyCredentialItem helper component rendering device type, formatted timestamps, backup status, and per-item delete button. Shared confirmation dialog driven by credential ID.

Sequence Diagram(s)

sequenceDiagram
  participant Frontend as Frontend UI
  participant Hook as usePasskeyManagement Hook
  participant API as Passkey API
  participant Backend as Backend Controller
  participant DB as Database (Model)

  Frontend->>Hook: remove(credentialId)
  Hook->>API: deletePasskey(credentialId)
  API->>Backend: DELETE /api/user/passkey/:credentialId
  Backend->>DB: DeletePasskeyByCredentialID(credentialId, userId)
  DB-->>Backend: success
  Backend-->>API: response
  API-->>Hook: resolved
  Hook->>Hook: refresh status
  Hook-->>Frontend: updated credentials list
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • QuantumNous/new-api#1912: Overlapping modifications to passkey and universal verification controller logic and flow.
  • QuantumNous/new-api#3257: Concurrent changes to UniversalVerify passkey availability detection and verification rewiring.
  • QuantumNous/new-api#4393: Intersecting changes to passkey verification gating and secure verification enforcement paths.

Suggested reviewers

  • Calcium-Ion

Poem

🐰 Hoppy hops through passkeys now so free,
Multiple credentials dance with glee,
The bunny refactored schemas with care,
From single to many—a beautiful pair,
Frontend and backend both taking their share! 🔐

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 4.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat(passkey): support multiple passkeys per user' directly and clearly summarizes the main change—enabling multiple passkey credentials per user instead of one.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
model/passkey.go (1)

5-5: 🛠️ Refactor suggestion | 🟠 Major | ⚡ Quick win

Use common.Marshal/common.Unmarshal instead of direct encoding/json calls.

Lines 49 and 68 use json.Unmarshal and json.Marshal directly. As per coding guidelines, all JSON operations must use wrapper functions from common/json.go.

♻️ Proposed fix
 import (
 	"encoding/base64"
-	"encoding/json"
 	"errors"
 	"fmt"

In TransportList():

-	if err := json.Unmarshal([]byte(p.Transports), &transports); err != nil {
+	if err := common.Unmarshal([]byte(p.Transports), &transports); err != nil {

In SetTransports():

-	encoded, err := json.Marshal(stringList)
+	encoded, err := common.Marshal(stringList)

As per coding guidelines: "All JSON marshal/unmarshal operations MUST use wrapper functions from common/json.go."

Also applies to: 49-49, 68-68

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@model/passkey.go` at line 5, Replace direct encoding/json calls in
TransportList() and SetTransports(): where the code currently calls
json.Unmarshal and json.Marshal, use the wrapper helpers common.Unmarshal and
common.Marshal from common/json.go instead. Update the import to remove
"encoding/json" and ensure the functions handle the returned error values the
same way (propagate or log) so TransportList() and SetTransports() maintain
existing error handling semantics.
🧹 Nitpick comments (1)
web/default/src/features/profile/components/passkey-card.tsx (1)

362-379: 💤 Low value

Props are destructured in the function signature.

The PasskeyCredentialItem component destructures its props directly in the function signature. Additionally, the type t: (key: string) => string is overly narrow and doesn't match the actual react-i18next t function signature which supports interpolation parameters.

As per coding guidelines: "Do not destructure component props; use props.xxx directly instead for clarity".

Suggested refactor
-function PasskeyCredentialItem({
-  credential,
-  removing,
-  onRemove,
-  t,
-}: {
-  credential: {
+import type { TFunction } from 'i18next'
+
+interface PasskeyCredentialItemProps {
+  credential: {
     credential_id: string
     created_at: string
     last_used_at?: string | null
     backup_eligible?: boolean
     backup_state?: boolean
     attachment?: string
   }
   removing: boolean
   onRemove: () => void
-  t: (key: string) => string
-}) {
+  t: TFunction
+}
+
+function PasskeyCredentialItem(props: PasskeyCredentialItemProps) {
   const formattedLastUsed =
-    credential.last_used_at && !Number.isNaN(Date.parse(credential.last_used_at))
-      ? dayjs(credential.last_used_at).fromNow()
-      : t('Not used yet')
+    props.credential.last_used_at && !Number.isNaN(Date.parse(props.credential.last_used_at))
+      ? dayjs(props.credential.last_used_at).fromNow()
+      : props.t('Not used yet')
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@web/default/src/features/profile/components/passkey-card.tsx` around lines
362 - 379, PasskeyCredentialItem currently destructures props in the function
signature and types the translator as t: (key: string) => string; change the
component to accept a single props object (e.g. function
PasskeyCredentialItem(props: PasskeyCredentialItemProps) { ... }) and reference
props.credential, props.removing, props.onRemove, props.t inside the body;
define an interface PasskeyCredentialItemProps that describes credential,
removing, onRemove and a wider translator type such as t: (key: string, vars?:
Record<string, any>) => string (or the appropriate i18next/TFunction type) to
allow interpolation. Ensure all internal usages switch from destructured names
to props.xxx and update any callers if necessary.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@model/passkey.go`:
- Line 5: Replace direct encoding/json calls in TransportList() and
SetTransports(): where the code currently calls json.Unmarshal and json.Marshal,
use the wrapper helpers common.Unmarshal and common.Marshal from common/json.go
instead. Update the import to remove "encoding/json" and ensure the functions
handle the returned error values the same way (propagate or log) so
TransportList() and SetTransports() maintain existing error handling semantics.

---

Nitpick comments:
In `@web/default/src/features/profile/components/passkey-card.tsx`:
- Around line 362-379: PasskeyCredentialItem currently destructures props in the
function signature and types the translator as t: (key: string) => string;
change the component to accept a single props object (e.g. function
PasskeyCredentialItem(props: PasskeyCredentialItemProps) { ... }) and reference
props.credential, props.removing, props.onRemove, props.t inside the body;
define an interface PasskeyCredentialItemProps that describes credential,
removing, onRemove and a wider translator type such as t: (key: string, vars?:
Record<string, any>) => string (or the appropriate i18next/TFunction type) to
allow interpolation. Ensure all internal usages switch from destructured names
to props.xxx and update any callers if necessary.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0fdaaa6a-7b2d-4a2b-a470-c5a1f0537e1f

📥 Commits

Reviewing files that changed from the base of the PR and between f69ceb6 and 2499e4f.

📒 Files selected for processing (12)
  • controller/passkey.go
  • controller/secure_verification.go
  • model/main.go
  • model/passkey.go
  • router/api-router.go
  • service/passkey/user.go
  • web/classic/src/components/settings/PersonalSetting.jsx
  • web/classic/src/components/settings/personal/cards/AccountManagement.jsx
  • web/default/src/features/auth/passkey/api.ts
  • web/default/src/features/auth/passkey/hooks/use-passkey-management.ts
  • web/default/src/features/auth/passkey/types.ts
  • web/default/src/features/profile/components/passkey-card.tsx

@seefs001
Copy link
Copy Markdown
Collaborator

暂不考虑允许多passkey

@seefs001 seefs001 closed this May 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants