feat: require account password for passkey enrollment and revocation

A hijacked session must not be able to silently add a passkey as a
persistent backdoor or remove the victim's credentials. Registration
(begin) and deletion now verify the account password server-side,
reusing the existing PASSWORD_REQUIRED / PASSWORD_INCORRECT errors.

The password is used instead of TOTP step-up so the guard also protects
deployments that never configured a TOTP encryption key. The password
key in both request bodies is covered by the audit middleware's
key-substring redaction, so no credential material reaches audit_logs.

Frontend: the add-passkey form gains a current-password field, and the
delete confirmation is now a dialog with a password input (replacing
window.confirm), mirroring the TOTP disable dialog. Backend error
messages (e.g. wrong password) are surfaced instead of the generic
failure toast. Rename remains password-free as it is cosmetic.
This commit is contained in:
shaw
2026-07-28 14:12:46 +08:00
parent 97f44b21bb
commit 38ef8dc069
8 changed files with 302 additions and 31 deletions
+16 -2
View File
@@ -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
}
+26 -1
View File
@@ -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)
}
+83
View File
@@ -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)
}
+63 -4
View File
@@ -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<string, unknown> = {
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' }
})
})
})
+5 -4
View File
@@ -122,10 +122,11 @@ async function login(): Promise<AuthResponse> {
return data
}
async function register(name: string): Promise<PasskeyCredentialSummary> {
async function register(name: string, password: string): Promise<PasskeyCredentialSummary> {
requirePasskeySupport()
const { data: begin } = await apiClient.post<CeremonyOptionsResponse>(
'/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<void> {
await apiClient.patch(`/user/passkeys/${id}`, { name })
}
async function remove(id: number): Promise<void> {
await apiClient.delete(`/user/passkeys/${id}`)
async function remove(id: number, password: string): Promise<void> {
await apiClient.delete(`/user/passkeys/${id}`, { data: { password } })
}
export const passkeyAPI = {
@@ -30,25 +30,40 @@
<div>
<form
v-if="enabled && supported && showAddForm"
class="mb-5 flex flex-col gap-3 rounded-lg border border-gray-200 p-4 dark:border-dark-700 sm:flex-row sm:items-end"
class="mb-5 flex flex-col gap-3 rounded-lg border border-gray-200 p-4 dark:border-dark-700"
@submit.prevent="addPasskey"
>
<div class="flex-1">
<label for="passkey-name" class="input-label">{{ t('profile.passkey.name') }}</label>
<input
id="passkey-name"
v-model="newName"
class="input"
maxlength="100"
:placeholder="t('profile.passkey.namePlaceholder')"
autofocus
/>
<div class="grid gap-3 sm:grid-cols-2">
<div>
<label for="passkey-name" class="input-label">{{ t('profile.passkey.name') }}</label>
<input
id="passkey-name"
v-model="newName"
class="input"
maxlength="100"
:placeholder="t('profile.passkey.namePlaceholder')"
autofocus
/>
</div>
<div>
<label for="passkey-add-password" class="input-label">{{
t('profile.currentPassword')
}}</label>
<input
id="passkey-add-password"
v-model="newPassword"
type="password"
autocomplete="current-password"
class="input"
:placeholder="t('profile.passkey.passwordPlaceholder')"
/>
</div>
</div>
<div class="flex gap-2">
<div class="flex justify-end gap-2">
<button type="button" class="btn btn-secondary" :disabled="busy" @click="cancelAdd">
{{ t('common.cancel') }}
</button>
<button type="submit" class="btn btn-primary" :disabled="busy">
<button type="submit" class="btn btn-primary" :disabled="busy || newPassword.length === 0">
{{ busy ? t('common.processing') : t('profile.passkey.continue') }}
</button>
</div>
@@ -113,6 +128,51 @@
</div>
</div>
</div>
<!-- 删除确认:吊销凭据需验证当前密码,防止被窃会话静默移除 Passkey -->
<div v-if="deleteTarget" class="fixed inset-0 z-50 overflow-y-auto">
<div class="flex min-h-full items-center justify-center p-4">
<div class="fixed inset-0 bg-black/50 transition-opacity" @click="closeDeleteDialog"></div>
<div
class="relative w-full max-w-md transform rounded-xl bg-white p-6 shadow-xl transition-all dark:bg-dark-800"
>
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
{{ t('profile.passkey.deleteTitle') }}
</h3>
<p class="mt-2 text-sm text-gray-500 dark:text-gray-400">
{{ t('profile.passkey.deleteConfirm', { name: deleteTarget.name }) }}
</p>
<form class="mt-4 space-y-4" @submit.prevent="confirmDelete">
<div>
<label for="passkey-delete-password" class="input-label">{{
t('profile.currentPassword')
}}</label>
<input
id="passkey-delete-password"
v-model="deletePassword"
type="password"
autocomplete="current-password"
class="input"
:placeholder="t('profile.passkey.passwordPlaceholder')"
autofocus
/>
</div>
<div class="flex justify-end gap-3">
<button type="button" class="btn btn-secondary" :disabled="busy" @click="closeDeleteDialog">
{{ t('common.cancel') }}
</button>
<button
type="submit"
class="btn btn-danger"
:disabled="busy || deletePassword.length === 0"
>
{{ busy ? t('common.processing') : t('common.delete') }}
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</template>
@@ -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<PasskeyCredentialSummary | null>(null)
const deletePassword = ref('')
const credentials = ref<PasskeyCredentialSummary[]>([])
// 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<void> {
loading.value = true
try {
@@ -149,15 +219,16 @@ async function loadCredentials(): Promise<void> {
}
async function addPasskey(): Promise<void> {
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<void> {
function cancelAdd(): void {
showAddForm.value = false
newName.value = ''
newPassword.value = ''
}
async function renamePasskey(credential: PasskeyCredentialSummary): Promise<void> {
@@ -184,15 +256,28 @@ async function renamePasskey(credential: PasskeyCredentialSummary): Promise<void
}
}
async function deletePasskey(credential: PasskeyCredentialSummary): Promise<void> {
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<void> {
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
}
@@ -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.'
@@ -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 失败。'