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
@@ -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
}