feat: add WebAuthn passkey authentication support

- Add passkey login as alternative authentication method
- Support multiple passkeys per user with custom names
- Backend: WebAuthn service, auth strategy pattern, API endpoints
- Frontend: passkey management UI in settings, login option
- Fix: convert downloader check from sync requests to async httpx
  to prevent blocking the event loop when downloader unavailable

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
EstrellaXD
2026-01-22 11:50:55 +01:00
parent 0c69498089
commit d2cfd9b150
23 changed files with 1607 additions and 87 deletions

88
webui/src/api/passkey.ts Normal file
View File

@@ -0,0 +1,88 @@
import type { ApiSuccess } from '#/api';
import type { LoginSuccess } from '#/auth';
import type {
AuthenticationOptions,
PasskeyAuthFinishRequest,
PasskeyAuthStartRequest,
PasskeyCreateRequest,
PasskeyDeleteRequest,
PasskeyItem,
RegistrationOptions,
} from '#/passkey';
/**
* Passkey API 客户端
*/
export const apiPasskey = {
// ============ 注册流程 ============
/**
* 获取注册选项(步骤 1
*/
async getRegistrationOptions(): Promise<RegistrationOptions> {
const { data } = await axios.post<RegistrationOptions>(
'api/v1/passkey/register/options'
);
return data;
},
/**
* 提交注册结果(步骤 2
*/
async verifyRegistration(request: PasskeyCreateRequest): Promise<ApiSuccess> {
const { data } = await axios.post<ApiSuccess>(
'api/v1/passkey/register/verify',
request
);
return data;
},
// ============ 认证流程 ============
/**
* 获取登录选项(步骤 1
*/
async getLoginOptions(
request: PasskeyAuthStartRequest
): Promise<AuthenticationOptions> {
const { data } = await axios.post<AuthenticationOptions>(
'api/v1/passkey/auth/options',
request
);
return data;
},
/**
* 提交认证结果(步骤 2
*/
async loginWithPasskey(
request: PasskeyAuthFinishRequest
): Promise<LoginSuccess> {
const { data } = await axios.post<LoginSuccess>(
'api/v1/passkey/auth/verify',
request
);
return data;
},
// ============ 管理 ============
/**
* 获取 Passkey 列表
*/
async list(): Promise<PasskeyItem[]> {
const { data } = await axios.get<PasskeyItem[]>('api/v1/passkey/list');
return data;
},
/**
* 删除 Passkey
*/
async delete(request: PasskeyDeleteRequest): Promise<ApiSuccess> {
const { data } = await axios.post<ApiSuccess>(
'api/v1/passkey/delete',
request
);
return data;
},
};

View File

@@ -0,0 +1,174 @@
<script lang="ts" setup>
import { Delete } from '@icon-park/vue-next';
import type { PasskeyItem } from '#/passkey';
const { t } = useMyI18n();
const { passkeys, loading, isSupported, loadPasskeys, addPasskey, deletePasskey } =
usePasskey();
const showAddDialog = ref(false);
const deviceName = ref('');
const isRegistering = ref(false);
onMounted(() => {
loadPasskeys();
});
function openAddDialog() {
// 生成默认设备名称
const platform = navigator.platform || 'Device';
const userAgent = navigator.userAgent;
if (userAgent.includes('iPhone')) {
deviceName.value = 'iPhone';
} else if (userAgent.includes('iPad')) {
deviceName.value = 'iPad';
} else if (userAgent.includes('Mac')) {
deviceName.value = 'MacBook';
} else if (userAgent.includes('Windows')) {
deviceName.value = 'Windows PC';
} else if (userAgent.includes('Android')) {
deviceName.value = 'Android';
} else {
deviceName.value = platform;
}
showAddDialog.value = true;
}
async function handleAdd() {
if (!deviceName.value.trim()) return;
isRegistering.value = true;
try {
const success = await addPasskey(deviceName.value.trim());
if (success) {
showAddDialog.value = false;
deviceName.value = '';
}
} finally {
isRegistering.value = false;
}
}
async function handleDelete(passkey: PasskeyItem) {
if (!confirm(t('passkey.delete_confirm'))) return;
await deletePasskey(passkey.id);
}
function formatDate(dateString: string | null): string {
if (!dateString) return '-';
return new Date(dateString).toLocaleString();
}
</script>
<template>
<ab-fold-panel :title="$t('passkey.title')">
<div space-y-12>
<!-- 不支持提示 -->
<div v-if="!isSupported" text-orange-500 text-14>
{{ $t('passkey.not_supported') }}
</div>
<!-- 加载中 -->
<div v-else-if="loading" text-gray-500 text-14>
{{ $t('passkey.loading') }}
</div>
<!-- Passkey -->
<div v-else-if="passkeys.length === 0" text-gray-500 text-14>
{{ $t('passkey.no_passkeys') }}
</div>
<!-- Passkey 列表 -->
<div v-else space-y-8>
<div
v-for="passkey in passkeys"
:key="passkey.id"
flex="~ justify-between items-center"
p-12
bg-gray-50
rounded-8
>
<div>
<div font-medium>{{ passkey.name }}</div>
<div text-12 text-gray-500>
{{ $t('passkey.created_at') }}: {{ formatDate(passkey.created_at) }}
</div>
<div v-if="passkey.last_used_at" text-12 text-gray-500>
{{ $t('passkey.last_used') }}: {{ formatDate(passkey.last_used_at) }}
</div>
<div v-if="passkey.backup_eligible" text-12 text-green-600>
{{ $t('passkey.synced') }}
</div>
</div>
<ab-button
size="small"
type="warn"
@click="handleDelete(passkey)"
>
<Delete size="16" />
</ab-button>
</div>
</div>
<div line></div>
<!-- 添加按钮 -->
<div flex="~ justify-end">
<ab-button
v-if="isSupported"
size="small"
type="primary"
@click="openAddDialog"
>
{{ $t('passkey.add_new') }}
</ab-button>
</div>
</div>
<!-- 添加对话框 -->
<ab-popup
v-model:show="showAddDialog"
:title="$t('passkey.register_title')"
css="w-365"
>
<div space-y-16>
<ab-label :label="$t('passkey.device_name')">
<input
v-model="deviceName"
type="text"
:placeholder="$t('passkey.device_name_placeholder')"
ab-input
maxlength="64"
@keyup.enter="handleAdd"
/>
</ab-label>
<div text-14 text-gray-500>
{{ $t('passkey.register_hint') }}
</div>
<div line></div>
<div flex="~ justify-end gap-8">
<ab-button
size="small"
type="warn"
@click="showAddDialog = false"
>
{{ $t('config.cancel') }}
</ab-button>
<ab-button
size="small"
type="primary"
:disabled="!deviceName.trim() || isRegistering"
@click="handleAdd"
>
{{ $t('config.apply') }}
</ab-button>
</div>
</div>
</ab-popup>
</ab-fold-panel>
</template>

View File

@@ -0,0 +1,94 @@
import { createSharedComposable } from '@vueuse/core';
import { apiPasskey } from '@/api/passkey';
import {
registerPasskey,
loginWithPasskey as webauthnLogin,
isWebAuthnSupported,
} from '@/services/webauthn';
import type { PasskeyItem } from '#/passkey';
export const usePasskey = createSharedComposable(() => {
const message = useMessage();
const { t } = useMyI18n();
const { isLoggedIn } = useAuth();
// 状态
const passkeys = ref<PasskeyItem[]>([]);
const loading = ref(false);
const isSupported = ref(false);
// 检测浏览器支持
onMounted(() => {
isSupported.value = isWebAuthnSupported();
});
// 加载 Passkey 列表
async function loadPasskeys() {
if (!isLoggedIn.value) return;
try {
loading.value = true;
passkeys.value = await apiPasskey.list();
} catch (error) {
console.error('Failed to load passkeys:', error);
} finally {
loading.value = false;
}
}
// 注册新 Passkey
async function addPasskey(deviceName: string): Promise<boolean> {
try {
await registerPasskey(deviceName);
message.success(t('passkey.register_success'));
await loadPasskeys();
return true;
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
message.error(`${t('passkey.register_failed')}: ${errorMessage}`);
return false;
}
}
// 使用 Passkey 登录
async function loginWithPasskey(username: string): Promise<boolean> {
try {
await webauthnLogin(username);
isLoggedIn.value = true;
message.success(t('notify.login_success'));
return true;
} catch (error: unknown) {
const errorMessage =
error instanceof Error ? error.message : String(error);
message.error(`${t('passkey.login_failed')}: ${errorMessage}`);
return false;
}
}
// 删除 Passkey
async function deletePasskey(passkeyId: number): Promise<boolean> {
try {
await apiPasskey.delete({ passkey_id: passkeyId });
message.success(t('passkey.delete_success'));
await loadPasskeys();
return true;
} catch (error) {
message.error(t('passkey.delete_failed'));
return false;
}
}
return {
// 状态
passkeys,
loading,
isSupported,
// 方法
loadPasskeys,
addPasskey,
loginWithPasskey,
deletePasskey,
};
});

View File

@@ -103,10 +103,31 @@
},
"login": {
"login_btn": "Login",
"passkey_btn": "Passkey",
"password": "Password",
"title": "Login",
"username": "Username"
},
"passkey": {
"add_new": "Add Passkey",
"created_at": "Created",
"delete_confirm": "Are you sure you want to delete this passkey?",
"delete_failed": "Delete failed",
"delete_success": "Passkey deleted",
"device_name": "Device Name",
"device_name_placeholder": "e.g., iPhone 15, MacBook Pro",
"last_used": "Last used",
"loading": "Loading...",
"login_failed": "Passkey login failed",
"no_passkeys": "No passkeys registered yet",
"not_supported": "Your browser does not support Passkeys",
"register_failed": "Registration failed",
"register_hint": "After clicking confirm, follow your browser's prompts to complete authentication.",
"register_success": "Passkey registered successfully",
"register_title": "Add New Passkey",
"synced": "Synced across devices",
"title": "Passkey Settings"
},
"notify": {
"copy_failed": "Your browser does not support Clipboard API!",
"copy_success": "Copy Success!",

View File

@@ -103,10 +103,31 @@
},
"login": {
"login_btn": "登录",
"passkey_btn": "通行密钥",
"password": "密码",
"title": "登录",
"username": "用户名"
},
"passkey": {
"add_new": "添加 Passkey",
"created_at": "创建于",
"delete_confirm": "确定删除此 Passkey",
"delete_failed": "删除失败",
"delete_success": "Passkey 已删除",
"device_name": "设备名称",
"device_name_placeholder": "例如iPhone 15, MacBook Pro",
"last_used": "最后使用",
"loading": "加载中...",
"login_failed": "Passkey 登录失败",
"no_passkeys": "尚未注册任何 Passkey",
"not_supported": "您的浏览器不支持 Passkey",
"register_failed": "注册失败",
"register_hint": "点击确认后,请按照浏览器提示完成身份验证。",
"register_success": "Passkey 注册成功",
"register_title": "添加新的 Passkey",
"synced": "已同步到多设备",
"title": "Passkey 设置"
},
"notify": {
"copy_failed": "您的浏览器不支持剪贴板操作!",
"copy_success": "复制成功!",

View File

@@ -33,6 +33,8 @@ onActivated(() => {
<config-player></config-player>
<config-openai></config-openai>
<config-passkey></config-passkey>
</div>
</div>

View File

@@ -1,9 +1,30 @@
<script lang="ts" setup>
import { Fingerprint } from '@icon-park/vue-next';
definePage({
name: 'Login',
});
const { user, login } = useAuth();
const { isSupported, loginWithPasskey } = usePasskey();
const isPasskeyLoading = ref(false);
async function handlePasskeyLogin() {
if (!user.username) {
const message = useMessage();
const { t } = useMyI18n();
message.warning(t('notify.please_enter', [t('login.username')]));
return;
}
isPasskeyLoading.value = true;
try {
await loginWithPasskey(user.username);
} finally {
isPasskeyLoading.value = false;
}
}
</script>
<template>
@@ -31,7 +52,18 @@ const { user, login } = useAuth();
<div line></div>
<div flex="~ justify-end">
<div flex="~ justify-between items-center">
<ab-button
v-if="isSupported"
size="small"
type="secondary"
:disabled="isPasskeyLoading"
@click="handlePasskeyLogin"
>
<Fingerprint size="16" mr-4 />
{{ $t('login.passkey_btn') }}
</ab-button>
<div v-else></div>
<ab-button size="small" @click="login">
{{ $t('login.login_btn') }}
</ab-button>

View File

@@ -0,0 +1,166 @@
import { apiPasskey } from '@/api/passkey';
/**
* WebAuthn 浏览器 API 封装
* 处理 Base64URL 编码和浏览器兼容性
*/
// ============ 工具函数 ============
function base64UrlToBuffer(base64url: string): ArrayBuffer {
// 补齐 padding
const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/');
const padding = '='.repeat((4 - (base64.length % 4)) % 4);
const binary = atob(base64 + padding);
const buffer = new ArrayBuffer(binary.length);
const bytes = new Uint8Array(buffer);
for (let i = 0; i < binary.length; i++) {
bytes[i] = binary.charCodeAt(i);
}
return buffer;
}
function bufferToBase64Url(buffer: ArrayBuffer): string {
const bytes = new Uint8Array(buffer);
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
}
// ============ 注册流程 ============
/**
* 注册新的 Passkey
* @param deviceName 设备名称(用户输入)
*/
export async function registerPasskey(deviceName: string): Promise<void> {
// 1. 获取注册选项
const options = await apiPasskey.getRegistrationOptions();
// 2. 转换选项为浏览器 API 格式
const createOptions: PublicKeyCredentialCreationOptions = {
challenge: base64UrlToBuffer(options.challenge),
rp: options.rp,
user: {
id: base64UrlToBuffer(options.user.id),
name: options.user.name,
displayName: options.user.displayName,
},
pubKeyCredParams: options.pubKeyCredParams.map((p) => ({
type: p.type as PublicKeyCredentialType,
alg: p.alg,
})),
timeout: options.timeout || 60000,
excludeCredentials: options.excludeCredentials?.map((cred) => ({
type: cred.type as PublicKeyCredentialType,
id: base64UrlToBuffer(cred.id),
transports: cred.transports as AuthenticatorTransport[],
})),
authenticatorSelection: options.authenticatorSelection as AuthenticatorSelectionCriteria,
};
// 3. 调用浏览器 WebAuthn API
const credential = (await navigator.credentials.create({
publicKey: createOptions,
})) as PublicKeyCredential;
if (!credential) {
throw new Error('Failed to create credential');
}
// 4. 序列化 credential 为 JSON
const response = credential.response as AuthenticatorAttestationResponse;
const attestationResponse = {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
attestationObject: bufferToBase64Url(response.attestationObject),
},
};
// 5. 提交到后端验证
await apiPasskey.verifyRegistration({
name: deviceName,
attestation_response: attestationResponse,
});
}
// ============ 认证流程 ============
/**
* 使用 Passkey 登录
* @param username 用户名
*/
export async function loginWithPasskey(username: string): Promise<void> {
// 1. 获取认证选项
const options = await apiPasskey.getLoginOptions({ username });
// 2. 转换选项
const getOptions: PublicKeyCredentialRequestOptions = {
challenge: base64UrlToBuffer(options.challenge),
timeout: options.timeout || 60000,
rpId: options.rpId,
allowCredentials: options.allowCredentials?.map((cred) => ({
type: cred.type as PublicKeyCredentialType,
id: base64UrlToBuffer(cred.id),
transports: cred.transports as AuthenticatorTransport[],
})),
userVerification: options.userVerification as UserVerificationRequirement,
};
// 3. 调用浏览器 API
const credential = (await navigator.credentials.get({
publicKey: getOptions,
})) as PublicKeyCredential;
if (!credential) {
throw new Error('Failed to get credential');
}
// 4. 序列化响应
const response = credential.response as AuthenticatorAssertionResponse;
const assertionResponse = {
id: credential.id,
rawId: bufferToBase64Url(credential.rawId),
type: credential.type,
response: {
clientDataJSON: bufferToBase64Url(response.clientDataJSON),
authenticatorData: bufferToBase64Url(response.authenticatorData),
signature: bufferToBase64Url(response.signature),
userHandle: response.userHandle
? bufferToBase64Url(response.userHandle)
: null,
},
};
// 5. 提交到后端验证并登录
await apiPasskey.loginWithPasskey({
username,
credential: assertionResponse,
});
}
// ============ 浏览器支持检测 ============
export function isWebAuthnSupported(): boolean {
return !!(
window.PublicKeyCredential &&
navigator.credentials &&
navigator.credentials.create
);
}
export async function isPlatformAuthenticatorAvailable(): Promise<boolean> {
if (!isWebAuthnSupported()) return false;
try {
return await PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable();
} catch {
return false;
}
}