feat: 支持通过通行密钥登录

This commit is contained in:
nap0o committed 2026-03-10 16:38:12 +08:00
1 parent 7356113f89
commit ed91e1943a
19 files changed
+759 -9

No files matched your search

File renamed without changes.
+7 -5
View File
@@ -52,13 +52,14 @@ jobs:
- name: Install Project Dependencies
if: env.SECURITY_SNYK_TOKEN != ''
continue-on-error: true
env:
SECURITY_PACKAGE: ${{ vars.SECURITY_PACKAGE || '' }}
SECURITY_PACKAGE: ${{ vars.SECURITY_PACKAGE || secrets.SECURITY_PACKAGE || env.SECURITY_PACKAGE || '' }}
run: |
echo "Preparing dependency lock files for security scanning..."
if [ -z "$SECURITY_PACKAGE" ]; then
echo "SECURITY_PACKAGE is empty, installing in root..."
npm install --package-lock-only
npm install --package-lock-only --legacy-peer-deps
else
echo "SECURITY_PACKAGE is set to: $SECURITY_PACKAGE"
# Split by comma and install
@@ -66,7 +67,7 @@ jobs:
for pkg in "${PACKAGES[@]}"; do
if [ -d "$pkg" ]; then
echo "Installing in "$pkg"..."
npm install --prefix "$pkg" --package-lock-only
npm install --prefix "$pkg" --package-lock-only --legacy-peer-deps
else
echo "Warning: Directory $pkg not found, skipping."
fi
@@ -103,12 +104,13 @@ jobs:
trivy config . --format json --output trivy_result.json --severity CRITICAL,HIGH || true
- name: Generate Security Report
if: always()
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
# Gitleaks typically produces results.sarif if configured or by default in some versions
# We'll ensure it exists for our reporter
node .github/scripts/security.js
node .github/scripts/security.cjs
# Also append to step summary for immediate visibility in GHA UI
cat README.md >> $GITHUB_STEP_SUMMARY
@@ -123,7 +125,7 @@ jobs:
category: gitleaks
- name: Push to Audit Branch
if: github.event_name != 'pull_request'
if: always() && github.event_name != 'pull_request'
run: |
mkdir audit_temp
cp README.md audit_temp/
+2 -1
View File
@@ -8,6 +8,7 @@
},
"dependencies": {
"@hono/node-server": "^1.19.11",
"@simplewebauthn/server": "^13.3.0",
"aws4fetch": "^1.0.20",
"better-sqlite3": "^12.6.2",
"drizzle-orm": "^0.30.10",
@@ -27,4 +28,4 @@
"overrides": {
"esbuild": ">=0.25.0"
}
}
}
+13
View File
@@ -41,12 +41,25 @@ CREATE TABLE IF NOT EXISTS backup_telegram_history (
created_at INTEGER NOT NULL
);
-- Passkey 凭证表
CREATE TABLE IF NOT EXISTS auth_passkeys (
credential_id TEXT PRIMARY KEY, -- 凭证唯一标识
user_id TEXT NOT NULL, -- 关联用户标识 (Email)
public_key BLOB NOT NULL, -- 导出的公钥
counter INTEGER DEFAULT 0, -- 签名计数器
name TEXT, -- 硬件名称/别名
transports TEXT, -- 允许的传输方式 (JSON array)
created_at INTEGER NOT NULL,
last_used_at INTEGER
);
-- 索引
DROP INDEX IF EXISTS idx_vault_service;
CREATE INDEX IF NOT EXISTS idx_vault_created_at ON vault(created_at DESC);
CREATE INDEX IF NOT EXISTS idx_backup_providers_type ON backup_providers(type);
CREATE INDEX IF NOT EXISTS idx_vault_service_created_at ON vault(service, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_backup_telegram_history_provider_id ON backup_telegram_history(provider_id, created_at DESC);
CREATE INDEX IF NOT EXISTS idx_passkeys_user_id ON auth_passkeys(user_id);
-- Remove any existing duplicates before enforcing unique constraint; keep the earliest
DELETE FROM vault
+91
View File
@@ -4,10 +4,12 @@ import { EnvBindings, AppError } from '@/app/config';
import { authMiddleware } from '@/shared/middleware/auth';
import { getAvailableProviders } from '@/features/auth/providers/index';
import { AuthService } from '@/features/auth/authService';
import { WebAuthnService } from '@/features/auth/webAuthnService';
const auth = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
const getService = (c: any) => new AuthService(c.env);
const getWebAuthnService = (c: any) => new WebAuthnService(c.env, c.req.url);
// 获取可用登录方式列表
auth.get('/providers', (c) => {
@@ -126,4 +128,93 @@ auth.get('/me', authMiddleware, (c) => {
});
});
// --- WebAuthn (Passkey) 核心接口 ---
// 1. 获取注册选项 (需已登录)
auth.get('/webauthn/register/options', authMiddleware, async (c) => {
const user = c.get('user');
const service = getWebAuthnService(c);
const options = await service.generateRegistrationOptions(user.id, user.email);
// 存储 challenge 到 Cookie
setCookie(c, 'webauthn_registration_challenge', options.challenge, {
httpOnly: true, secure: true, sameSite: 'Lax', maxAge: 120, path: '/'
});
return c.json(options);
});
// 2. 验证注册响应 (需已登录)
auth.post('/webauthn/register/verify', authMiddleware, async (c) => {
const user = c.get('user');
const body = await c.req.json();
const expectedChallenge = getCookie(c, 'webauthn_registration_challenge');
if (!expectedChallenge) throw new AppError('webauthn_challenge_missing', 400);
const service = getWebAuthnService(c);
const { name } = body; // 前端传回的自定义别名
const result = await service.verifyRegistrationResponse(user.email, body.response, expectedChallenge, name);
deleteCookie(c, 'webauthn_registration_challenge', { path: '/' });
return c.json(result);
});
// 3. 获取登录选项 (公开)
auth.get('/webauthn/login/options', async (c) => {
const service = getWebAuthnService(c);
const options = await service.generateAuthenticationOptions();
setCookie(c, 'webauthn_login_challenge', options.challenge, {
httpOnly: true, secure: true, sameSite: 'Lax', maxAge: 120, path: '/'
});
return c.json(options);
});
// 4. 验证登录响应 (公开)
auth.post('/webauthn/login/verify', async (c) => {
const body = await c.req.json();
const expectedChallenge = getCookie(c, 'webauthn_login_challenge');
if (!expectedChallenge) throw new AppError('webauthn_challenge_missing', 400);
const service = getWebAuthnService(c);
const result = await service.verifyAuthenticationResponse(body, expectedChallenge);
// 登录成功,设置会话 Cookie (逻辑同 OAuth callback)
setCookie(c, 'auth_token', result.token, {
httpOnly: true, secure: true, sameSite: 'Strict', maxAge: 7 * 24 * 60 * 60, path: '/',
});
const csrfToken = crypto.randomUUID();
setCookie(c, 'csrf_token', csrfToken, {
httpOnly: false, secure: true, sameSite: 'Strict', maxAge: 7 * 24 * 60 * 60, path: '/',
});
deleteCookie(c, 'webauthn_login_challenge', { path: '/' });
return c.json({
success: true,
userInfo: result.userInfo
});
});
// 5. 获取凭证列表 (需已登录)
auth.get('/webauthn/credentials', authMiddleware, async (c) => {
const user = c.get('user');
const service = getWebAuthnService(c);
const credentials = await service.listCredentials(user.email);
return c.json({ success: true, credentials });
});
// 6. 删除凭证 (需已登录)
auth.delete('/webauthn/credentials/:id', authMiddleware, async (c) => {
const user = c.get('user');
const credentialId = c.req.param('id');
const service = getWebAuthnService(c);
const result = await service.deleteCredential(user.email, credentialId);
return c.json(result);
});
export default auth;
@@ -0,0 +1,204 @@
import { EnvBindings, AppError } from '@/app/config';
import { generateSecureJWT } from '@/shared/utils/crypto';
import * as schema from '@/shared/db/schema';
import { eq, desc, and } from 'drizzle-orm';
import {
generateRegistrationOptions,
verifyRegistrationResponse,
generateAuthenticationOptions,
verifyAuthenticationResponse,
} from '@simplewebauthn/server';
export class WebAuthnService {
private env: EnvBindings;
private rpName = '2FAuth Worker';
private rpID: string;
private origin: string;
constructor(env: EnvBindings, url: string) {
this.env = env;
const parsedUrl = new URL(url);
this.rpID = parsedUrl.hostname;
this.origin = `${parsedUrl.protocol}//${parsedUrl.host}`;
}
/**
* 生成注册选项
*/
async generateRegistrationOptions(userId: string, userEmail: string) {
// 获取已存在的凭证
const results = await this.env.DB.select({ credential_id: schema.authPasskeys.credentialId })
.from(schema.authPasskeys)
.where(eq(schema.authPasskeys.userId, userEmail));
const options = await generateRegistrationOptions({
rpName: this.rpName,
rpID: this.rpID,
userID: new TextEncoder().encode(userId),
userName: userEmail,
attestationType: 'none',
excludeCredentials: results.map((row: any) => ({
id: row.credential_id,
type: 'public-key',
})),
authenticatorSelection: {
residentKey: 'preferred',
userVerification: 'preferred',
},
});
return options;
}
/**
* 验证注册响应
*/
async verifyRegistrationResponse(userEmail: string, body: any, expectedChallenge: string, credentialName?: string) {
const verification = await verifyRegistrationResponse({
response: body,
expectedChallenge,
expectedOrigin: this.origin,
expectedRPID: this.rpID,
});
if (verification.verified && verification.registrationInfo) {
const { credential } = verification.registrationInfo;
// 存储到 DB
await this.env.DB.insert(schema.authPasskeys).values({
credentialId: credential.id,
userId: userEmail,
publicKey: credential.publicKey as any,
counter: verification.registrationInfo.credential.counter,
name: credentialName || `Passkey ${new Date().toLocaleDateString()}`,
createdAt: Date.now()
});
return { success: true };
}
throw new AppError('webauthn_registration_failed', 400);
}
/**
* 生成登录选项
*/
async generateAuthenticationOptions() {
const options = await generateAuthenticationOptions({
rpID: this.rpID,
userVerification: 'preferred',
});
return options;
}
/**
* 验证登录响应
*/
async verifyAuthenticationResponse(body: any, expectedChallenge: string) {
const credentialID = body.id;
// 从 DB 查找凭证
const [credential] = await this.env.DB.select()
.from(schema.authPasskeys)
.where(eq(schema.authPasskeys.credentialId, credentialID))
.limit(1);
if (!credential) {
throw new AppError('passkey_not_found', 404);
}
const verification = await verifyAuthenticationResponse({
response: body,
expectedChallenge,
expectedOrigin: this.origin,
expectedRPID: this.rpID,
credential: {
id: credential.credentialId,
publicKey: new Uint8Array(Object.values(credential.publicKey)) as any,
counter: credential.counter,
transports: []
},
});
if (verification.verified && verification.authenticationInfo) {
// 更新计数器
await this.env.DB.update(schema.authPasskeys)
.set({
counter: verification.authenticationInfo.newCounter,
lastUsedAt: Date.now()
})
.where(eq(schema.authPasskeys.credentialId, credentialID));
// 验证白名单 (这里我们需要模拟一个 OAuthUserInfo 结构)
const userEmail = credential.userId; // 在这里 user_id 存储的是 Email
// 签发 Token
const token = await this.generateSystemToken({
id: userEmail,
username: userEmail.split('@')[0],
email: userEmail,
provider: 'passkey'
});
return {
success: true,
token,
userInfo: {
id: userEmail,
username: userEmail.split('@')[0],
email: userEmail,
provider: 'passkey'
}
};
}
throw new AppError('webauthn_login_failed', 400);
}
private async generateSystemToken(userInfo: any): Promise<string> {
const payload = {
userInfo: {
id: userInfo.id,
username: userInfo.username,
email: userInfo.email,
avatar: userInfo.avatar,
provider: userInfo.provider
}
};
if (!this.env.JWT_SECRET) {
throw new AppError('missing_jwt_secret', 500);
}
return await generateSecureJWT(payload, this.env.JWT_SECRET);
}
/**
* 获取用户的凭证列表
*/
async listCredentials(userEmail: string) {
const results = await this.env.DB.select({
id: schema.authPasskeys.credentialId,
name: schema.authPasskeys.name,
created_at: schema.authPasskeys.createdAt,
last_used_at: schema.authPasskeys.lastUsedAt
})
.from(schema.authPasskeys)
.where(eq(schema.authPasskeys.userId, userEmail))
.orderBy(desc(schema.authPasskeys.createdAt));
return results;
}
/**
* 删除凭证
*/
async deleteCredential(userEmail: string, credentialId: string) {
await this.env.DB.delete(schema.authPasskeys)
.where(and(
eq(schema.authPasskeys.userId, userEmail),
eq(schema.authPasskeys.credentialId, credentialId)
));
return { success: true };
}
}
+11
View File
@@ -45,6 +45,17 @@ export const backupTelegramHistory = sqliteTable('backup_telegram_history', {
createdAt: integer('created_at').notNull(),
});
// 4. 通行密钥表 (Passkeys)
export const authPasskeys = sqliteTable('auth_passkeys', {
credentialId: text('credential_id').primaryKey(), // 唯一凭据 ID
userId: text('user_id').notNull(), // 在本应用中绑定的是邮箱
name: text('name'), // 别名
publicKey: text('public_key').notNull(), // Uint8Array 序列化后的数组
counter: integer('counter').default(0), // 认证流计算器
lastUsedAt: integer('last_used_at'), // 最后一次使用的时间戳
createdAt: integer('created_at').notNull(),
});
// 导出类型定义
export type VaultItem = typeof vault.$inferSelect;
export type NewVaultItem = typeof vault.$inferInsert;
+1
View File
@@ -10,6 +10,7 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@simplewebauthn/browser": "^13.3.0",
"@tanstack/vue-query": "^5.66.0",
"argon2-browser": "^1.18.0",
"bcryptjs": "^3.0.3",
+2 -1
View File
@@ -27,4 +27,5 @@
@import './modules/components/backup.css';
@import './modules/components/migration.css';
@import './modules/components/tools.css';
@import './modules/components/health.css';
@import './modules/components/health.css';
@import './modules/components/security.css';
@@ -60,6 +60,37 @@
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.passkey-btn {
margin-bottom: 5px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%) !important;
border: none !important;
color: white !important;
}
.login-divider {
display: flex;
align-items: center;
text-align: center;
color: var(--el-text-color-secondary);
font-size: 12px;
margin: 10px 0;
}
.login-divider::before,
.login-divider::after {
content: '';
flex: 1;
border-bottom: 1px dashed var(--el-border-color-lighter);
}
.login-divider:not(:empty)::before {
margin-right: .5em;
}
.login-divider:not(:empty)::after {
margin-left: .5em;
}
.footer-tips {
margin-top: 10px;
}
@@ -0,0 +1,21 @@
.security-wrapper .empty-state {
padding: 60px 0;
}
.security-container {
max-width: 1200px;
margin: 0 auto;
}
.security-header-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 24px;
}
.security-header-actions h3 {
margin: 0;
display: flex;
align-items: center;
}
@@ -0,0 +1,70 @@
import {
startRegistration,
startAuthentication,
browserSupportsWebAuthn
} from '@simplewebauthn/browser';
import { request } from '@/shared/utils/request';
export const webAuthnService = {
/**
* 判断当前浏览器是否支持 WebAuthn
*/
isSupported() {
return browserSupportsWebAuthn();
},
/**
* 注册新的 Passkey
*/
async register(name) {
// 1. 获取注册选项
const options = await request('/api/oauth/webauthn/register/options');
// 2. 调用浏览器 API 进行挑战响应
const attestation = await startRegistration(options);
// 3. 发回后端验证并存储
const result = await request('/api/oauth/webauthn/register/verify', {
method: 'POST',
body: JSON.stringify({
name,
response: attestation
})
});
return result;
},
/**
* 使用 Passkey 登录
*/
async login() {
// 1. 获取登录选项
const options = await request('/api/oauth/webauthn/login/options');
// 2. 调用浏览器 API 进行签名
const assertion = await startAuthentication(options);
// 3. 发回后端验证并签发 JWT
const result = await request('/api/oauth/webauthn/login/verify', {
method: 'POST',
body: JSON.stringify(assertion)
});
return result;
},
/**
* 获取凭证列表
*/
async listCredentials() {
return await request('/api/oauth/webauthn/credentials');
},
/**
* 删除凭证
*/
async deleteCredential(id) {
return await request(`/api/oauth/webauthn/credentials/${id}`, { method: 'DELETE' });
}
};
+58 -1
View File
@@ -20,7 +20,7 @@
class="oauth-btn"
:style="{ backgroundColor: provider.color, borderColor: provider.color }"
:loading="loadingProvider === provider.id"
:disabled="!!loadingProvider && loadingProvider !== provider.id"
:disabled="(!!loadingProvider && loadingProvider !== provider.id) || loadingPasskey"
@click="handleLogin(provider.id)"
>
<template #icon>
@@ -31,6 +31,24 @@
{{ $t('auth.login_with', { provider: provider.name }) }}
</el-button>
</template>
<div class="login-divider" v-if="providers.length > 0">
OR
</div>
<!-- Passkey Login -->
<el-button
type="primary"
size="large"
class="oauth-btn passkey-btn"
:loading="loadingPasskey"
@click="handlePasskeyLogin"
>
<template #icon>
<el-icon><iconFingerprint /></el-icon>
</template>
{{ $t('auth.passkey_login') }}
</el-button>
</div>
<div class="footer-tips">
@@ -47,14 +65,23 @@
</template>
<script setup>
import { ref } from 'vue'
import { Lock, Platform, Loading } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import { useI18n } from 'vue-i18n'
import iconGithub from '@/shared/components/icons/iconGithub.vue'
import iconGoogle from '@/shared/components/icons/iconGoogle.vue'
import iconGitee from '@/shared/components/icons/iconGitee.vue'
import iconTelegram from '@/shared/components/icons/iconTelegram.vue'
import iconCloudflare from '@/shared/components/icons/iconCloudflare.vue'
import iconNodeloc from '@/shared/components/icons/iconNodeloc.vue'
import iconFingerprint from '@/shared/components/icons/iconFingerprint.vue'
import { useOAuthProviders } from '@/features/auth/composables/useOAuthProviders'
import { webAuthnService } from '@/features/auth/service/webAuthnService'
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
const { t } = useI18n()
const authUserStore = useAuthUserStore()
const iconComponents = {
iconGithub,
@@ -71,4 +98,34 @@ const {
isFetchingProviders,
handleLogin
} = useOAuthProviders()
const loadingPasskey = ref(false)
const handlePasskeyLogin = async () => {
if (!webAuthnService.isSupported()) {
ElMessage.warning(t('auth.passkey_not_supported'))
return
}
loadingPasskey.value = true
try {
const res = await webAuthnService.login()
if (res.success) {
// Passkey 登录逻辑与 OAuth 回调成功后一致
await authUserStore.setUserInfo(res.userInfo)
// 设备指纹 key (Passkey 登录暂时可以不强制要求 deviceKey,或者由后端生成返回)
if (res.deviceKey) {
localStorage.setItem('device_key', res.deviceKey)
}
ElMessage.success(t('common.success'))
window.location.href = '/'
}
} catch (error) {
console.error('Passkey login failed:', error)
// 错误在 Axios 拦截器中处理,这里仅做状态清理
} finally {
loadingPasskey.value = false
}
}
</script>
@@ -0,0 +1,177 @@
<template>
<div class="security-wrapper">
<div class="tab-card-wrapper">
<div class="text-center mb-30">
<h2>{{ $t('security.passkey_title') }}</h2>
<p class="text-secondary">{{ $t('security.passkey_desc') }}</p>
</div>
<div class="security-container">
<div class="security-header-actions">
<h3>{{ $t('security.management') }}</h3>
<el-button type="primary" @click="showAddDialog = true">
<el-icon><Plus /></el-icon> {{ $t('security.add_passkey') }}
</el-button>
</div>
<!-- 全局加载状态 -->
<div v-if="loading && credentials.length === 0" class="flex-column flex-center min-h-200 text-secondary">
<el-icon class="is-loading mb-20 text-primary" :size="48"><Loading /></el-icon>
<p class="text-16 ls-1">{{ $t('common.loading') }}</p>
</div>
<!-- 空状态 -->
<div v-else-if="credentials.length === 0" class="empty-state mt-40">
<el-empty :description="$t('security.no_passkeys')" />
</div>
<!-- 列表区域 (有数据情况) -->
<div v-else class="credential-list mt-20">
<el-table :data="credentials" style="width: 100%">
<el-table-column :label="$t('security.credential_name')" min-width="150">
<template #default="{ row }">
<div class="flex-align-center">
<span>{{ row.name || $t('security.default_name') }}</span>
</div>
</template>
</el-table-column>
<el-table-column :label="$t('security.created_at')" min-width="150">
<template #default="{ row }">
{{ formatDate(row.created_at) }}
</template>
</el-table-column>
<el-table-column :label="$t('security.last_used')" min-width="150">
<template #default="{ row }">
{{ row.last_used_at ? formatDate(row.last_used_at) : '-' }}
</template>
</el-table-column>
<el-table-column align="right" width="100">
<template #default="{ row }">
<el-button
type="danger"
link
:icon="Delete"
@click="handleDelete(row)"
/>
</template>
</el-table-column>
</el-table>
</div>
<!-- 添加 Passkey 弹窗 -->
<el-dialog
v-model="showAddDialog"
:title="$t('security.add_dialog_title')"
width="400px"
append-to-body
>
<el-form :model="addForm" @submit.prevent="handleAdd">
<el-form-item :label="$t('security.credential_name')">
<el-input
v-model="addForm.name"
:placeholder="$t('security.default_name')"
clearable
/>
<p class="text-secondary text-12 mt-8">{{ $t('security.add_dialog_tip') }}</p>
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showAddDialog = false">{{ $t('common.cancel') }}</el-button>
<el-button type="primary" :loading="adding" @click="handleAdd">
{{ $t('common.confirm') }}
</el-button>
</span>
</template>
</el-dialog>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { Plus, Delete, Cpu, CircleCheck, Loading } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { useI18n } from 'vue-i18n'
import { webAuthnService } from '@/features/auth/service/webAuthnService'
const { t } = useI18n()
const loading = ref(true)
const adding = ref(false)
const showAddDialog = ref(false)
const credentials = ref([])
const addForm = ref({
name: ''
})
const fetchCredentials = async () => {
loading.value = true
try {
const res = await webAuthnService.listCredentials()
if (res.success) {
credentials.value = res.credentials
}
} catch (error) {
console.error('Failed to fetch credentials:', error)
} finally {
loading.value = false
}
}
const handleAdd = async () => {
if (!webAuthnService.isSupported()) {
ElMessage.error(t('auth.passkey_not_supported'))
return
}
adding.value = true
try {
const name = addForm.value.name.trim() || t('security.default_name')
const res = await webAuthnService.register(name)
if (res.success) {
ElMessage.success(t('security.register_success'))
showAddDialog.value = false
addForm.value.name = ''
await fetchCredentials()
}
} catch (error) {
console.error('Passkey registration failed:', error)
// Error notification is handled by requester or service level usually
} finally {
adding.value = false
}
}
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm(
t('security.delete_confirm'),
t('common.warning'),
{
confirmButtonText: t('common.confirm'),
cancelButtonText: t('common.cancel'),
type: 'warning',
}
)
const res = await webAuthnService.deleteCredential(row.id)
if (res.success) {
ElMessage.success(t('common.success'))
await fetchCredentials()
}
} catch (error) {
if (error !== 'cancel') {
console.error('Failed to delete credential:', error)
}
}
}
const formatDate = (timestamp) => {
if (!timestamp) return '-'
return new Date(timestamp).toLocaleString()
}
onMounted(() => {
fetchCredentials()
})
</script>
@@ -39,6 +39,10 @@
<el-menu-item index="tools">
<el-icon><Tools /></el-icon><template #title><span>{{ $t('menu.tools') }}</span></template>
</el-menu-item>
<el-menu-item index="security">
<el-icon><iconFingerprint /></el-icon><template #title><span>{{ $t('menu.security') }}</span></template>
</el-menu-item>
</el-menu>
<!-- 底部操作按钮 -->
@@ -113,6 +117,9 @@
<el-menu-item index="tools">
<el-icon><Tools /></el-icon><span>{{ $t('menu.tools') }}</span>
</el-menu-item>
<el-menu-item index="security">
<el-icon><iconFingerprint /></el-icon><span>{{ $t('menu.security') }}</span>
</el-menu-item>
</el-menu>
<div class="sidebar-footer" style="display: flex; gap: 15px; justify-content: center; padding: 20px 0;">
@@ -135,6 +142,7 @@ import {
Cloudy, Sunny, Moon, SwitchButton, Fold, Expand, Tools
} from '@element-plus/icons-vue'
import iconLocales from '@/shared/components/icons/iconLocales.vue'
import iconFingerprint from '@/shared/components/icons/iconFingerprint.vue'
import { useLayoutStore } from '@/shared/stores/layoutStore'
import { useThemeStore } from '@/shared/stores/themeStore'
import { useAuthUserStore } from '@/features/auth/store/authUserStore'
+7 -1
View File
@@ -39,6 +39,11 @@
<UtilityTools />
</div>
<!-- 视图:安全设置 -->
<div v-if="activeTab === 'security'" class="view-container">
<SecuritySettings />
</div>
</el-main>
</el-container>
</el-container>
@@ -59,13 +64,14 @@ const DataExport = createAsyncComponent(() => import('@/features/migration/vi
const DataImport = createAsyncComponent(() => import('@/features/migration/views/dataImport.vue'))
const DataBackup = createAsyncComponent(() => import('@/features/backup/views/dataBackup.vue'))
const UtilityTools = createAsyncComponent(() => import('@/features/tools/views/utilityTools.vue'))
const SecuritySettings = createAsyncComponent(() => import('@/features/auth/views/securitySettings.vue'))
const sessionKey = 'activeTab'
const activeTab = ref(sessionStorage.getItem(sessionKey) || 'vault')
onMounted(() => {
// 强制校准:如果状态非法,回归默认视图
const validTabs = ['vault', 'add-vault-scan', 'add-vault-manual', 'migration-export', 'migration-import', 'backups', 'tools']
const validTabs = ['vault', 'add-vault-scan', 'add-vault-manual', 'migration-export', 'migration-import', 'backups', 'tools', 'security']
if (!validTabs.includes(activeTab.value)) {
activeTab.value = 'vault'
}
+24
View File
@@ -41,6 +41,7 @@
"backup": "Cloud Backup",
"migration": "Migration",
"tools": "Toolbox",
"security": "PassKey",
"settings": "Settings",
"logout": "Sign Out"
},
@@ -485,6 +486,8 @@
"login_with": "Login with {provider}",
"privacy_title": "Privacy & Security Note",
"privacy_desc": "The system validates identity based on the OAuth 2.0 protocol and will never capture, log, or transmit your password.",
"passkey_login": "Login with Passkey",
"passkey_not_supported": "Your browser or device does not support Passkeys",
"secure_login_in_progress": "Secure login in progress",
"secure_login_sub": "Please wait, exchanging security credentials with {provider}...",
"auth_failed_title": "Authorization Failed",
@@ -564,6 +567,10 @@
"webdav_download_failed": "WebDAV fallback download failed",
"webdav_delete_failed": "WebDAV fallback delete failed",
"unsupported_provider": "Unsupported provider",
"webauthn_challenge_missing": "Authentication challenge expired, please refresh and try again",
"webauthn_registration_failed": "Passkey registration failed",
"webauthn_login_failed": "Passkey login failed",
"passkey_not_found": "No matching Passkey credential found",
"health_check_failed": "Security vulnerability detected! System access is locked."
},
"healthCheck": {
@@ -649,5 +656,22 @@
"copy_unsupported": "Your browser doesn't support automatic copying, please manually select it.",
"copy_success": "Key copied to clipboard",
"connection_failed": "Cannot connect to server for health check"
},
"security": {
"management": "Passkey Management",
"passkey_title": "Passkey Management",
"passkey_desc": "Enable passwordless login using biometrics such as fingerprint or face recognition for a more secure and convenient access experience.",
"passkey_list": "Registered Passkeys",
"add_passkey": "Add Passkey",
"no_passkeys": "You haven't registered any Passkeys yet",
"credential_name": "Device Name",
"created_at": "Added",
"last_used": "Last Used",
"delete_confirm": "Are you sure you want to remove this Passkey? You will no longer be able to login directly with this device.",
"add_dialog_title": "Add New Passkey",
"add_dialog_tip": "Give your device a recognizable name (e.g., My iPhone 15).",
"default_name": "My New Device",
"registering": "Communicating with hardware...",
"register_success": "Passkey registered successfully!"
}
}
+24
View File
@@ -41,6 +41,7 @@
"backup": "云端备份",
"migration": "数据迁移",
"tools": "实用工具",
"security": "通行密钥",
"settings": "系统设置",
"logout": "退出登录"
},
@@ -486,6 +487,8 @@
"login_with": "通过 {provider} 授权登录",
"privacy_title": "安全隐私提示",
"privacy_desc": "系统基于 OAuth 2.0 协议验证身份,绝不会获取、记录或传输您的密码信息。",
"passkey_login": "通过通行密钥登录",
"passkey_not_supported": "您的浏览器或设备不支持通行密钥",
"secure_login_in_progress": "正在安全登录中",
"secure_login_sub": "请稍候,系统正在与 {provider} 交换安全凭证...",
"auth_failed_title": "授权失败",
@@ -565,6 +568,10 @@
"webdav_download_failed": "WebDAV 备选方案下载失败",
"webdav_delete_failed": "WebDAV 备选方案删除失败",
"unsupported_provider": "不支持的提供商",
"webauthn_challenge_missing": "身份验证挑战已过期,请刷新页面重试",
"webauthn_registration_failed": "通行密钥注册失败",
"webauthn_login_failed": "通行密钥登录失败",
"passkey_not_found": "未找到对应的通行密钥凭证",
"health_check_failed": "环境配置漏洞警告,系统已被锁定!"
},
"healthCheck": {
@@ -650,5 +657,22 @@
"copy_unsupported": "您的浏览器不支持自动复制,请手动选中复制。",
"copy_success": "安全密钥已复制到剪贴板",
"connection_failed": "无法连接到服务端进行安全校验"
},
"security": {
"management": "通行密钥管理",
"passkey_title": "通行密钥管理",
"passkey_desc": "通过生物识别(如指纹或面容)实现无密码登录,为您提供更安全、更快速的账户访问体验。",
"passkey_list": "已绑定的通行密钥",
"add_passkey": "添加通行密钥",
"no_passkeys": "您尚未绑定任何通行密钥",
"credential_name": "设备名称",
"created_at": "绑定时间",
"last_used": "最后使用",
"delete_confirm": "确定要移除这个通行密钥吗?移除后您将无法使用该设备直接登录。",
"add_dialog_title": "添加新的通行密钥",
"add_dialog_tip": "建议为您的设备起一个容易辨识的名称(如:我的 iPhone 15)。",
"default_name": "我的新设备",
"registering": "正在与硬件通信...",
"register_success": "通行密钥绑定成功!"
}
}
@@ -0,0 +1,8 @@
<template>
<svg fill="currentColor" width="1em" height="1em" viewBox="0 0 512 512" xmlns="http://www.w3.org/2000/svg">
<path d="M390.42,75.28a10.45,10.45,0,0,1-5.32-1.44C340.72,50.08,302.35,40,256.35,40c-45.77,0-89.23,11.28-128.76,33.84C122,77,115.11,74.8,111.87,69a12.4,12.4,0,0,1,4.63-16.32A281.81,281.81,0,0,1,256.35,16c49.23,0,92.23,11.28,139.39,36.48a12,12,0,0,1,4.85,16.08A11.3,11.3,0,0,1,390.42,75.28Zm-330.79,126a11.73,11.73,0,0,1-6.7-2.16,12.26,12.26,0,0,1-2.78-16.8c22.89-33.6,52-60,86.69-78.48C209.42,65,302.35,64.72,375.16,103.6c34.68,18.48,63.8,44.64,86.69,78a12.29,12.29,0,0,1-2.78,16.8,11.26,11.26,0,0,1-16.18-2.88c-20.8-30.24-47.15-54-78.36-70.56-66.34-35.28-151.18-35.28-217.29.24-31.44,16.8-57.79,40.8-78.59,71A10,10,0,0,1,59.63,201.28ZM204.1,491a10.66,10.66,0,0,1-8.09-3.6C175.9,466.48,165,453,149.55,424c-16-29.52-24.27-65.52-24.27-104.16,0-71.28,58.71-129.36,130.84-129.36S387,248.56,387,319.84a11.56,11.56,0,1,1-23.11,0c0-58.08-48.32-105.36-107.72-105.36S148.4,261.76,148.4,319.84c0,34.56,7.39,66.48,21.49,92.4,14.8,27.6,25,39.36,42.77,58.08a12.67,12.67,0,0,1,0,17A12.44,12.44,0,0,1,204.1,491Zm165.75-44.4c-27.51,0-51.78-7.2-71.66-21.36a129.1,129.1,0,0,1-55-105.36,11.57,11.57,0,1,1,23.12,0,104.28,104.28,0,0,0,44.84,85.44c16.41,11.52,35.6,17,58.72,17a147.41,147.41,0,0,0,24-2.4c6.24-1.2,12.25,3.12,13.4,9.84a11.92,11.92,0,0,1-9.47,13.92A152.28,152.28,0,0,1,369.85,446.56ZM323.38,496a13,13,0,0,1-3-.48c-36.76-10.56-60.8-24.72-86-50.4-32.37-33.36-50.16-77.76-50.16-125.28,0-38.88,31.9-70.56,71.19-70.56s71.2,31.68,71.2,70.56c0,25.68,21.5,46.56,48.08,46.56s48.08-20.88,48.08-46.56c0-90.48-75.13-163.92-167.59-163.92-65.65,0-125.75,37.92-152.79,96.72-9,19.44-13.64,42.24-13.64,67.2,0,18.72,1.61,48.24,15.48,86.64,2.32,6.24-.69,13.2-6.7,15.36a11.34,11.34,0,0,1-14.79-7,276.39,276.39,0,0,1-16.88-95c0-28.8,5.32-55,15.72-77.76,30.75-67,98.94-110.4,173.6-110.4,105.18,0,190.71,84.24,190.71,187.92,0,38.88-31.9,70.56-71.2,70.56s-71.2-31.68-71.2-70.56C303.5,293.92,282,273,255.42,273s-48.08,20.88-48.08,46.56c0,41,15.26,79.44,43.23,108.24,22,22.56,43,35,75.59,44.4,6.24,1.68,9.71,8.4,8.09,14.64A11.39,11.39,0,0,1,323.38,496Z"/>
</svg>
</template>
<script>
export default { name: 'iconFingerprint' }
</script>