mirror of
https://github.com/EstrellaXD/Auto_Bangumi.git
synced 2026-07-17 12:11:14 +08:00
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:
88
webui/src/api/passkey.ts
Normal file
88
webui/src/api/passkey.ts
Normal 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;
|
||||
},
|
||||
};
|
||||
174
webui/src/components/setting/config-passkey.vue
Normal file
174
webui/src/components/setting/config-passkey.vue
Normal 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>
|
||||
94
webui/src/hooks/usePasskey.ts
Normal file
94
webui/src/hooks/usePasskey.ts
Normal 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,
|
||||
};
|
||||
});
|
||||
@@ -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!",
|
||||
|
||||
@@ -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": "复制成功!",
|
||||
|
||||
@@ -33,6 +33,8 @@ onActivated(() => {
|
||||
<config-player></config-player>
|
||||
|
||||
<config-openai></config-openai>
|
||||
|
||||
<config-passkey></config-passkey>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
166
webui/src/services/webauthn.ts
Normal file
166
webui/src/services/webauthn.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user