feat: 支持通过通行密钥登录
This commit is contained in:
1 parent
7356113f89
commit
ed91e1943a
19 files changed
+759
-9
No files matched your search
File renamed without changes.
@@ -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/
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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' });
|
||||
}
|
||||
};
|
||||
@@ -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'
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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!"
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in new issue
Block a user