diff --git a/backend/internal/handler/passkey_handler.go b/backend/internal/handler/passkey_handler.go index c5521ebdd1..8ddb16e4b3 100644 --- a/backend/internal/handler/passkey_handler.go +++ b/backend/internal/handler/passkey_handler.go @@ -49,6 +49,20 @@ type passkeyRenameRequest struct { Name string `json:"name" binding:"required"` } +// passkeyPasswordRequest carries the account password that gates passkey +// enrollment and revocation. Binding errors are tolerated: a missing or +// malformed body yields an empty password, which the service rejects with a +// precise PASSWORD_REQUIRED error. +type passkeyPasswordRequest struct { + Password string `json:"password"` +} + +func bindPasskeyPassword(c *gin.Context) string { + var req passkeyPasswordRequest + _ = c.ShouldBindJSON(&req) + return req.Password +} + const passkeyFinishBodyMaxBytes = 64 * 1024 // BeginLogin starts a usernameless, discoverable-credential login ceremony. @@ -101,7 +115,7 @@ func (h *PasskeyHandler) BeginRegistration(c *gin.Context) { response.Unauthorized(c, "User not authenticated") return } - creation, token, err := h.passkeys.BeginRegistration(c.Request.Context(), subject.UserID) + creation, token, err := h.passkeys.BeginRegistration(c.Request.Context(), subject.UserID, bindPasskeyPassword(c)) if err != nil { response.ErrorFrom(c, err) return @@ -173,7 +187,7 @@ func (h *PasskeyHandler) Delete(c *gin.Context) { if !ok { return } - if err := h.passkeys.Delete(c.Request.Context(), subject.UserID, credentialID); err != nil { + if err := h.passkeys.Delete(c.Request.Context(), subject.UserID, credentialID, bindPasskeyPassword(c)); err != nil { response.ErrorFrom(c, err) return } diff --git a/backend/internal/service/passkey.go b/backend/internal/service/passkey.go index 7458d2f7b1..a8776a831a 100644 --- a/backend/internal/service/passkey.go +++ b/backend/internal/service/passkey.go @@ -151,9 +151,24 @@ func (s *PasskeyService) requireEnabled() error { return nil } +// verifyPasskeyPassword gates credential enrollment and revocation with the +// account password so a hijacked session cannot silently add or remove +// passkeys. The password is used instead of TOTP step-up so the guard also +// works on deployments without a TOTP encryption key configured. +func verifyPasskeyPassword(user *User, password string) error { + if password == "" { + return ErrPasswordRequired + } + if user == nil || !user.CheckPassword(password) { + return ErrPasswordIncorrect + } + return nil +} + func (s *PasskeyService) BeginRegistration( ctx context.Context, userID int64, + password string, ) (creation *protocol.CredentialCreation, sessionToken string, err error) { if err = s.requireEnabled(); err != nil { return nil, "", err @@ -165,6 +180,9 @@ func (s *PasskeyService) BeginRegistration( if !user.IsActive() { return nil, "", ErrUserNotActive } + if err = verifyPasskeyPassword(user, password); err != nil { + return nil, "", err + } candidate := make([]byte, 32) if _, err = rand.Read(candidate); err != nil { @@ -330,10 +348,17 @@ func (s *PasskeyService) Rename(ctx context.Context, userID, credentialID int64, return s.repo.Rename(ctx, userID, credentialID, normalizePasskeyName(name)) } -func (s *PasskeyService) Delete(ctx context.Context, userID, credentialID int64) error { +func (s *PasskeyService) Delete(ctx context.Context, userID, credentialID int64, password string) error { if err := s.requireEnabled(); err != nil { return err } + user, err := s.userRepo.GetByID(ctx, userID) + if err != nil { + return err + } + if err = verifyPasskeyPassword(user, password); err != nil { + return err + } return s.repo.Delete(ctx, userID, credentialID) } diff --git a/backend/internal/service/passkey_test.go b/backend/internal/service/passkey_test.go index c5f8ff3cf0..53f4093228 100644 --- a/backend/internal/service/passkey_test.go +++ b/backend/internal/service/passkey_test.go @@ -1,9 +1,12 @@ package service import ( + "context" "strings" "testing" + "time" + "github.com/Wei-Shaw/sub2api/internal/config" "github.com/go-webauthn/webauthn/webauthn" "github.com/stretchr/testify/require" ) @@ -27,3 +30,83 @@ func TestPasskeySummaryReportsCurrentBackupState(t *testing.T) { record.Credential.Flags.BackupState = true require.True(t, passkeySummary(record).Backup) } + +// 桩仅实现测试所需方法;未桩方法调用即 panic(嵌入 nil 接口)。 +type passkeyPwUserRepoStub struct { + UserRepository + user *User +} + +func (s *passkeyPwUserRepoStub) GetByID(context.Context, int64) (*User, error) { + return s.user, nil +} + +type passkeyPwRepoStub struct { + PasskeyRepository + handleCalled bool + deleteCalled bool +} + +func (s *passkeyPwRepoStub) EnsureUserHandle(_ context.Context, _ int64, candidate []byte) ([]byte, error) { + s.handleCalled = true + return candidate, nil +} + +func (s *passkeyPwRepoStub) ListByUserID(context.Context, int64) ([]PasskeyCredentialRecord, error) { + return nil, nil +} + +func (s *passkeyPwRepoStub) Delete(context.Context, int64, int64) error { + s.deleteCalled = true + return nil +} + +type passkeyPwSessionStoreStub struct { + PasskeySessionStore +} + +func (s *passkeyPwSessionStoreStub) Store(context.Context, *PasskeySession, time.Duration) (string, error) { + return "session-token", nil +} + +func newPasskeyPwService(t *testing.T, user *User) (*PasskeyService, *passkeyPwRepoStub) { + t.Helper() + repo := &passkeyPwRepoStub{} + svc, err := NewPasskeyService(&config.Config{WebAuthn: config.WebAuthnConfig{ + Enabled: true, + RPDisplayName: "Sub2API", + RPID: "sub2api.example.com", + RPOrigins: []string{"https://sub2api.example.com"}, + }}, repo, &passkeyPwSessionStoreStub{}, &passkeyPwUserRepoStub{user: user}) + require.NoError(t, err) + return svc, repo +} + +// 注册与吊销必须验证账号密码:被窃会话不得静默添加/移除凭据。 +// 用密码而非 TOTP step-up,保证未配置 TOTP 加密密钥的部署同样受保护。 +func TestPasskeyEnrollmentAndRevocationRequireAccountPassword(t *testing.T) { + user := &User{ID: 7, Email: "user@example.com", Status: StatusActive} + require.NoError(t, user.SetPassword("correct-password")) + svc, repo := newPasskeyPwService(t, user) + + _, _, err := svc.BeginRegistration(context.Background(), user.ID, "") + require.ErrorIs(t, err, ErrPasswordRequired) + _, _, err = svc.BeginRegistration(context.Background(), user.ID, "wrong-password") + require.ErrorIs(t, err, ErrPasswordIncorrect) + require.False(t, repo.handleCalled) + + creation, token, err := svc.BeginRegistration(context.Background(), user.ID, "correct-password") + require.NoError(t, err) + require.NotNil(t, creation) + require.Equal(t, "session-token", token) + require.True(t, repo.handleCalled) + + err = svc.Delete(context.Background(), user.ID, 1, "") + require.ErrorIs(t, err, ErrPasswordRequired) + err = svc.Delete(context.Background(), user.ID, 1, "wrong-password") + require.ErrorIs(t, err, ErrPasswordIncorrect) + require.False(t, repo.deleteCalled) + + require.NoError(t, svc.Delete(context.Background(), user.ID, 1, "correct-password")) + require.True(t, repo.deleteCalled) +} diff --git a/frontend/src/api/__tests__/passkey.spec.ts b/frontend/src/api/__tests__/passkey.spec.ts index 40e058aa35..fe32c1c7cd 100644 --- a/frontend/src/api/__tests__/passkey.spec.ts +++ b/frontend/src/api/__tests__/passkey.spec.ts @@ -1,11 +1,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -const { get, post, patch, remove, credentialGet } = vi.hoisted(() => ({ +const { get, post, patch, remove, credentialGet, credentialCreate } = vi.hoisted(() => ({ get: vi.fn(), post: vi.fn(), patch: vi.fn(), remove: vi.fn(), - credentialGet: vi.fn() + credentialGet: vi.fn(), + credentialCreate: vi.fn() })) vi.mock('@/api/client', () => ({ @@ -24,7 +25,7 @@ class FakePublicKeyCredential { rawId = Uint8Array.from([1, 2, 3]).buffer type = 'public-key' authenticatorAttachment = 'platform' - response = { + response: Record = { authenticatorData: Uint8Array.from([4, 5]).buffer, clientDataJSON: Uint8Array.from([6, 7]).buffer, signature: Uint8Array.from([8, 9]).buffer, @@ -36,6 +37,17 @@ class FakePublicKeyCredential { } } +class FakeRegistrationCredential extends FakePublicKeyCredential { + constructor() { + super() + this.response = { + attestationObject: Uint8Array.from([12, 13]).buffer, + clientDataJSON: Uint8Array.from([6, 7]).buffer, + getTransports: () => ['internal'] + } + } +} + describe('passkey api', () => { beforeEach(() => { get.mockReset() @@ -43,6 +55,7 @@ describe('passkey api', () => { patch.mockReset() remove.mockReset() credentialGet.mockReset() + credentialCreate.mockReset() vi.stubGlobal('PublicKeyCredential', FakePublicKeyCredential) Object.defineProperty(window, 'PublicKeyCredential', { @@ -51,7 +64,7 @@ describe('passkey api', () => { }) Object.defineProperty(navigator, 'credentials', { configurable: true, - value: { get: credentialGet } + value: { get: credentialGet, create: credentialCreate } }) }) @@ -105,4 +118,50 @@ describe('passkey api', () => { } }) }) + + it('sends the account password when beginning registration', async () => { + post + .mockResolvedValueOnce({ + data: { + session_token: 'register-session', + options: { + publicKey: { + challenge: 'AQID', + user: { id: 'BAU', name: 'user@example.com', displayName: 'user' } + } + } + } + }) + .mockResolvedValueOnce({ + data: { id: 3, name: 'Laptop', created_at: '2026-07-28T00:00:00Z', backup: false } + }) + credentialCreate.mockResolvedValue(new FakeRegistrationCredential()) + + await passkeyAPI.register('Laptop', 'hunter2') + + expect(post).toHaveBeenNthCalledWith(1, '/user/passkeys/register/begin', { + password: 'hunter2' + }) + expect(post).toHaveBeenNthCalledWith(2, '/user/passkeys/register/finish', { + session_token: 'register-session', + name: 'Laptop', + credential: expect.objectContaining({ + response: { + attestationObject: 'DA0', + clientDataJSON: 'Bgc', + transports: ['internal'] + } + }) + }) + }) + + it('sends the account password when revoking a credential', async () => { + remove.mockResolvedValue({ data: null }) + + await passkeyAPI.remove(12, 'hunter2') + + expect(remove).toHaveBeenCalledWith('/user/passkeys/12', { + data: { password: 'hunter2' } + }) + }) }) diff --git a/frontend/src/api/passkey.ts b/frontend/src/api/passkey.ts index c082f3d98a..fdfe128a21 100644 --- a/frontend/src/api/passkey.ts +++ b/frontend/src/api/passkey.ts @@ -122,10 +122,11 @@ async function login(): Promise { return data } -async function register(name: string): Promise { +async function register(name: string, password: string): Promise { requirePasskeySupport() const { data: begin } = await apiClient.post( - '/user/passkeys/register/begin' + '/user/passkeys/register/begin', + { password } ) const credential = await navigator.credentials.create({ publicKey: creationOptionsFromJSON(begin.options.publicKey) @@ -153,8 +154,8 @@ async function rename(id: number, name: string): Promise { await apiClient.patch(`/user/passkeys/${id}`, { name }) } -async function remove(id: number): Promise { - await apiClient.delete(`/user/passkeys/${id}`) +async function remove(id: number, password: string): Promise { + await apiClient.delete(`/user/passkeys/${id}`, { data: { password } }) } export const passkeyAPI = { diff --git a/frontend/src/components/user/profile/ProfilePasskeyCard.vue b/frontend/src/components/user/profile/ProfilePasskeyCard.vue index 8bdcc1c6a9..0b60113d89 100644 --- a/frontend/src/components/user/profile/ProfilePasskeyCard.vue +++ b/frontend/src/components/user/profile/ProfilePasskeyCard.vue @@ -30,25 +30,40 @@
-
- - +
+
+ + +
+
+ + +
-
+
-
@@ -113,6 +128,51 @@
+ + +
+
+
+
+

+ {{ t('profile.passkey.deleteTitle') }} +

+

+ {{ t('profile.passkey.deleteConfirm', { name: deleteTarget.name }) }} +

+ +
+ + +
+
+ + +
+ +
+
+
@@ -132,8 +192,18 @@ const loading = ref(false) const busy = ref(false) const showAddForm = ref(false) const newName = ref('') +const newPassword = ref('') +const deleteTarget = ref(null) +const deletePassword = ref('') const credentials = ref([]) +// apiClient 拦截器把错误规范化为 { code, reason, message }; +// 透出后端消息(如密码错误),否则回退到通用文案。 +function extractErrorMessage(error: unknown, fallback: string): string { + const message = (error as { message?: string }).message + return typeof message === 'string' && message.length > 0 ? message : fallback +} + async function loadCredentials(): Promise { loading.value = true try { @@ -149,15 +219,16 @@ async function loadCredentials(): Promise { } async function addPasskey(): Promise { + if (newPassword.value.length === 0) return busy.value = true try { - await passkeyAPI.register(newName.value.trim()) + await passkeyAPI.register(newName.value.trim(), newPassword.value) appStore.showSuccess(t('profile.passkey.added')) cancelAdd() await loadCredentials() } catch (error) { if (!(error instanceof DOMException && error.name === 'NotAllowedError')) { - appStore.showError(t('profile.passkey.addFailed')) + appStore.showError(extractErrorMessage(error, t('profile.passkey.addFailed'))) } } finally { busy.value = false @@ -167,6 +238,7 @@ async function addPasskey(): Promise { function cancelAdd(): void { showAddForm.value = false newName.value = '' + newPassword.value = '' } async function renamePasskey(credential: PasskeyCredentialSummary): Promise { @@ -184,15 +256,28 @@ async function renamePasskey(credential: PasskeyCredentialSummary): Promise { - if (!window.confirm(t('profile.passkey.deleteConfirm', { name: credential.name }))) return +function deletePasskey(credential: PasskeyCredentialSummary): void { + deleteTarget.value = credential + deletePassword.value = '' +} + +function closeDeleteDialog(): void { + deleteTarget.value = null + deletePassword.value = '' +} + +async function confirmDelete(): Promise { + const credential = deleteTarget.value + if (!credential || deletePassword.value.length === 0) return busy.value = true try { - await passkeyAPI.remove(credential.id) + await passkeyAPI.remove(credential.id, deletePassword.value) credentials.value = credentials.value.filter((item) => item.id !== credential.id) appStore.showSuccess(t('profile.passkey.deleted')) - } catch { - appStore.showError(t('profile.passkey.deleteFailed')) + closeDeleteDialog() + } catch (error) { + // 密码错误等失败保持对话框打开,允许重试 + appStore.showError(extractErrorMessage(error, t('profile.passkey.deleteFailed'))) } finally { busy.value = false } diff --git a/frontend/src/i18n/locales/en/dashboard.ts b/frontend/src/i18n/locales/en/dashboard.ts index 1812da6125..e462eff4f1 100644 --- a/frontend/src/i18n/locales/en/dashboard.ts +++ b/frontend/src/i18n/locales/en/dashboard.ts @@ -697,6 +697,7 @@ export default { continue: 'Create passkey', name: 'Passkey name', namePlaceholder: 'For example, MacBook Touch ID', + passwordPlaceholder: 'Enter your current password to confirm', empty: 'No passkeys are registered yet.', synced: 'Synced', createdAt: 'Created {date}', @@ -709,6 +710,7 @@ export default { renamePrompt: 'Enter a new name for this passkey', renamed: 'Passkey renamed.', renameFailed: 'Failed to rename passkey.', + deleteTitle: 'Delete passkey', deleteConfirm: 'Delete “{name}”? You will no longer be able to sign in with it.', deleted: 'Passkey deleted.', deleteFailed: 'Failed to delete passkey.' diff --git a/frontend/src/i18n/locales/zh/dashboard.ts b/frontend/src/i18n/locales/zh/dashboard.ts index a96e2a0c5b..60448420ea 100644 --- a/frontend/src/i18n/locales/zh/dashboard.ts +++ b/frontend/src/i18n/locales/zh/dashboard.ts @@ -702,6 +702,7 @@ export default { continue: '创建 Passkey', name: 'Passkey 名称', namePlaceholder: '例如:MacBook 触控 ID', + passwordPlaceholder: '输入当前登录密码以确认', empty: '尚未添加任何 Passkey。', synced: '已同步', createdAt: '创建于 {date}', @@ -714,6 +715,7 @@ export default { renamePrompt: '请输入新的 Passkey 名称', renamed: 'Passkey 已重命名。', renameFailed: '重命名 Passkey 失败。', + deleteTitle: '删除 Passkey', deleteConfirm: '删除“{name}”?删除后将无法再使用它登录。', deleted: 'Passkey 已删除。', deleteFailed: '删除 Passkey 失败。'