diff --git a/README.md b/README.md index 915e709..d44e6bf 100644 --- a/README.md +++ b/README.md @@ -104,7 +104,6 @@ curl "https://api.telegram.org/bot<你的BotToken>/setWebhook?url=https://<你 | :--- | :--- | :--- | | `CLOUDFLARE_ACCOUNT_ID` | 你的 CF 账户 ID | `a1b2c3d4e5f6g7h8i9j0` (CF 面板的概述页右下角) | | `CLOUDFLARE_API_TOKEN` | CF 部署令牌 [前往获取](https://dash.cloudflare.com/profile/api-tokens)
点击“创建令牌” -> “创建自定义令牌”
权限 1: **账户** -> **D1** -> **编辑**
权限 2: **账户** -> **Workers 脚本** -> **编辑** | `r4_xyz...` | -| `CLOUDFLARE_D1_DATABASE_ID` | D1 数据库 ID | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` | #### 🔐 必填:系统安全参数 @@ -161,15 +160,14 @@ cd 2fauth-worker cd frontend && npm install cd ../backend && npm install -# 2. 在 backend 目录,修改 example.dev.vars 为 .dev.vars, 按说明写入开发用的测试密钥 +# 2. 在 backend 目录,复制 example.dev.vars 为 .dev.vars, 按说明写入开发用的测试密钥 +cp example.dev.vars .dev.vars # 3. 初始化本地的 SQLite Sandbox 数据库 -cd backend npx wrangler d1 execute 2fauth-db-dev --local --env dev --file=schema.sql # 4. 终端启动应用 -```bash -cd backend && npm run dev +npm run dev ``` --- diff --git a/backend/package.json b/backend/package.json index 7eb5516..369b22a 100644 --- a/backend/package.json +++ b/backend/package.json @@ -6,14 +6,18 @@ "deploy": "wrangler deploy --minify" }, "dependencies": { - "aws4fetch": "^1.0.19", - "drizzle-orm": "^0.30.9", - "hono": "^4.11.10", + "aws4fetch": "^1.0.20", + "drizzle-orm": "^0.45.1", + "hono": "^4.12.3", "webdav": "^5.9.0" }, "devDependencies": { "@cloudflare/workers-types": "^4.20260226.1", - "drizzle-kit": "^0.20.14", - "wrangler": "^3.0.0" + "drizzle-kit": "^0.31.9", + "typescript": "^5.9.3", + "wrangler": "^4.69.0" + }, + "overrides": { + "esbuild": ">=0.25.0" } -} \ No newline at end of file +} diff --git a/backend/schema.sql b/backend/schema.sql index 34ea6a7..4340f09 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -32,4 +32,14 @@ CREATE TABLE IF NOT EXISTS backup_providers ( -- 索引 CREATE INDEX IF NOT EXISTS idx_vault_service ON vault(service); -CREATE INDEX IF NOT EXISTS idx_vault_created_at ON vault(created_at DESC); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_vault_created_at ON vault(created_at DESC); + +-- Remove any existing duplicates before enforcing unique constraint; keep the earliest +DELETE FROM vault +WHERE rowid NOT IN ( + SELECT MIN(rowid) + FROM vault + GROUP BY lower(service), lower(account) +); + +CREATE UNIQUE INDEX IF NOT EXISTS vault_service_account_uq ON vault(lower(service), lower(account)); \ No newline at end of file diff --git a/backend/src/features/auth/providers/baseOAuthProvider.ts b/backend/src/features/auth/providers/baseOAuthProvider.ts index 4619508..11935ac 100644 --- a/backend/src/features/auth/providers/baseOAuthProvider.ts +++ b/backend/src/features/auth/providers/baseOAuthProvider.ts @@ -14,11 +14,10 @@ export abstract class BaseOAuthProvider { constructor(env: EnvBindings) { this.env = env; } - abstract readonly id: string; abstract readonly name: string; abstract readonly color: string; // 按钮背景色 - abstract readonly icon: string; // 前端图标 SVG 代码 (完整 标签) + abstract readonly icon: string; // 前端 Vue 图标组件名,如 'iconGithub' abstract readonly whitelistFields: string[]; // 新增:定义该 Provider 支持哪些白名单字段 (如 ['email', 'username']) // 1. 获取授权跳转链接 diff --git a/backend/src/features/auth/providers/cloudflareAccessProvider.ts b/backend/src/features/auth/providers/cloudflareAccessProvider.ts index a79306f..8d2aee0 100644 --- a/backend/src/features/auth/providers/cloudflareAccessProvider.ts +++ b/backend/src/features/auth/providers/cloudflareAccessProvider.ts @@ -32,7 +32,7 @@ export class CloudflareAccessProvider extends BaseOAuthProvider { readonly id = 'cloudflare'; readonly name = 'Cloudflare Access'; readonly color = '#F38020'; - readonly icon = ''; + readonly icon = 'iconCloudflare'; readonly whitelistFields = ['email']; // Cloudflare Access 主要基于邮箱验证 constructor(env: EnvBindings) { @@ -43,12 +43,12 @@ export class CloudflareAccessProvider extends BaseOAuthProvider { private getBaseUrl(): string { const orgDomain = this.env.OAUTH_CLOUDFLARE_ORG_DOMAIN; if (!orgDomain) { - throw new AppError('Cloudflare Access configuration incomplete: Missing OAUTH_CLOUDFLARE_ORG_DOMAIN', 500); + throw new AppError('Cloudflare Access configuration incomplete: Missing OAUTH_CLOUDFLARE_ORG_DOMAIN', 500); } let baseUrl = orgDomain.replace(/\/$/, ''); if (!baseUrl.startsWith('http')) { - baseUrl = `https://${baseUrl}`; + baseUrl = `https://${baseUrl}`; } return baseUrl; } @@ -58,9 +58,9 @@ export class CloudflareAccessProvider extends BaseOAuthProvider { const redirectUri = this.env.OAUTH_CLOUDFLARE_REDIRECT_URI; if (!clientId || !redirectUri) { - throw new AppError('Cloudflare Access configuration incomplete', 500); + throw new AppError('Cloudflare Access configuration incomplete', 500); } - + const baseUrl = this.getBaseUrl(); const { verifier, challenge } = await generatePKCE(); @@ -74,7 +74,7 @@ export class CloudflareAccessProvider extends BaseOAuthProvider { code_challenge_method: 'S256' }); - const url = `${baseUrl}/cdn-cgi/access/sso/oidc/${clientId}/authorization?${params.toString()}`; + const url = `${baseUrl}/cdn-cgi/access/sso/oidc/${clientId}/authorization?${params.toString()}`; return { url, codeVerifier: verifier }; } @@ -91,7 +91,7 @@ export class CloudflareAccessProvider extends BaseOAuthProvider { const redirectUri = this.env.OAUTH_CLOUDFLARE_REDIRECT_URI; if (!clientId || !clientSecret || !redirectUri) { - throw new AppError('Cloudflare Access configuration incomplete', 500); + throw new AppError('Cloudflare Access configuration incomplete', 500); } const baseUrl = this.getBaseUrl(); @@ -113,10 +113,10 @@ export class CloudflareAccessProvider extends BaseOAuthProvider { }); if (!tokenResponse.ok) { - // 增加更详细的错误日志,方便调试 - const errorText = await tokenResponse.text(); - console.error(`Cloudflare Access Token Error: ${tokenResponse.status} - ${errorText}`); - throw new AppError(`Cloudflare Access Token Exchange failed: ${tokenResponse.status}`, 502); + // 增加更详细的错误日志,方便调试 + const errorText = await tokenResponse.text(); + console.error(`Cloudflare Access Token Error: ${tokenResponse.status} - ${errorText}`); + throw new AppError(`Cloudflare Access Token Exchange failed: ${tokenResponse.status}`, 502); } const tokenData: any = await tokenResponse.json(); diff --git a/backend/src/features/auth/providers/giteeProvider.ts b/backend/src/features/auth/providers/giteeProvider.ts index cd3d99c..bbb8a35 100644 --- a/backend/src/features/auth/providers/giteeProvider.ts +++ b/backend/src/features/auth/providers/giteeProvider.ts @@ -4,8 +4,8 @@ import { EnvBindings, AppError } from '@/app/config'; export class GiteeProvider extends BaseOAuthProvider { readonly id = 'gitee'; readonly name = 'Gitee'; - readonly color = '#c71d23'; - readonly icon = ''; + readonly color = '#C71D23'; + readonly icon = 'iconGitee'; readonly whitelistFields = ['email']; constructor(env: EnvBindings) { diff --git a/backend/src/features/auth/providers/githubProvider.ts b/backend/src/features/auth/providers/githubProvider.ts index 50fe087..1c1268e 100644 --- a/backend/src/features/auth/providers/githubProvider.ts +++ b/backend/src/features/auth/providers/githubProvider.ts @@ -5,7 +5,7 @@ export class GitHubProvider extends BaseOAuthProvider { readonly id = 'github'; readonly name = 'GitHub'; readonly color = '#24292e'; - readonly icon = ''; + readonly icon = 'iconGithub'; readonly whitelistFields = ['email']; constructor(env: EnvBindings) { diff --git a/backend/src/features/auth/providers/googleProvider.ts b/backend/src/features/auth/providers/googleProvider.ts index 1f45d4b..d959a9e 100644 --- a/backend/src/features/auth/providers/googleProvider.ts +++ b/backend/src/features/auth/providers/googleProvider.ts @@ -5,8 +5,7 @@ export class GoogleProvider extends BaseOAuthProvider { readonly id = 'google'; readonly name = 'Google'; readonly color = '#33A854'; - // Google "G" Logo SVG - readonly icon = ''; + readonly icon = 'iconGoogle'; readonly whitelistFields = ['email']; // Google 主要基于邮箱验证 constructor(env: EnvBindings) { @@ -65,8 +64,8 @@ export class GoogleProvider extends BaseOAuthProvider { }); if (!tokenResponse.ok) { - const errText = await tokenResponse.text(); - throw new AppError(`Google Token Exchange failed: ${tokenResponse.status} - ${errText}`, 502); + const errText = await tokenResponse.text(); + throw new AppError(`Google Token Exchange failed: ${tokenResponse.status} - ${errText}`, 502); } const tokenData: any = await tokenResponse.json(); diff --git a/backend/src/features/auth/providers/nodeLocProvider.ts b/backend/src/features/auth/providers/nodeLocProvider.ts index 1d955ac..347dee0 100644 --- a/backend/src/features/auth/providers/nodeLocProvider.ts +++ b/backend/src/features/auth/providers/nodeLocProvider.ts @@ -5,7 +5,7 @@ export class NodeLocProvider extends BaseOAuthProvider { readonly id = 'nodeloc'; readonly name = 'NodeLoc'; readonly color = '#475569'; - readonly icon = ''; + readonly icon = 'iconNodeloc'; readonly whitelistFields = ['email']; constructor(env: EnvBindings) { diff --git a/backend/src/features/auth/providers/telegramProvider.ts b/backend/src/features/auth/providers/telegramProvider.ts index c46f015..f4ac8a0 100644 --- a/backend/src/features/auth/providers/telegramProvider.ts +++ b/backend/src/features/auth/providers/telegramProvider.ts @@ -5,7 +5,7 @@ export class TelegramProvider extends BaseOAuthProvider { readonly id = 'telegram'; readonly name = 'Telegram'; readonly color = '#54a9eb'; - readonly icon = ''; + readonly icon = 'iconTelegram'; readonly whitelistFields = ['id']; // Telegram 不保证提供 email constructor(env: EnvBindings) { diff --git a/backend/src/features/vault/vaultService.ts b/backend/src/features/vault/vaultService.ts index 3620161..3bf0445 100644 --- a/backend/src/features/vault/vaultService.ts +++ b/backend/src/features/vault/vaultService.ts @@ -53,13 +53,29 @@ export class VaultService { /** * 创建账户 */ + // normalize a service+account pair for comparison + private normalizeSignature(service: string, account: string) { + return `${(service || '').toString().trim().toLowerCase()}:${(account||'').toString().trim().toLowerCase()}`; + } + async createAccount(userId: string, data: any) { - const { service, account, category, secret, digits, period, algorithm } = data; + let { service, account, category, secret, digits, period, algorithm } = data; if (!service || !account || !secret || !validateBase32Secret(secret)) { throw new AppError('Invalid account data or secret format', 400); } + // 入库清洗:去掉 account 中可能带有的 Issuer: 前缀 + if (typeof account === 'string' && account.includes(':')) { + account = account.split(':').pop()?.trim() || account; + } + + // duplicate check (case‑insensitive & trimmed) using repository helper + const existing = await this.repository.findByServiceAccount(service, account); + if (existing) { + throw new AppError('Account already exists', 409); + } + const normalizedSecret = secret.replace(/\s/g, '').toUpperCase(); const encryptedSecret = await encryptField(normalizedSecret, this.encryptionKey); @@ -81,12 +97,17 @@ export class VaultService { * 更新账户 */ async updateAccount(id: string, data: any) { - const { service, account, category, secret, digits, period, algorithm } = data; + let { service, account, category, secret, digits, period, algorithm } = data; if (!service || !account) { throw new AppError('Service and account are required', 400); } + // 入库清洗:去掉 account 中可能带有的 Issuer: 前缀 + if (typeof account === 'string' && account.includes(':')) { + account = account.split(':').pop()?.trim() || account; + } + // secret 可选:若未传则保留数据库中已有的加密值 let encryptedSecret: string; if (secret) { @@ -211,15 +232,21 @@ export class VaultService { validAccounts = data.accounts; // 格式 B: 标准导出格式 (data.secrets[] with issuer field) } else if (Array.isArray(data.secrets)) { - validAccounts = data.secrets.map((item: any) => ({ - service: item.issuer || item.service || item.name || 'Unknown', - account: item.account || item.label || '', - secret: item.secret, - algorithm: item.algorithm || 'SHA1', - digits: item.digits || 6, - period: item.period || 30, - category: item.category || '', - })); + validAccounts = data.secrets.map((item: any) => { + let account = item.account || item.label || ''; + if (typeof account === 'string' && account.includes(':')) { + account = account.split(':').pop()?.trim() || account; + } + return { + service: item.issuer || item.service || item.name || 'Unknown', + account, + secret: item.secret, + algorithm: item.algorithm || 'SHA1', + digits: item.digits || 6, + period: item.period || 30, + category: item.category || '', + }; + }); // 格式 C: 旧版 2FAuth 开源项目导出 (data.data[]) } else if (data.app && data.app.includes('2fauth') && Array.isArray(data.data)) { validAccounts = data.data.map((item: any) => ({ ...item, category: '' })); @@ -228,29 +255,41 @@ export class VaultService { validAccounts = data; // 格式 E: 老版 2FAS JSON (data.services[]) } else if (data.services) { - validAccounts = data.services.map((s: any) => ({ - service: s.otp?.issuer || s.name || s.service, - account: s.otp?.account || s.account || s.login || '', - secret: s.secret, - algorithm: s.otp?.algorithm || s.algorithm || 'SHA1', - digits: s.otp?.digits || s.digits || 6, - period: s.otp?.period || s.period || 30, - category: s.group || '', - })); + validAccounts = data.services.map((s: any) => { + let account = s.otp?.account || s.account || s.login || ''; + if (typeof account === 'string' && account.includes(':')) { + account = account.split(':').pop()?.trim() || account; + } + return { + service: s.otp?.issuer || s.name || s.service, + account, + secret: s.secret, + algorithm: s.otp?.algorithm || s.algorithm || 'SHA1', + digits: s.otp?.digits || s.digits || 6, + period: s.otp?.period || s.period || 30, + category: s.group || '', + }; + }); } } else if (type === '2fas') { const data = JSON.parse(content); if (Array.isArray(data.services)) { - validAccounts = data.services.map((s: any) => ({ - // 2FAS backup 格式 schemaVersion 4:字段在 otp 子对象中 - service: s.otp?.issuer || s.name || s.otp?.issuer || 'Unknown', - account: s.otp?.account || s.account || s.username || '', - secret: s.secret || '', - algorithm: (s.otp?.algorithm || s.algorithm || 'SHA1').toUpperCase(), - digits: s.otp?.digits || s.digits || 6, - period: s.otp?.period || s.period || 30, - category: s.group || s.category || '', - })); + validAccounts = data.services.map((s: any) => { + let account = s.otp?.account || s.account || s.username || ''; + if (typeof account === 'string' && account.includes(':')) { + account = account.split(':').pop()?.trim() || account; + } + return { + // 2FAS backup 格式 schemaVersion 4:字段在 otp 子对象中 + service: s.otp?.issuer || s.name || s.otp?.issuer || 'Unknown', + account, + secret: s.secret || '', + algorithm: (s.otp?.algorithm || s.algorithm || 'SHA1').toUpperCase(), + digits: s.otp?.digits || s.digits || 6, + period: s.otp?.period || s.period || 30, + category: s.group || s.category || '', + }; + }); } } else if (type === 'text') { const lines = content.split('\n').filter((line: string) => line.trim()); @@ -271,16 +310,21 @@ export class VaultService { if (type === 'raw') validAccounts = JSON.parse(content); - // 获取现有以去重 + // 获取现有以去重 (使用小写/去空格以避免大小写差异导致重复) const existingItems = await this.repository.findAll(); - const existingSet = new Set(existingItems.map((row: any) => `${row.service}:${row.account}`)); + const existingSet = new Set(existingItems.map((row: any) => this.normalizeSignature(row.service, row.account))); - const uniqueAccountsToInsert = []; - const seenInBatch = new Set(); + const uniqueAccountsToInsert: any[] = []; + const seenInBatch = new Set(); for (const acc of validAccounts) { if (acc.service && acc.account && validateBase32Secret(acc.secret)) { - const signature = `${acc.service}:${acc.account}`; + // 统一清洗:去掉 account 中可能带有的 Issuer: 前缀 + if (typeof acc.account === 'string' && acc.account.includes(':')) { + acc.account = acc.account.split(':').pop()?.trim() || acc.account; + } + + const signature = this.normalizeSignature(acc.service, acc.account); if (!existingSet.has(signature) && !seenInBatch.has(signature)) { uniqueAccountsToInsert.push(acc); seenInBatch.add(signature); diff --git a/backend/src/shared/db/db.ts b/backend/src/shared/db/db.ts index 03f845e..b561eda 100644 --- a/backend/src/shared/db/db.ts +++ b/backend/src/shared/db/db.ts @@ -61,10 +61,13 @@ export async function batchInsertVaultItems( }; })); - // 2. 逐行写入:D1 每条 SQL 最多 100 个绑定参数,vault 表约 12 列 - // 单行插入 = 12 变量,远低于限制,彻底规避超限问题 - for (const item of preparedItems) { - await dbClient.insert(schema.vault).values(item).run(); + // 2. 批量写入:使用 D1 的 batch 功能避免连接超时。 + // 分批写入,每批最多 50 条。 + const CHUNK_SIZE = 50; + for (let i = 0; i < preparedItems.length; i += CHUNK_SIZE) { + const chunk = preparedItems.slice(i, i + CHUNK_SIZE); + const stmts = chunk.map(item => dbClient.insert(schema.vault).values(item)); + await dbClient.batch(stmts as any); } diff --git a/backend/src/shared/db/repositories/vaultRepository.ts b/backend/src/shared/db/repositories/vaultRepository.ts index 9c753dd..2db6289 100644 --- a/backend/src/shared/db/repositories/vaultRepository.ts +++ b/backend/src/shared/db/repositories/vaultRepository.ts @@ -90,6 +90,24 @@ export class VaultRepository { return result[0]; } + /** + * 根据 service/account 查找记录 (大小写不敏感,自动 trim) + */ + async findByServiceAccount(service: string, account: string): Promise { + // 使用 lower() 函数进行数据库级别的大小写不敏感比较 + const normalizedService = service.trim().toLowerCase(); + const normalizedAccount = account.trim().toLowerCase(); + const result = await this.db + .select() + .from(vault) + .where( + sql`lower(${vault.service}) = ${normalizedService}`, + sql`lower(${vault.account}) = ${normalizedAccount}` + ) + .limit(1); + return result[0]; + } + /** * 创建一个新 item */ diff --git a/backend/src/shared/db/schema.ts b/backend/src/shared/db/schema.ts index a16bf28..900c0e5 100644 --- a/backend/src/shared/db/schema.ts +++ b/backend/src/shared/db/schema.ts @@ -1,6 +1,8 @@ import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core'; // 1. 金库项目表 (Vault Items -> mapped to 'vault' table in D1) +// 注意:唯一索引在 D1 上需手动创建,通过迁移命令运行一次即可。 +// 该项目使用 SQLite schema 定义仅描述列,复杂索引/约束由数据库层管理。 export const vault = sqliteTable('vault', { id: text('id').primaryKey(), // UUID service: text('service').notNull(), diff --git a/frontend/package.json b/frontend/package.json index e9bcbaf..bc0a68e 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -10,21 +10,26 @@ }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", - "@tanstack/vue-query": "^5.0.0", - "element-plus": "^2.13.2", + "@tanstack/vue-query": "^5.66.0", + "element-plus": "^2.9.5", "jsqr": "^1.4.0", "pinia": "^3.0.4", - "qrcode": "^1.5.3", + "qrcode": "^1.5.4", + "scrypt-js": "^3.0.1", + "sql.js": "^1.10.3", "vue": "^3.5.25", - "vue-router": "^5.0.2" + "vue-router": "^5.0.3" }, "devDependencies": { "@playwright/test": "^1.58.2", - "@types/node": "^25.3.0", + "@types/node": "^22.13.5", "@vitejs/plugin-vue": "^6.0.2", "unplugin-auto-import": "^0.19.0", - "unplugin-vue-components": "^0.26.0", - "vite": "^6.0.0", + "unplugin-vue-components": "^0.28.0", + "vite": "^6.2.0", "vite-plugin-pwa": "^0.21.1" + }, + "overrides": { + "serialize-javascript": "^7.0.3" } } diff --git a/frontend/public/pwa-192x192.png b/frontend/public/pwa-192x192.png index 6f273da..61d5465 100644 Binary files a/frontend/public/pwa-192x192.png and b/frontend/public/pwa-192x192.png differ diff --git a/frontend/public/pwa-512x512.png b/frontend/public/pwa-512x512.png index 24867f2..6565265 100644 Binary files a/frontend/public/pwa-512x512.png and b/frontend/public/pwa-512x512.png differ diff --git a/frontend/public/sql-wasm.wasm b/frontend/public/sql-wasm.wasm new file mode 100755 index 0000000..b103da4 Binary files /dev/null and b/frontend/public/sql-wasm.wasm differ diff --git a/frontend/src/app/router.js b/frontend/src/app/router.js index 606f0b4..7d10e42 100644 --- a/frontend/src/app/router.js +++ b/frontend/src/app/router.js @@ -1,5 +1,5 @@ import { createRouter, createWebHistory } from 'vue-router' -import { useUserStore } from '@/features/auth/store/userStore' +import { useAuthUserStore } from '@/features/auth/store/authUserStore' const routes = [ { @@ -33,17 +33,17 @@ const router = createRouter({ // 路由守卫:拦截未登录用户的访问 router.beforeEach(async (to, from, next) => { - const userStore = useUserStore() + const authUserStore = useAuthUserStore() // 1. 检查内存中是否有用户信息 (乐观检查) - let isAuthenticated = !!(userStore.userInfo && userStore.userInfo.id) + let isAuthenticated = !!(authUserStore.userInfo && authUserStore.userInfo.id) // 2. 如果内存无状态,但目标页需要登录 OR 是仅游客页面(如登录页),尝试通过 Cookie 恢复会话 // (Option 1B: 即使访问登录页,也先确认是否已登录,防止误判) if (!isAuthenticated && (to.meta.requiresAuth || to.meta.guestOnly)) { try { - await userStore.fetchUserInfo() - isAuthenticated = !!(userStore.userInfo && userStore.userInfo.id) + await authUserStore.fetchUserInfo() + isAuthenticated = !!(authUserStore.userInfo && authUserStore.userInfo.id) } catch (e) { } } diff --git a/frontend/src/app/styles/main.css b/frontend/src/app/styles/main.css index ee5863d..610d91c 100644 --- a/frontend/src/app/styles/main.css +++ b/frontend/src/app/styles/main.css @@ -1165,4 +1165,11 @@ main { .upload-placeholder:hover { color: var(--el-color-primary); +} + +@media (max-width: 768px) { + /* 修复 el-dialog 在移动端固定宽溢出 */ + .el-dialog { + width: 90% !important; + } } \ No newline at end of file diff --git a/frontend/src/features/auth/composables/useOAuthCallback.js b/frontend/src/features/auth/composables/useOAuthCallback.js new file mode 100644 index 0000000..5576913 --- /dev/null +++ b/frontend/src/features/auth/composables/useOAuthCallback.js @@ -0,0 +1,124 @@ +import { ref, onMounted } from 'vue' +import { useRoute, useRouter } from 'vue-router' +import { useAuthUserStore } from '@/features/auth/store/authUserStore' +import { setIdbItem } from '@/shared/utils/idb' +import { authService } from '@/features/auth/service/authService' + +/** + * 处理 OAuth 授权重定向回来的回调逻辑 (验证、防伪造、Token 兑换) + */ +export function useOAuthCallback() { + const route = useRoute() + const router = useRouter() + const errorMsg = ref('') + const providerName = ref('身份提供商') + + onMounted(async () => { + const code = route.query.code + const state = route.query.state + const error = route.query.error + const hash = route.query.hash // Telegram 特有参数 + + // 1. 智能识别 Provider + let providerId = route.params.provider + if (!providerId) { + if (hash) providerId = 'telegram' + else providerId = localStorage.getItem('oauth_provider') || 'github' + } + + // 优化:优先从缓存读取提供商名称 + try { + const cached = localStorage.getItem('oauth_providers_cache') + if (cached) { + const providers = JSON.parse(cached) + const p = providers.find(x => x.id === providerId) + if (p) providerName.value = p.name + } else { + const res = await authService.getProviders() + const p = res.providers?.find(x => x.id === providerId) + if (p) providerName.value = p.name + } + } catch (e) { + // Ignore cache errors + } + + if (error) { + errorMsg.value = route.query.error_description || `您拒绝了 ${providerName.value} 的授权请求` + return + } + + // 2. 参数结构校验 (策略分发) + let payload = {} + + if (providerId === 'telegram') { + if (!hash) { + errorMsg.value = 'Telegram 登录缺少签名参数' + return + } + const savedState = localStorage.getItem('oauth_state') + payload = { ...route.query, state: savedState } + } else { + if (!code || !state) { + errorMsg.value = 'URL 缺少必要的授权凭证参数' + return + } + + // 防御 CSRF:必须检查 State 匹不匹配 + const savedState = localStorage.getItem('oauth_state') + if (!savedState || savedState !== state) { + errorMsg.value = '安全警告:State 校验失败,请求可能被篡改' + return + } + + const codeVerifier = localStorage.getItem('oauth_code_verifier') + payload = { code, state, codeVerifier } + } + + // 无论成功与否,用过一次立马清除防止重放 (Replay Attack) + localStorage.removeItem('oauth_state') + localStorage.removeItem('oauth_provider') + localStorage.removeItem('oauth_code_verifier') + + try { + const startTime = Date.now() + // Telegram 用户体验优化:强制展示 1.5s 的 Loading 画风 + const MIN_DISPLAY_TIME = providerId === 'telegram' ? 1500 : 0 + + // 向后端服务进行 Token 兑换 + const data = await authService.loginWithToken(providerId, payload) + + const elapsed = Date.now() - startTime + if (elapsed < MIN_DISPLAY_TIME) { + await new Promise(resolve => setTimeout(resolve, MIN_DISPLAY_TIME - elapsed)) + } + + if (data.success) { + // 持久化设备指纹标识 (用于双端加密验证) + if (data.deviceKey) { + await setIdbItem('device_salt', data.deviceKey) + } + + // 更新状态机进入应用主流程 + const authUserStore = useAuthUserStore() + await authUserStore.fetchUserInfo() + router.push('/') + } else { + errorMsg.value = data.error || '登录验证被后端拒绝' + } + } catch (err) { + console.error('OAuth Callback Error:', err) + // Error Managed by request interceptor and authError mapping + errorMsg.value = err.message || '网络请求异常,请稍后再试' + } + }) + + const goBackToLogin = () => { + router.push('/login') + } + + return { + errorMsg, + providerName, + goBackToLogin + } +} diff --git a/frontend/src/features/auth/composables/useOAuthProviders.js b/frontend/src/features/auth/composables/useOAuthProviders.js new file mode 100644 index 0000000..5b0c3b1 --- /dev/null +++ b/frontend/src/features/auth/composables/useOAuthProviders.js @@ -0,0 +1,77 @@ +import { ref, onMounted } from 'vue' +import { ElMessage } from 'element-plus' +import { authService } from '@/features/auth/service/authService' + +const CACHE_KEY = 'oauth_providers_cache' + +/** + * 处理 OAuth 提供商列表加载与授权重定向 + */ +export function useOAuthProviders() { + const loadingProvider = ref(null) + const providers = ref([]) + + onMounted(async () => { + // 1. 优先从缓存读取,实现秒开 (Offline-first / Stale-while-revalidate) + const cached = localStorage.getItem(CACHE_KEY) + if (cached) { + try { + providers.value = JSON.parse(cached) + } catch (e) { + console.warn('Invalid oauth providers cache', e) + } + } + + // 2. 后台请求接口更新数据 + try { + const data = await authService.getProviders() + if (data.success) { + providers.value = data.providers + // 只有当后端返回了有效配置时才更新缓存 + if (data.providers && data.providers.length > 0) { + localStorage.setItem(CACHE_KEY, JSON.stringify(data.providers)) + } + } + } catch (e) { + console.error('Failed to sync oauth providers:', e) + } + }) + + const handleLogin = async (providerId) => { + if (loadingProvider.value) return + loadingProvider.value = providerId + + try { + // 1. 获取授权链接及防篡改参数 + const data = await authService.getAuthorizeUrl(providerId) + + if (data.success && data.authUrl) { + // 2. 存储 state 防御 CSRF 并跳转 + localStorage.setItem('oauth_state', data.state) + localStorage.setItem('oauth_provider', providerId) + + // PKCE (Proof Key for Code Exchange) 支持 + if (data.codeVerifier) { + localStorage.setItem('oauth_code_verifier', data.codeVerifier) + } + + window.location.href = data.authUrl + } else { + ElMessage.error(data.error || '获取授权链接失败: 提供商未配置或失效') + } + } catch (error) { + // Error managed by axios request interceptor & authError + ElMessage.error(error.message || '获取授权状态遭遇网络异常') + } finally { + // The loading state mostly clears when the page navigates away, + // but we reset it here in case of errors. + loadingProvider.value = null + } + } + + return { + providers, + loadingProvider, + handleLogin + } +} diff --git a/frontend/src/features/auth/service/authService.js b/frontend/src/features/auth/service/authService.js new file mode 100644 index 0000000..d1d7962 --- /dev/null +++ b/frontend/src/features/auth/service/authService.js @@ -0,0 +1,99 @@ +import { request } from '@/shared/utils/request' +import { authError } from '@/shared/utils/errors/authError' + +/** + * @typedef {Object} OAuthProvider + * @property {string} id - 提供商标识 (如 'github', 'telegram') + * @property {string} name - 提供商显示名称 + * @property {string} [icon] - SVG 图标 + * @property {string} [color] - 品牌主色调 + */ + +/** + * @typedef {Object} AuthorizeResponse + * @property {boolean} success - 是否成功 + * @property {string} authUrl - 授权跳转链接 + * @property {string} state - 随机状态码 (CSRF防御) + * @property {string} [codeVerifier] - PKCE验证码 + * @property {string} [error] - 错误信息 + */ + +/** + * @typedef {Object} LoginResponse + * @property {boolean} success - 是否成功 + * @property {string} [deviceKey] - 设备指纹标识 (用于后续验证) + * @property {string} [error] - 错误信息 + */ + +export const authService = { + /** + * 获取系统当前支持的 OAuth 登录提供商列表 + * @returns {Promise<{success: boolean, providers: OAuthProvider[]}>} + * @throws {authError} + */ + async getProviders() { + try { + return await request('/api/oauth/providers') + } catch (error) { + throw new authError(error.message || '获取登录方式失败', error.status) + } + }, + + /** + * 获取指定提供商的授权重定向地址 + * @param {string} providerId - 提供商 ID + * @returns {Promise} + * @throws {authError} + */ + async getAuthorizeUrl(providerId) { + try { + return await request(`/api/oauth/authorize/${providerId}`) + } catch (error) { + throw new authError(error.message || '获取授权链接失败', error.status) + } + }, + + /** + * 回调阶段:使用授权码 (Code) 兑换系统 JWT + * @param {string} providerId - 提供商 ID + * @param {Object} payload - 需要验证的载荷 (如 code, state, codeVerifier, hash) + * @returns {Promise} + * @throws {authError} + */ + async loginWithToken(providerId, payload) { + try { + return await request(`/api/oauth/callback/${providerId}`, { + method: 'POST', + body: JSON.stringify(payload) // request 工作器内部使用的是 fetch,需要手动 stringify + }) + } catch (error) { + throw new authError(error.message || '登录验证失败', error.status) + } + }, + + /** + * 退出登录,注销服务端 Session/Token + * @returns {Promise<{success: boolean}>} + */ + async logout() { + try { + return await request('/api/oauth/logout', { method: 'POST' }) + } catch (error) { + console.error('Logout request failed', error) + return { success: false } + } + }, + + /** + * 获取当前登录用户信息 + * @returns {Promise<{success: boolean, userInfo?: Object}>} + */ + async fetchMe() { + try { + // 加入时间戳防缓存,silent: true 防止请求拦截器弹出 message + return await request(`/api/oauth/me?_t=${Date.now()}`, { silent: true }) + } catch (error) { + return { success: false } + } + } +} diff --git a/frontend/src/features/auth/store/userStore.js b/frontend/src/features/auth/store/authUserStore.js similarity index 69% rename from frontend/src/features/auth/store/userStore.js rename to frontend/src/features/auth/store/authUserStore.js index 803dba2..b4ab61b 100644 --- a/frontend/src/features/auth/store/userStore.js +++ b/frontend/src/features/auth/store/authUserStore.js @@ -1,10 +1,10 @@ import { defineStore } from 'pinia' import { ref } from 'vue' -import { request } from '@/shared/utils/request' +import { authService } from '@/features/auth/service/authService' import { useVaultStore } from '@/features/vault/store/vaultStore' import { removeIdbItem } from '@/shared/utils/idb' -export const useUserStore = defineStore('user', () => { +export const useAuthUserStore = defineStore('authUserInfo', () => { const getStoredUser = () => { try { return JSON.parse(localStorage.getItem('userInfo') || '{}') @@ -38,27 +38,19 @@ export const useUserStore = defineStore('user', () => { } const logout = async () => { - try { - await request('/api/oauth/logout', { method: 'POST' }) - } catch (e) { - console.error('Logout request failed', e) - } finally { - await clearUserInfo() - } + await authService.logout() + await clearUserInfo() } const fetchUserInfo = async () => { - try { - const data = await request(`/api/oauth/me?_t=${Date.now()}`, { silent: true }) - if (data.success) { - setUserInfo(data.userInfo) - return true - } - } catch (e) { + const data = await authService.fetchMe() + if (data && data.success) { + setUserInfo(data.userInfo) + return true + } else { await clearUserInfo() return false } - return false } return { diff --git a/frontend/src/features/auth/views/login.vue b/frontend/src/features/auth/views/login.vue index 5404f21..b70b40b 100644 --- a/frontend/src/features/auth/views/login.vue +++ b/frontend/src/features/auth/views/login.vue @@ -4,7 +4,7 @@

2FAuth Worker

-

您的专属云端双因素认证管家

+

A Secure 2FA Management Tool

@@ -19,8 +19,9 @@ @click="handleLogin(provider.id)" > 通过 {{ provider.name }} 授权登录 @@ -41,68 +42,27 @@ \ No newline at end of file diff --git a/frontend/src/features/auth/views/oauthCallback.vue b/frontend/src/features/auth/views/oauthCallback.vue index df486bd..a763905 100644 --- a/frontend/src/features/auth/views/oauthCallback.vue +++ b/frontend/src/features/auth/views/oauthCallback.vue @@ -27,127 +27,12 @@ \ No newline at end of file diff --git a/frontend/src/features/backup/components/backupSettings.vue b/frontend/src/features/backup/components/backupSettings.vue index 4af44de..a3a9aac 100644 --- a/frontend/src/features/backup/components/backupSettings.vue +++ b/frontend/src/features/backup/components/backupSettings.vue @@ -193,67 +193,26 @@ -const openAddDialog = () => { - isEditing.value = false - form.value = { type: 'webdav', name: '', config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' }, autoBackup: false, autoBackupPassword: '', autoBackupRetain: 30 } - hasExistingAutoPwd.value = false - configUseExistingAutoPwd.value = false - showConfigDialog.value = true + \ No newline at end of file diff --git a/frontend/src/features/backup/composables/useBackupActions.js b/frontend/src/features/backup/composables/useBackupActions.js new file mode 100644 index 0000000..a91f8f9 --- /dev/null +++ b/frontend/src/features/backup/composables/useBackupActions.js @@ -0,0 +1,146 @@ +import { ref } from 'vue' +import { ElMessage, ElNotification } from 'element-plus' +import { backupService } from '@/features/backup/service/backupService' +import { dataMigrationService } from '@/features/migration/service/dataMigrationService' +import { useVaultStore } from '@/features/vault/store/vaultStore' + +export function useBackupActions(emit, fetchProviders) { + const vaultStore = useVaultStore() + + // Backup Action State + const showBackupDialog = ref(false) + const backupPassword = ref('') + const isBackingUp = ref(false) + const useAutoPassword = ref(false) + const currentActionProvider = ref(null) + + // Restore Action State + const showRestoreListDialog = ref(false) + const isLoadingFiles = ref(false) + const backupFiles = ref([]) + const showRestoreConfirmDialog = ref(false) + const restorePassword = ref('') + const selectedFile = ref(null) + const isRestoring = ref(false) + + // Backup methods + const openBackupDialog = (provider) => { + currentActionProvider.value = provider + backupPassword.value = '' + useAutoPassword.value = !!provider.auto_backup + showBackupDialog.value = true + } + + const handleBackup = async () => { + if (!useAutoPassword.value && backupPassword.value.length < 12) { + return ElMessage.warning('密码至少12位') + } + + const pwdToSend = useAutoPassword.value ? '' : backupPassword.value + + isBackingUp.value = true + try { + const res = await backupService.triggerBackup(currentActionProvider.value.id, pwdToSend) + if (res.success) { + ElNotification({ + title: '云端备份完成', + message: `
🎉 成功备份了数据
`, + dangerouslyUseHTMLString: true, + type: 'success', + duration: 5000 + }) + showBackupDialog.value = false + if (fetchProviders) await fetchProviders() + } + } catch (e) { + ElMessage.error(e.message || '备份失败') + } finally { isBackingUp.value = false } + } + + // Restore methods + const openRestoreDialog = async (provider) => { + currentActionProvider.value = provider + showRestoreListDialog.value = true + isLoadingFiles.value = true + try { + const res = await backupService.getBackupFiles(provider.id) + if (res.success) backupFiles.value = res.files + } catch (e) { + ElMessage.error(e.message || '获取备份文件失败') + } finally { isLoadingFiles.value = false } + } + + const selectRestoreFile = (file) => { + selectedFile.value = file + restorePassword.value = '' + showRestoreConfirmDialog.value = true + } + + const handleRestore = async () => { + isRestoring.value = true + try { + const downloadRes = await backupService.downloadBackupFile(currentActionProvider.value.id, selectedFile.value.filename) + + let contentToDecrypt = downloadRes.content + try { + const json = typeof contentToDecrypt === 'string' ? JSON.parse(contentToDecrypt) : contentToDecrypt + if (json && json.encrypted && json.data) { + contentToDecrypt = json.data + } + } catch (e) { } + + const vault = await dataMigrationService.parseImportData(contentToDecrypt, 'encrypted', restorePassword.value) + const saveRes = await dataMigrationService.saveImportedVault(vault) + + if (saveRes.success) { + let msgHtml = `
共处理 1 个备份文件 (${vault.length} 个基准账号)。
` + if (saveRes.count > 0) { + msgHtml += `
🎉 成功导入 ${saveRes.count} 个新账户!
` + } else { + msgHtml += `
⚠️ 恢复的账户皆已存在,无新添项。
` + } + if (saveRes.duplicates > 0) msgHtml += `
ℹ️ 自动跳过了 ${saveRes.duplicates} 个已有账户。
` + + ElNotification({ + title: '恢复数据结束', + message: msgHtml, + dangerouslyUseHTMLString: true, + type: 'success', + duration: 8000 + }) + + showRestoreConfirmDialog.value = false + showRestoreListDialog.value = false + if (saveRes.count > 0) { + vaultStore.markDirty() + emit('restore-success') + } + } + } catch (e) { + ElMessage.error(e.message || '恢复失败') + } finally { isRestoring.value = false } + } + + return { + // Backup + showBackupDialog, + backupPassword, + isBackingUp, + useAutoPassword, + currentActionProvider, + openBackupDialog, + handleBackup, + + // Restore + showRestoreListDialog, + isLoadingFiles, + backupFiles, + showRestoreConfirmDialog, + restorePassword, + selectedFile, + isRestoring, + openRestoreDialog, + selectRestoreFile, + handleRestore + } +} diff --git a/frontend/src/features/backup/composables/useBackupProviders.js b/frontend/src/features/backup/composables/useBackupProviders.js new file mode 100644 index 0000000..ebc55fd --- /dev/null +++ b/frontend/src/features/backup/composables/useBackupProviders.js @@ -0,0 +1,166 @@ +import { ref, onMounted } from 'vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import { useVaultStore } from '@/features/vault/store/vaultStore' +import { backupService } from '@/features/backup/service/backupService' + +export function useBackupProviders() { + const vaultStore = useVaultStore() + + const providers = ref([]) + const isLoading = ref(false) + + // Form and Dialog State + const showConfigDialog = ref(false) + const isEditing = ref(false) + const isTesting = ref(false) + const isSaving = ref(false) + + const initialFormState = () => ({ + type: 'webdav', + name: '', + config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' }, + autoBackup: false, + autoBackupPassword: '', + autoBackupRetain: 30 + }) + + const form = ref(initialFormState()) + const currentProviderId = ref(null) + const hasExistingAutoPwd = ref(false) + const configUseExistingAutoPwd = ref(false) + + // Methods + const fetchProviders = async () => { + const cachedEncrypted = await vaultStore.getEncryptedBackupProviders() + if (cachedEncrypted && Array.isArray(cachedEncrypted)) { + providers.value = cachedEncrypted + } else { + isLoading.value = true + } + + try { + const res = await backupService.getProviders() + if (res.success) { + providers.value = res.providers + await vaultStore.saveEncryptedBackupProviders(res.providers) + } + } finally { isLoading.value = false } + } + + const openAddDialog = () => { + isEditing.value = false + form.value = initialFormState() + hasExistingAutoPwd.value = false + configUseExistingAutoPwd.value = false + showConfigDialog.value = true + } + + const editProvider = (provider) => { + isEditing.value = true + currentProviderId.value = provider.id + form.value = JSON.parse(JSON.stringify({ + type: provider.type, + name: provider.name, + config: provider.config, + autoBackup: !!provider.auto_backup, + autoBackupPassword: '', + autoBackupRetain: provider.auto_backup_retain ?? 30 + })) + hasExistingAutoPwd.value = !!provider.auto_backup_password + configUseExistingAutoPwd.value = true + showConfigDialog.value = true + } + + const validateForm = () => { + if (!form.value.name) return '请输入名称' + const c = form.value.config + if (form.value.type === 'webdav') { + if (!c.url) return '请输入 WebDAV 地址' + if (!c.username) return '请输入用户名' + if (!c.password) return '请输入密码' + } else if (form.value.type === 's3') { + if (!c.endpoint) return '请输入 Endpoint' + if (!c.bucket) return '请输入 Bucket' + if (!c.accessKeyId) return '请输入 Access Key ID' + if (!c.secretAccessKey) return '请输入 Secret Access Key' + } + + if (form.value.autoBackup) { + if (isEditing.value && hasExistingAutoPwd.value && configUseExistingAutoPwd.value) { + return null + } + if (!form.value.autoBackupPassword || form.value.autoBackupPassword.length < 12) { + return '自动备份密码长度必须至少 12 位' + } + } + return null + } + + const testConnection = async () => { + const error = validateForm() + if (error) return ElMessage.warning(error) + + isTesting.value = true + try { + const res = await backupService.testConnection(form.value.type, form.value.config) + if (res.success) ElMessage.success('连接成功') + } catch (e) { + ElMessage.error(e.message || '连接失败') + } finally { isTesting.value = false } + } + + const saveProvider = async () => { + const error = validateForm() + if (error) return ElMessage.warning(error) + + if (isEditing.value && hasExistingAutoPwd.value && configUseExistingAutoPwd.value) { + form.value.autoBackupPassword = '' + } + + isSaving.value = true + try { + const res = isEditing.value + ? await backupService.updateProvider(currentProviderId.value, form.value) + : await backupService.createProvider(form.value) + if (res.success) { + ElMessage.success('保存成功') + showConfigDialog.value = false + await fetchProviders() + } + } catch (e) { + ElMessage.error(e.message || '保存失败') + } finally { isSaving.value = false } + } + + const deleteProvider = async (provider) => { + try { + await ElMessageBox.confirm('确定删除该备份源吗?', '警告', { type: 'warning' }) + await backupService.deleteProvider(provider.id) + await fetchProviders() + } catch (e) { + if (e !== 'cancel') { + ElMessage.error(e.message || '删除失败') + } + } + } + + onMounted(fetchProviders) + + return { + providers, + isLoading, + showConfigDialog, + isEditing, + isTesting, + isSaving, + form, + hasExistingAutoPwd, + configUseExistingAutoPwd, + fetchProviders, + openAddDialog, + editProvider, + testConnection, + saveProvider, + deleteProvider + } +} diff --git a/frontend/src/features/backup/service/backupService.js b/frontend/src/features/backup/service/backupService.js new file mode 100644 index 0000000..388c2f6 --- /dev/null +++ b/frontend/src/features/backup/service/backupService.js @@ -0,0 +1,158 @@ +import { request } from '@/shared/utils/request' +import { backupError } from '@/shared/utils/errors/backupError' + +/** + * @typedef {Object} BackupProviderConfig + * @property {string} url - WebDAV URL + * @property {string} username - WebDAV Username + * @property {string} password - WebDAV Password + * @property {string} saveDir - Save Directory + * @property {string} endpoint - S3 Endpoint + * @property {string} bucket - S3 Bucket + * @property {string} region - S3 Region + * @property {string} accessKeyId - S3 Access Key ID + * @property {string} secretAccessKey - S3 Secret Access Key + */ + +/** + * @typedef {Object} BackupProvider + * @property {string} [id] - Provider ID + * @property {string} name - Provider Name + * @property {string} type - Provider Type ('webdav' | 's3') + * @property {BackupProviderConfig} config - Provider Configuration + * @property {boolean} auto_backup - Auto backup enabled + * @property {string} [auto_backup_password] - Auto backup encryption password + * @property {number} [auto_backup_retain] - Number of backups to retain + */ + +export const backupService = { + /** + * 获取所有备份源配置 + * @returns {Promise<{success: boolean, providers: BackupProvider[]}>} + * @throws {backupError} + */ + async getProviders() { + try { + return await request('/api/backups/providers') + } catch (e) { + throw new backupError('Failed to fetch backup providers', 'PROVIDERS_FETCH_FAILED', e) + } + }, + + /** + * 测试备份源连接 + * @param {string} type - 'webdav' 或 's3' + * @param {BackupProviderConfig} config + * @returns {Promise<{success: boolean}>} + * @throws {backupError} + */ + async testConnection(type, config) { + try { + return await request('/api/backups/providers/test', { + method: 'POST', + body: JSON.stringify({ type, config }) + }) + } catch (e) { + throw new backupError('Failed to test connection', 'CONNECTION_TEST_FAILED', e) + } + }, + + /** + * 创建新的备份源 + * @param {BackupProvider} providerData + * @returns {Promise<{success: boolean, provider: BackupProvider}>} + * @throws {backupError} + */ + async createProvider(providerData) { + try { + return await request('/api/backups/providers', { + method: 'POST', + body: JSON.stringify(providerData) + }) + } catch (e) { + throw new backupError('Failed to create provider', 'PROVIDER_CREATE_FAILED', e) + } + }, + + /** + * 更新现有备份源 + * @param {string} id + * @param {BackupProvider} providerData + * @returns {Promise<{success: boolean}>} + * @throws {backupError} + */ + async updateProvider(id, providerData) { + try { + return await request(`/api/backups/providers/${id}`, { + method: 'PUT', + body: JSON.stringify(providerData) + }) + } catch (e) { + throw new backupError('Failed to update provider', 'PROVIDER_UPDATE_FAILED', e) + } + }, + + /** + * 删除备份源 + * @param {string} id + * @returns {Promise<{success: boolean}>} + * @throws {backupError} + */ + async deleteProvider(id) { + try { + return await request(`/api/backups/providers/${id}`, { method: 'DELETE' }) + } catch (e) { + throw new backupError('Failed to delete provider', 'PROVIDER_DELETE_FAILED', e) + } + }, + + /** + * 手动触发一次备份 + * @param {string} id + * @param {string} password - 备份加密密码 (若使用自动备份密码则为空) + * @returns {Promise<{success: boolean}>} + * @throws {backupError} + */ + async triggerBackup(id, password = '') { + try { + return await request(`/api/backups/providers/${id}/backup`, { + method: 'POST', + body: JSON.stringify({ password }) + }) + } catch (e) { + throw new backupError('Failed to trigger backup', 'BACKUP_TRIGGER_FAILED', e) + } + }, + + /** + * 获取备份历史文件列表 + * @param {string} id + * @returns {Promise<{success: boolean, files: Object[]}>} + * @throws {backupError} + */ + async getBackupFiles(id) { + try { + return await request(`/api/backups/providers/${id}/files`) + } catch (e) { + throw new backupError('Failed to fetch backup files', 'FILES_FETCH_FAILED', e) + } + }, + + /** + * 下载加密的备份文件 + * @param {string} id + * @param {string} filename + * @returns {Promise<{success: boolean, content: string|Object}>} + * @throws {backupError} + */ + async downloadBackupFile(id, filename) { + try { + return await request(`/api/backups/providers/${id}/download`, { + method: 'POST', + body: JSON.stringify({ filename }) + }) + } catch (e) { + throw new backupError('Failed to download backup file', 'FILE_DOWNLOAD_FAILED', e) + } + } +} diff --git a/frontend/src/features/backup/service/migrationService.js b/frontend/src/features/backup/service/migrationService.js deleted file mode 100644 index 57a3cc0..0000000 --- a/frontend/src/features/backup/service/migrationService.js +++ /dev/null @@ -1,171 +0,0 @@ -import { request } from '@/shared/utils/request' -import { encryptDataWithPassword, decryptDataWithPassword } from '@/shared/utils/crypto' // 现在已指向标准实现 -import { parseOtpUri } from '@/shared/utils/totp' - -export const migrationService = { - // 1. 获取所有数据 (用于导出) - async fetchAllVault() { - // 请求后端获取所有数据 (limit=9999) - const res = await request('/api/vault?limit=9999') - if (!res.success) throw new Error('无法获取账号数据') - return res.vault || [] - }, - - // 2. 处理导出逻辑 (前端加密/格式化) - async exportData(vault, type, password) { - const timestamp = new Date().toISOString() - const baseData = { version: "2.0", app: "2fauth", timestamp } - - // 格式 A: 本系统加密备份 (使用 crypto-vault AES-GCM) - if (type === 'encrypted') { - if (!password) throw new Error('加密导出需要密码') - const payload = { ...baseData, accounts: vault } - const encryptedData = await encryptDataWithPassword(payload, password) - return JSON.stringify({ - ...baseData, - encrypted: true, - data: encryptedData, - note: "This file is encrypted with your export password (AES-GCM-256 + PBKDF2)." - }, null, 2) - } - - // 格式 B: 标准 JSON - if (type === 'json') { - return JSON.stringify({ ...baseData, encrypted: false, accounts: vault }, null, 2) - } - - // 格式 C: 2FAS 兼容格式 (schemaVersion 4 标准) - if (type === '2fas') { - const services = vault.map(acc => ({ - name: acc.service, - secret: acc.secret, - otp: { - tokenType: 'TOTP', - issuer: acc.service, - account: acc.account, - digits: acc.digits, - period: acc.period, - algorithm: (acc.algorithm || 'SHA1').replace('SHA-', 'SHA'), - counter: 0, - }, - order: { position: 0 }, - })) - return JSON.stringify({ schemaVersion: 4, appOrigin: 'export', services }, null, 2) - } - - // 格式 D: OTPAuth 纯文本格式 - if (type === 'text') { - return vault.map(acc => { - const label = encodeURIComponent(`${acc.service}:${acc.account}`) - const issuer = encodeURIComponent(acc.service) - return `otpauth://totp/${label}?secret=${acc.secret}&issuer=${issuer}&digits=${acc.digits}&period=${acc.period}` - }).join('\n') - } - - throw new Error('未知的导出类型') - }, - - // 3. 处理导入逻辑 (前端解密/解析) - async parseImportData(content, type, password) { - let rawVault = [] - - // 场景 A: 加密备份 - if (type === 'encrypted') { - if (!password) throw new Error('导入加密文件需要密码') - try { - let ciphertext = content - const json = typeof content === 'string' ? tryParseJSON(content) : content - if (json && typeof json === 'object' && json.data) { - ciphertext = json.data - } - const input = typeof ciphertext === 'object' ? JSON.stringify(ciphertext) : ciphertext - const decrypted = await decryptDataWithPassword(input, password) - rawVault = decrypted.vault || decrypted.accounts || [] - } catch (e) { - console.error(e) - throw new Error('解密失败:密码错误或文件格式不兼容') - } - } - // 场景 B: 标准 JSON / 2FAuth - else if (type === 'json') { - const json = typeof content === 'string' ? JSON.parse(content) : content - // 本项目导出格式: data.accounts[] 或 data.vault[] - if (Array.isArray(json.accounts)) { - rawVault = json.accounts - } else if (Array.isArray(json.vault)) { - rawVault = json.vault - // 旧导出格式: data.data[] - } else if (Array.isArray(json.data)) { - rawVault = json.data - // 标准第三方格式: data.secrets[] (issuer + algorithm 字段) - } else if (Array.isArray(json.secrets)) { - rawVault = json.secrets.map(s => ({ - service: s.issuer || s.service || s.name || 'Unknown', - account: s.account || s.label || '', - secret: s.secret || '', - algorithm: s.algorithm || 'SHA1', - digits: s.digits || 6, - period: s.period || 30, - })) - // 简单数组 - } else if (Array.isArray(json)) { - rawVault = json - // 误选 JSON 但实际是 2FAS 格式 - } else if (json.services) { - rawVault = json.services.map(s => ({ - service: s.otp?.issuer || s.name || 'Unknown', - account: s.otp?.account || s.account || s.username || '', - secret: s.secret || '', - algorithm: s.otp?.algorithm || s.algorithm || 'SHA1', - digits: s.otp?.digits || s.digits || 6, - period: s.otp?.period || s.period || 30, - })) - } - } - // 场景 C: 2FAS (schemaVersion 4, 字段在 otp 子对象中) - else if (type === '2fas') { - const json = typeof content === 'string' ? JSON.parse(content) : content - if (Array.isArray(json.services)) { - rawVault = json.services.map(s => ({ - service: s.otp?.issuer || s.name || 'Unknown', - account: s.otp?.account || s.account || s.username || '', - secret: s.secret || '', - algorithm: (s.otp?.algorithm || s.algorithm || 'SHA1').toUpperCase(), - digits: s.otp?.digits || s.digits || 6, - period: s.otp?.period || s.period || 30, - })) - } - } - // 场景 D: OTPAuth 纯文本格式 - else if (type === 'text') { - const lines = content.split('\n') - lines.forEach(line => { - const acc = parseOtpUri(line.trim()) - if (acc) rawVault.push(acc) - }) - } - - // 过滤无效数据 - return rawVault.filter(acc => acc && acc.secret && acc.service && acc.account) - }, - - - // 4. 批量保存到后端s - async saveImportedVault(vault) { - // 调用后端批量导入接口 (需要后端支持接收纯 JSON 数组) - // 我们将复用 /api/vault/import 接口,但修改其实现以支持 'raw' 类型 - return await request('/api/vault/import', { - method: 'POST', - body: JSON.stringify({ type: 'raw', content: JSON.stringify(vault) }) - }) - } -} - -// 辅助函数:安全解析 JSON,失败返回 null -function tryParseJSON(str) { - try { - return JSON.parse(str) - } catch (e) { - return null - } -} \ No newline at end of file diff --git a/frontend/src/features/backup/views/dataExport.vue b/frontend/src/features/backup/views/dataExport.vue deleted file mode 100644 index f801a10..0000000 --- a/frontend/src/features/backup/views/dataExport.vue +++ /dev/null @@ -1,119 +0,0 @@ - - - diff --git a/frontend/src/features/backup/views/dataImport.vue b/frontend/src/features/backup/views/dataImport.vue deleted file mode 100644 index 1da19ce..0000000 --- a/frontend/src/features/backup/views/dataImport.vue +++ /dev/null @@ -1,117 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/features/migration/composables/useDataExport.js b/frontend/src/features/migration/composables/useDataExport.js new file mode 100644 index 0000000..eb7b843 --- /dev/null +++ b/frontend/src/features/migration/composables/useDataExport.js @@ -0,0 +1,208 @@ +import { ref } from 'vue' +import { ElMessage } from 'element-plus' +import { dataMigrationService } from '@/features/migration/service/dataMigrationService' +import { downloadBlob } from '@/shared/utils/common' + +export function useDataExport() { + const showPasswordDialog = ref(false) + const showWarningDialog = ref(false) + const showGaDialog = ref(false) + const isExporting = ref(false) + const exportForm = ref({ password: '', confirm: '' }) + const loadingText = ref('') + + const currentType = ref('') + const currentVariant = ref('generic') + + // GA 分批导出相关 + const gaQrDataUrls = ref([]) + const gaCurrentIndex = ref(0) + + // GA 按需导出选择器相关 + const showAccountSelectDialog = ref(false) + const fullVault = ref([]) + const searchKeyword = ref('') + const selectedAccountIds = ref([]) + + const openExportDialog = (type, variant = 'generic') => { + currentType.value = type + currentVariant.value = variant + exportForm.value = { password: '', confirm: '' } + showPasswordDialog.value = true + } + + const openWarningDialog = (type, variant = 'generic') => { + currentType.value = type + currentVariant.value = variant + showWarningDialog.value = true + } + + // 计算属性:过滤后的账号列表 + const filteredVault = computed(() => { + const keyword = searchKeyword.value.toLowerCase().trim() + if (!keyword) return fullVault.value + return fullVault.value.filter(acc => + (acc.service && acc.service.toLowerCase().includes(keyword)) || + (acc.account && acc.account.toLowerCase().includes(keyword)) + ) + }) + + // 直接开启 Google Auth 导出流程(跳过明文警告弹窗) + const openGaDialogDirectly = () => { + currentType.value = 'gauth' + executeExport() + } + + // 全选/取消全选 (仅作用于当前过滤结果) + const toggleSelectAll = (val) => { + const filteredIds = filteredVault.value.map(acc => acc.id) + if (val) { + // 将不在已选列表中的过滤结果加入已选列表 + const newIds = filteredIds.filter(id => !selectedAccountIds.value.includes(id)) + selectedAccountIds.value.push(...newIds) + } else { + // 将过滤结果从已选列表中移除 + selectedAccountIds.value = selectedAccountIds.value.filter(id => !filteredIds.includes(id)) + } + } + + const executeExport = async () => { + const type = currentType.value + const variant = currentVariant.value + let password = '' + + if (type === 'encrypted') { + if (exportForm.value.password !== exportForm.value.confirm) { + return ElMessage.error('两次输入的密码不一致!') + } + if (exportForm.value.password.length < 12) { + return ElMessage.error('密码太弱!至少需要 12 个字符。') + } + password = exportForm.value.password + } + + isExporting.value = true + showPasswordDialog.value = false + showWarningDialog.value = false + + try { + loadingText.value = '正在获取账号数据...' + const vault = await dataMigrationService.fetchAllVault() + if (!vault || vault.length === 0) { + throw new Error('没有可导出的账号') + } + + loadingText.value = type === 'encrypted' ? '正在加密...' : '正在生成导出文件...' + + // 特殊处理 Google Auth (不再直接生成,而是进入选择界面) + if (type === 'gauth') { + fullVault.value = vault + // 默认全不选 (由用户的偏好修改) + selectedAccountIds.value = [] + searchKeyword.value = '' + showAccountSelectDialog.value = true + isExporting.value = false + return + } + + // 特殊处理 HTML 报告导出 + if (type === 'html') { + const htmlContent = await dataMigrationService.exportAsHtml(vault) + downloadBlob(htmlContent, `2fa-backup-report-${new Date().toISOString().split('T')[0]}.html`, 'text/html') + ElMessage.success('导出完成!请在浏览器中打开该文件。') + isExporting.value = false + return + } + + const fileContent = await dataMigrationService.exportData(vault, type, password, variant) + + let filename, mimeType + const date = new Date().toISOString().split('T')[0] + + switch (type) { + case 'text': + mimeType = 'text/plain' + filename = `2fa-export-otpauth-${date}.txt` + break + case 'csv': + mimeType = 'text/csv' + filename = `2fa-export-csv-${date}.csv` + break + case 'bwauth': + mimeType = 'application/json' + filename = `2fa-export-bwauth-${date}.json` + break + case '2fas': + mimeType = 'application/json' + filename = `2fa-export-2fas-${date}.2fas` + break + case 'generic_json': + mimeType = 'application/json' + filename = `2fa-export-generic-json-${date}.json` + break + default: + // Covers 'encrypted', 'json', 'aegis' + mimeType = 'application/json' + filename = `2fa-export-${type}-${date}.json` + break + } + + downloadBlob(fileContent, filename, mimeType) + ElMessage.success('导出成功!') + } catch (error) { + console.error('Export failed:', error) + ElMessage.error(error.message || '导出失败,请稍后重试') + } finally { + isExporting.value = false + } + } + + // 执行生成所选的 Google Auth 二维码 + const executeGaExport = async () => { + if (selectedAccountIds.value.length === 0) { + return ElMessage.warning('请至少选择一个账号') + } + + isExporting.value = true + showAccountSelectDialog.value = false + loadingText.value = '正在生成二维码...' + + try { + const selectedVault = fullVault.value.filter(acc => selectedAccountIds.value.includes(acc.id)) + showGaDialog.value = true + gaQrDataUrls.value = [] + gaCurrentIndex.value = 0 + gaQrDataUrls.value = await dataMigrationService.exportAsGaMigration(selectedVault) + } catch (error) { + console.error('GA Export failed:', error) + ElMessage.error(error.message || '生成二维码失败') + } finally { + isExporting.value = false + } + } + + return { + // State + showPasswordDialog, + showWarningDialog, + showAccountSelectDialog, + showGaDialog, + isExporting, + exportForm, + loadingText, + gaQrDataUrls, + gaCurrentIndex, + fullVault, + searchKeyword, + selectedAccountIds, + filteredVault, + + // Methods + openExportDialog, + openWarningDialog, + openGaDialogDirectly, + executeExport, + executeGaExport, + toggleSelectAll + } +} diff --git a/frontend/src/features/migration/composables/useDataImport.js b/frontend/src/features/migration/composables/useDataImport.js new file mode 100644 index 0000000..523545c --- /dev/null +++ b/frontend/src/features/migration/composables/useDataImport.js @@ -0,0 +1,291 @@ +import { ref, computed, watch } from 'vue' +import { ElMessage, ElNotification } from 'element-plus' +import { dataMigrationService } from '@/features/migration/service/dataMigrationService' +import { useVaultStore } from '@/features/vault/store/vaultStore' + +export function useDataImport(emitFn) { + const vaultStore = useVaultStore() + + // --- State --- + const currentFileContent = ref('') + const currentFile = ref(null) + const currentImportType = ref('') + const showDecryptDialog = ref(false) + const importPassword = ref('') + const importingJobs = ref(0) + const isImporting = computed(() => importingJobs.value > 0) + const isDecrypting = ref(false) + const isDialogHandled = ref(false) + + // Batch properties + const batchTotalJobs = ref(0) + const batchProcessedJobs = ref(0) + const batchAccumulatedVault = ref([]) + const batchErrors = ref([]) + const batchCurrentTaskName = ref('') + const showBatchProgress = ref(false) + const batchProgressPercent = computed(() => { + if (batchTotalJobs.value === 0) return 0 + return Math.floor((batchProcessedJobs.value / batchTotalJobs.value) * 100) + }) + + let finalizeTimer = null + + // --- Watchers --- + watch(importingJobs, (newVal) => { + if (newVal === 0 && batchTotalJobs.value > 0 && !showDecryptDialog.value) { + if (finalizeTimer) clearTimeout(finalizeTimer) + finalizeTimer = setTimeout(() => { + finishBatchImport() + }, 300) + } + }) + + // --- Core Logic --- + const initBatchTask = (taskCount) => { + if (batchTotalJobs.value === 0) { + batchAccumulatedVault.value = [] + batchErrors.value = [] + showBatchProgress.value = true + } + importingJobs.value += taskCount + batchTotalJobs.value += taskCount + } + + const resetBatchState = () => { + batchTotalJobs.value = 0 + batchProcessedJobs.value = 0 + importingJobs.value = 0 + batchAccumulatedVault.value = [] + batchErrors.value = [] + batchCurrentTaskName.value = '' + } + + const handleFileUpload = async (uploadFile) => { + const file = uploadFile.raw + if (!file) return + + if (file.size > 10 * 1024 * 1024) { + ElMessage.error(`文件 ${file.name} 太大,不能超过 10MB`) + return + } + + initBatchTask(1) + batchCurrentTaskName.value = `准备解析:${file.name}` + + if (file.type.startsWith('image/')) { + await handleImageFile(file) + } else { + handleNonImageFile(file) + } + } + + const handleImageFile = async (file) => { + try { + batchCurrentTaskName.value = `截取图中二维码:${file.name}` + const vault = await dataMigrationService.parseGaQrImageFile(file) + batchAccumulatedVault.value.push(...vault) + } catch (err) { + console.error(err) + batchErrors.value.push(`[${file.name}] 解析图片失败: ${err.message}`) + } finally { + batchProcessedJobs.value++ + importingJobs.value-- + } + } + + const handleNonImageFile = (file) => { + try { + currentFile.value = file + const reader = new FileReader() + + // 无论文件名为何,都先读取成二进制,再由 detectFileType 判断类型 + reader.onload = async (event) => { + const buffer = event.target.result + currentFileContent.value = buffer + + const detectedType = dataMigrationService.detectFileType(buffer, file.name) + currentImportType.value = detectedType + + if (detectedType === 'unknown') { + batchErrors.value.push(`[${file.name}] 无法识别该文件格式。`) + batchProcessedJobs.value++ + importingJobs.value-- + return + } + + // 如果不是 phonefactor,后续解析需要字符串内容 + let contentForParse = buffer + if (detectedType !== 'phonefactor') { + try { + contentForParse = new TextDecoder().decode(buffer) + } catch { + // fallback to array buffer if decoding fails; service handles it + } + } + + if (detectedType === 'encrypted' || detectedType === 'aegis_encrypted') { + importPassword.value = '' + isDialogHandled.value = false + showDecryptDialog.value = true + // keep job active until unblocked by decrypt action + currentFileContent.value = contentForParse + return + } + + batchCurrentTaskName.value = `构建数据:${file.name}` + try { + const vault = await dataMigrationService.parseImportData(contentForParse, detectedType) + batchAccumulatedVault.value.push(...vault) + } catch (err) { + console.error(`[${file.name}] parseImportData failed:`, err) + const detail = err && err.message ? err.message : String(err) + batchErrors.value.push(`[${file.name}] 解析失败: ${detail}`) + } finally { + batchProcessedJobs.value++ + importingJobs.value-- + } + } + + reader.onerror = () => { + batchErrors.value.push(`[${file.name}] 读取失败`) + batchProcessedJobs.value++ + importingJobs.value-- + } + + reader.readAsArrayBuffer(file) + + } catch (error) { + console.error(error) + batchErrors.value.push(`[${file.name}] 读取异常`) + batchProcessedJobs.value++ + importingJobs.value-- + } + } + + const submitEncryptedData = async () => { + if (!importPassword.value) { + return ElMessage.warning('请输入解密密码') + } + + isDecrypting.value = true + try { + batchCurrentTaskName.value = '正在使用密码解密数据...' + const vault = await dataMigrationService.parseImportData( + currentFileContent.value, + currentImportType.value, + importPassword.value + ) + isDialogHandled.value = true + showDecryptDialog.value = false + batchAccumulatedVault.value.push(...vault) + batchProcessedJobs.value++ + importingJobs.value-- + } catch (error) { + console.error(error) + ElMessage.error(`解密失败:密码错误或不支持加密标准`) + // do not decrement job, user can try again + } finally { + isDecrypting.value = false + } + } + + const handleDecryptDialogClose = () => { + if (!isDialogHandled.value) { + batchErrors.value.push(`[${currentFile.value?.name || '加密文件'}] 用户取消密码输入`) + batchProcessedJobs.value++ + importingJobs.value-- + isDialogHandled.value = true + } + } + + const finishBatchImport = async () => { + if (batchAccumulatedVault.value.length === 0) { + showBatchProgress.value = false + if (batchErrors.value.length > 0) { + ElNotification({ + title: '导入异常', + message: batchErrors.value.join('
'), + type: 'error', + dangerouslyUseHTMLString: true, + duration: 0 + }) + } else { + ElMessage.warning('没有可导入的有效账户数据') + } + resetBatchState() + return + } + + // 先在前端做简单的去重处理(大小写/空格不敏感),避免重复发送相同条目 + const normalize = (s, a) => `${(s||'').toString().trim().toLowerCase()}:${(a||'').toString().trim().toLowerCase()}`; + const seen = new Set(); + const filteredVault = []; + for (const acc of batchAccumulatedVault.value) { + const sig = normalize(acc.service, acc.account); + if (!seen.has(sig)) { + seen.add(sig); + filteredVault.push(acc); + } + } + batchAccumulatedVault.value = filteredVault; + batchCurrentTaskName.value = `合并保存中... (${batchAccumulatedVault.value.length} 个账号)` + + try { + const data = await dataMigrationService.saveImportedVault(batchAccumulatedVault.value) + showBatchProgress.value = false + + if (data.success) { + let msgHtml = `
共处理 ${batchTotalJobs.value} 个文件。
` + if (data.count > 0) { + msgHtml += `
🎉 成功导入 ${data.count} 个新账户!
` + } else { + msgHtml += `
⚠️ 导入账户皆已存在,无需添加。
` + } + + if (data.duplicates > 0) msgHtml += `
ℹ️ 自动跳过了 ${data.duplicates} 个已拥有的账户。
` + + if (batchErrors.value.length > 0) { + msgHtml += `
❌ 错误摘要:
${batchErrors.value.join('
')}
` + } + + ElNotification({ + title: '批量导入结束', + message: msgHtml, + dangerouslyUseHTMLString: true, + type: batchErrors.value.length > 0 ? 'warning' : 'success', + duration: batchErrors.value.length > 0 ? 0 : 8000 + }) + + if (data.count > 0) { + vaultStore.markDirty() + emitFn('success') + } + } else { + ElMessage.error(`后端合并保存失败,请检查网络`) + } + } catch (err) { + showBatchProgress.value = false + ElMessage.error(`最终保存异常:${err.message}`) + } finally { + resetBatchState() + } + } + + return { + // State + currentImportType, + showDecryptDialog, + importPassword, + isDecrypting, + showBatchProgress, + batchCurrentTaskName, + batchProcessedJobs, + batchTotalJobs, + batchProgressPercent, + // Handlers + handleFileUpload, + submitEncryptedData, + handleDecryptDialogClose + } +} diff --git a/frontend/src/features/migration/service/dataMigrationService.js b/frontend/src/features/migration/service/dataMigrationService.js new file mode 100644 index 0000000..97e481c --- /dev/null +++ b/frontend/src/features/migration/service/dataMigrationService.js @@ -0,0 +1,722 @@ +import { vaultService } from '@/features/vault/service/vaultService' +import { encryptDataWithPassword, decryptDataWithPassword } from '@/shared/utils/crypto' +import { parseOtpUri } from '@/shared/utils/totp' +import { tryParseJSON, base64ToBytes } from '@/shared/utils/encoding' +import { gaMigrationStrategy } from '@/shared/utils/serializers/gauthStrategy' +import { csvStrategy } from '@/shared/utils/serializers/csvStrategy' +import { aegisStrategy } from '@/shared/utils/serializers/aegisStrategy' +import { migrationError } from '@/shared/utils/errors/migrationError' +import jsQR from 'jsqr' +import initSqlJs from 'sql.js' + +/** + * 数据迁移服务核心控制器 + * 负责智能分发不同来源与格式的导入导出请求 + */ +export const dataMigrationService = { + /** + * 智能识别导入内容或文件的类型 + * @param {string|ArrayBuffer|Uint8Array} content - 文件文本内容或二进制数据 + * @param {string} filename - 文件名 + * @returns {'bwauth_csv'|'generic_csv'|'text'|'bwauth_json'|'encrypted'|'json'|'2fas'|'aegis'|'aegis_encrypted'|'phonefactor'|'unknown'} 返回类型标识 + */ + detectFileType(content, filename) { + // 如果是二进制数据,尝试用更宽容的方式判断 PhoneFactor + if (content instanceof ArrayBuffer || content instanceof Uint8Array) { + const bytes = content instanceof Uint8Array ? content : new Uint8Array(content) + // 1) 检查 SQLite 文件头 + const header = 'SQLite format 3' + let headerMatch = true + for (let i = 0; i < header.length && i < bytes.length; i++) { + if (bytes[i] !== header.charCodeAt(i)) { headerMatch = false; break } + } + if (headerMatch) return 'phonefactor' + + // 2) 快速 ASCII 搜索表/列名(容错,某些导出会保留表名字符串在文件内部) + try { + const hay = new TextDecoder('utf-8', { fatal: false }).decode(bytes) + if (hay.includes('accounts') && (hay.includes('oath_secret_key') || hay.includes('encrypted_oath_secret_key'))) return 'phonefactor' + } catch (e) { /* ignore decode errors */ } + } + + // 宽容的文件名匹配(包含 phonefactor 字样) + if (filename && filename.toLowerCase().includes('phonefactor')) { + // 如果文件名包含 phonefactor,则谨慎标记为 phonefactor(上层读取时还会再校验头/表) + return 'phonefactor' + } + + if (filename && filename.toLowerCase().endsWith('.csv')) { + const firstLine = content.split('\n')[0].toLowerCase() + if (firstLine.includes('login_totp')) return 'bwauth_csv' + return 'generic_csv' + } + + if (typeof content === 'string' && content.trim().startsWith('otpauth://')) { + return 'text' + } + + if (typeof content === 'string') { + const json = tryParseJSON(content) + if (json) { + if (Array.isArray(json.items) && json.items.length > 0 && json.items[0].login && json.items[0].login.totp) return 'bwauth_json' + if (json.encrypted === true && json.app === '2fauth') return 'encrypted' + if (json.app === '2fauth' || Array.isArray(json.accounts) || Array.isArray(json.vault) || Array.isArray(json.secrets)) return 'json' + if (json.schemaVersion && Array.isArray(json.services)) return '2fas' + if (json.version === 1 && json.db && typeof json.db === 'object' && Array.isArray(json.db.entries)) return 'aegis' // Aegis unencrypted + if (json.version === 1 && json.header && json.db && typeof json.db === 'string') return 'aegis_encrypted' + } + } + + if (filename) { + const ext = filename.toLowerCase() + if (ext.endsWith('.2fas')) return '2fas' + if (ext.endsWith('.txt')) return 'text' + } + + return 'unknown' + }, + + /** + * 1. 获取所有数据 (用于导出) + * @returns {Promise} + */ + async fetchAllVault() { + const res = await vaultService.getVault({ limit: 9999 }) + if (!res.success) throw new migrationError('无法获取账号数据', 'VAULT_FETCH_FAILED') + return res.vault || [] + }, + + /** + * 2. 处理导出逻辑 (前端加密/格式化) + * @param {Object[]} vault + * @param {string} type + * @param {string} password + * @param {string} [variant='generic'] + * @returns {Promise} 导出的文件字符串内容 + */ + async exportData(vault, type, password, variant = 'generic') { + const timestamp = new Date().toISOString() + const baseData = { version: "2.0", app: "2fauth", timestamp } + + if (type === 'encrypted') { + if (!password) throw new migrationError('加密导出需要密码', 'MISSING_PASSWORD') + const payload = { ...baseData, accounts: vault } + const encryptedData = await encryptDataWithPassword(payload, password) + return JSON.stringify({ + ...baseData, + encrypted: true, + data: encryptedData, + note: "This file is encrypted with your export password (AES-GCM-256 + PBKDF2)." + }, null, 2) + } + + if (type === 'generic_json') { + const secrets = vault.map(acc => ({ + issuer: acc.service || 'Unknown', + account: acc.account || '', + secret: acc.secret, + type: 'TOTP', + digits: acc.digits || 6, + period: acc.period || 30, + algorithm: (acc.algorithm || 'SHA1').toUpperCase().replace('SHA-', 'SHA') + })) + return JSON.stringify({ + version: "1.0", + exportDate: new Date().toISOString(), + count: secrets.length, + secrets + }, null, 2) + } + + if (type === 'json') { + return JSON.stringify({ ...baseData, encrypted: false, accounts: vault }, null, 2) + } + + if (type === '2fas') { + const services = vault.map((acc, index) => { + const algo = (acc.algorithm || 'SHA1') + .replace('SHA-1', 'SHA1') + .replace('SHA-256', 'SHA256') + .replace('SHA-512', 'SHA512') + return { + name: acc.service, + secret: acc.secret, + otp: { + source: 'manual', + account: acc.account || '', + digits: acc.digits || 6, + period: acc.period || 30, + algorithm: algo, + tokenType: 'TOTP', + counter: 0, + }, + order: { position: index }, + badge: { color: 'Default' }, + updatedAt: Date.now(), + icon: { + selected: 'Label', + label: { + text: (acc.service || '?').slice(0, 2).toUpperCase(), + backgroundColor: 'Default', + }, + iconCollection: { id: 'A5B3FB65-4EC5-43E6-8EC1-49E24CA9E7AD' }, + }, + } + }) + return JSON.stringify({ + schemaVersion: 4, + appVersionCode: 50316, + appVersionName: '5.3.16', + appOrigin: 'ios', + groups: [], + services + }) + } + + if (type === 'aegis') { + const entries = vault.map(acc => ({ + type: 'totp', + uuid: crypto.randomUUID(), + name: acc.account || acc.service, + issuer: acc.service, + info: { + secret: acc.secret, + algo: (acc.algorithm || 'SHA1').replace('SHA-', 'SHA'), + digits: acc.digits || 6, + period: acc.period || 30 + } + })) + return JSON.stringify({ + version: 1, + header: { slots: null, params: null }, + db: { version: 3, entries } + }, null, 2) + } + + if (type === 'text') { + return vault.map(acc => { + const label = encodeURIComponent(`${acc.service}:${acc.account}`) + const issuer = encodeURIComponent(acc.service) + return `otpauth://totp/${label}?secret=${acc.secret}&issuer=${issuer}&digits=${acc.digits}&period=${acc.period}` + }).join('\n') + } + + if (type === 'csv') { + if (variant === 'bitwarden') { + let csv = 'name,secret,totp,favorite,folder\n' + vault.forEach(acc => { + const name = `"${acc.service}${acc.account ? ':' + acc.account : ''}"` + const label = encodeURIComponent(`${acc.service}:${acc.account}`) + const issuer = encodeURIComponent(acc.service) + const totp = `"otpauth://totp/${label}?secret=${acc.secret}&issuer=${issuer}&digits=${acc.digits}&period=${acc.period}"` + csv += `${name},${acc.secret},${totp},0,\n` + }) + return csv + } else { + // generic csv + let csv = 'name,issuer,secret,algorithm,digits,period,type\n' + vault.forEach(acc => { + const name = `"${acc.account}"` + const issuer = `"${acc.service}"` + csv += `${name},${issuer},${acc.secret},${acc.algorithm},${acc.digits},${acc.period},TOTP\n` + }) + return csv + } + } + + if (type === 'bwauth') { + const items = vault.map(acc => { + const label = encodeURIComponent(acc.account ? `${acc.service}:${acc.account}` : acc.service) + const issuer = encodeURIComponent(acc.service) + const algo = (acc.algorithm || 'SHA1').replace('SHA-1', 'SHA1').replace('SHA-256', 'SHA256').replace('SHA-512', 'SHA512') + const totp = `otpauth://totp/${label}?secret=${acc.secret}&issuer=${issuer}&algorithm=${algo}&digits=${acc.digits || 6}&period=${acc.period || 30}` + return { + favorite: false, + id: crypto.randomUUID().toUpperCase(), + login: { totp, username: acc.account || '' }, + name: acc.service, + type: 1 + } + }) + return JSON.stringify({ encrypted: false, items }) + } + + throw new migrationError('未知的导出类型: ' + type, 'UNKNOWN_EXPORT_TYPE') + }, + + /** + * 导出为 Google Authenticator 迁移协议二维码 (分批生成图片 URI) + * @param {Object[]} vault + * @returns {Promise} QR Code Data URLs + */ + async exportAsGaMigration(vault) { + if (!vault || vault.length === 0) throw new migrationError('没有账户可以迁移', 'EMPTY_VAULT') + + const BATCH_SIZE = 10; + const batches = []; + for (let i = 0; i < vault.length; i += BATCH_SIZE) { + batches.push(vault.slice(i, i + BATCH_SIZE)); + } + + const batchId = Math.floor(Math.random() * 0x7fffffff); + const QRCode = await import('qrcode') + const qrDataUrls = []; + + function writeVarint(val, arr) { + while (val >= 0x80) { + arr.push((val & 0x7f) | 0x80) + val >>>= 7 + } + arr.push(val) + } + + function writeString(str, arr) { + const bytes = new TextEncoder().encode(str) + writeVarint(bytes.length, arr) + for (let i = 0; i < bytes.length; i++) arr.push(bytes[i]) + } + + function writeBytes(bytes, arr) { + writeVarint(bytes.length, arr) + for (let i = 0; i < bytes.length; i++) arr.push(bytes[i]) + } + + for (let bIndex = 0; bIndex < batches.length; bIndex++) { + const currentBatch = batches[bIndex]; + const payloadBytes = []; + + payloadBytes.push(16) // version = 1 + writeVarint(1, payloadBytes) + payloadBytes.push(24) // batch_size + writeVarint(batches.length, payloadBytes) + payloadBytes.push(32) // batch_index + writeVarint(bIndex, payloadBytes) + payloadBytes.push(40) // batch_id + writeVarint(batchId, payloadBytes) + + for (const acc of currentBatch) { + const otpBytes = [] + const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + const secretStr = acc.secret.toUpperCase().replace(/=+$/, '').replace(/[^A-Z2-7]/g, '') + const sBytes = [] + let bits = 0, value = 0 + for (let i = 0; i < secretStr.length; i++) { + const val = ALPHABET.indexOf(secretStr[i]) + if (val === -1) continue + value = (value << 5) | val + bits += 5 + if (bits >= 8) { + sBytes.push((value >>> (bits - 8)) & 0xFF) + bits -= 8 + } + } + if (sBytes.length > 0) { + otpBytes.push(10) + writeBytes(sBytes, otpBytes) + } + + const name = acc.account || acc.service + if (name) { + otpBytes.push(18) + writeString(name, otpBytes) + } + + if (acc.service) { + otpBytes.push(26) + writeString(acc.service, otpBytes) + } + + let algoVal = 1 // SHA1 + if (acc.algorithm === 'SHA256') algoVal = 2 + else if (acc.algorithm === 'SHA512') algoVal = 3 + otpBytes.push(32) + writeVarint(algoVal, otpBytes) + + let digVal = 1 // SIX + if (acc.digits === 8) digVal = 2 + otpBytes.push(40) + writeVarint(digVal, otpBytes) + + otpBytes.push(48) + writeVarint(2, otpBytes) // TOTP = 2 + + payloadBytes.push(10) + writeVarint(otpBytes.length, payloadBytes) + for (let i = 0; i < otpBytes.length; i++) payloadBytes.push(otpBytes[i]) + } + + let binary = '' + for (let i = 0; i < payloadBytes.length; i++) binary += String.fromCharCode(payloadBytes[i]) + const uri = `otpauth-migration://offline?data=${encodeURIComponent(btoa(binary))}` + const dataUrl = await QRCode.toDataURL(uri, { errorCorrectionLevel: 'M', width: 480, margin: 2 }) + qrDataUrls.push(dataUrl); + } + + return qrDataUrls; + }, + + async exportAsHtml(vault) { + const QRCode = await import('qrcode') + const htmlContent = [] + + htmlContent.push(` + + + + + 2FAuth 备份报告 + + + +
+

2FAuth 二步验证账户备份

+

生成时间:${new Date().toLocaleString()}

+

⚠️ 警告:此页面包含敏感信息,请妥善保管。请使用浏览器打印功能将其保存为 PDF 或打印成纸质备份。

+ +
+
+ `) + + for (const acc of vault) { + const label = encodeURIComponent(`${acc.account}`) + const issuer = encodeURIComponent(acc.service) + const uri = `otpauth://totp/${label}?secret=${acc.secret}&issuer=${issuer}&digits=${acc.digits}&period=${acc.period}` + + try { + const qrDataUrl = await QRCode.toDataURL(uri, { errorCorrectionLevel: 'M', margin: 2 }) + htmlContent.push(` +
+
${acc.service}
+ + QR Code +
${acc.secret.replace(/(.{4})/g, '$1 ').trim()}
+
${acc.algorithm} / ${acc.digits} / ${acc.period}s
+
+ `) + } catch (e) { } + } + + htmlContent.push(` +
+ + + + `) + return htmlContent.join('\n') + }, + + /** + * 3.0 解析PhoneFactor SQLite数据库 + * @param {ArrayBuffer} dbBuffer - SQLite数据库二进制数据 + * @returns {Promise} 标准化的账户列表 + * @throws {migrationError} + */ + async parsePhoneFactor(dbBuffer) { + try { + // 初始化 sql.js:浏览器端优先从 /sql-wasm.wasm 或 CDN 加载 wasm,Node 环境从 node_modules 加载 + let SQL + try { + if (typeof window !== 'undefined') { + try { + SQL = await initSqlJs({ locateFile: file => `/sql-wasm.wasm` }) + } catch (e) { + // 如果本地 public 中不存在 wasm,回退到 CDN + SQL = await initSqlJs({ locateFile: file => `https://unpkg.com/sql.js@1.10.3/dist/${file}` }) + } + } else { + SQL = await initSqlJs({ locateFile: file => require('path').resolve('node_modules/sql.js/dist/', file) }) + } + } catch (e) { + // 最后兜底,直接初始化(可能仍然抛错) + SQL = await initSqlJs() + } + const db = new SQL.Database(new Uint8Array(dbBuffer)) + + // 检查accounts表是否存在 + const tables = db.exec("SELECT name FROM sqlite_master WHERE type='table' AND name='accounts'") + if (tables.length === 0) { + throw new migrationError('不是有效的Microsoft Authenticator数据文件', 'INVALID_PHONEFACTOR_FILE') + } + + // 使用 prepared statement 逐行读取,避免依赖 exec 返回的 columns/values 结构不一致 + const vault = [] + let skippedEncrypted = 0 + let skippedEmpty = 0 + const base32Re = /^[A-Z2-7]+=*$/i + + try { + const stmt = db.prepare("SELECT name, username, oath_secret_key, encrypted_oath_secret_key FROM accounts WHERE account_type=0") + while (stmt.step()) { + const record = stmt.getAsObject() + const name = record.name + const username = record.username + const oath_secret_key = record.oath_secret_key + const encrypted_oath_secret_key = record.encrypted_oath_secret_key + + // 跳过条件:name 和 username 同时为空 + if ((!name || String(name).trim() === '') && (!username || String(username).trim() === '')) { + skippedEmpty++ + continue + } + + const secret = (oath_secret_key || '').toString().trim() + if (!secret) { + if (encrypted_oath_secret_key && String(encrypted_oath_secret_key).trim() !== '') { + skippedEncrypted++ + continue + } + skippedEmpty++ + continue + } + + const normalized = secret.replace(/\s+/g, '').replace(/=+$/, '') + if (!base32Re.test(normalized)) { + if (encrypted_oath_secret_key && String(encrypted_oath_secret_key).trim() !== '') { + skippedEncrypted++ + continue + } + skippedEmpty++ + continue + } + + vault.push({ + service: name || 'Unknown Service', + account: username || 'Unknown Account', + secret: secret, + algorithm: 'SHA1', + digits: 6, + period: 30 + }) + } + stmt.free() + } catch (e) { + db.close() + throw new migrationError('解析 PhoneFactor 数据库失败', 'INVALID_PHONEFACTOR_FILE', e) + } + + db.close() + if (vault.length === 0) { + if (skippedEncrypted > 0) throw new migrationError('PhoneFactor 文件仅包含加密的密钥,无法在前端导入', 'PHONEFACTOR_ONLY_ENCRYPTED') + throw new migrationError('未能从 PhoneFactor 文件中提取到可导入的 TOTP 记录', 'PHONEFACTOR_NO_IMPORTABLE_ROWS') + } + return vault + + } catch (error) { + if (error instanceof migrationError) { + console.error('parsePhoneFactor migrationError:', error) + throw error + } + // 打印详细调试信息到控制台,包含底层错误与缓冲长度 + try { + const bufLen = (dbBuffer && (dbBuffer.byteLength || dbBuffer.length)) || 0 + console.error('parsePhoneFactor failed:', { message: error && error.message, stack: error && error.stack, bufferLength: bufLen, error }) + } catch (logErr) { + console.error('parsePhoneFactor failed (logging error):', logErr) + } + const msg = error && error.message ? `${error.message}` : String(error) + throw new migrationError(`不是有效的Microsoft Authenticator数据文件: ${msg}`, 'INVALID_PHONEFACTOR_FILE', error) + } + }, + + /** + * 3. 导入逻辑:前端解密/分发解析序列化策略 + * @param {string|ArrayBuffer} content - 文件内容或二进制数据 + * @param {string} type - 探测出的类型 + * @param {string} [password] - 加密文件的密码 + * @returns {Promise} 返回标准化后的金库清单 + * @throws {migrationError} + */ + async parseImportData(content, type, password) { + let rawVault = [] + + // 处理PhoneFactor SQLite数据库 + if (type === 'phonefactor') { + return await this.parsePhoneFactor(content) + } + + if (type === 'bwauth_csv' || type === 'generic_csv') { + rawVault = csvStrategy.parseCsv(content) + content = JSON.stringify(rawVault) + type = 'raw' + } + + if (type === 'aegis_encrypted') { + const parsedAegis = tryParseJSON(content) + const decryptedDb = await aegisStrategy.decryptDatabase(parsedAegis, password) + content = JSON.stringify(decryptedDb) + type = 'aegis' + password = undefined + } else if (type === 'aegis') { + const parsedAegis = tryParseJSON(content) + content = JSON.stringify(parsedAegis.db) + } + + if (type === 'encrypted') { + if (!password) throw new migrationError('导入加密文件需要密码', 'MISSING_PASSWORD') + try { + let ciphertext = content + const json = typeof content === 'string' ? tryParseJSON(content) : content + if (json && typeof json === 'object' && json.data) ciphertext = json.data + const decrypted = await decryptDataWithPassword(ciphertext, password) + rawVault = decrypted.vault || decrypted.accounts || [] + } catch (e) { + throw new migrationError('解密失败:密码错误或文件格式不兼容', 'DECRYPTION_FAILED', e) + } + } + else if (type === 'json') { + const json = typeof content === 'string' ? JSON.parse(content) : content + if (Array.isArray(json.accounts)) rawVault = json.accounts + else if (Array.isArray(json.vault)) rawVault = json.vault + else if (Array.isArray(json.data)) rawVault = json.data + else if (json.secrets) { + rawVault = json.secrets.map(s => { + let account = s.account || s.label || ''; + if (typeof account === 'string' && account.includes(':')) { + account = account.split(':').pop()?.trim() || account; + } + return { + service: s.issuer || s.service || s.name || 'Unknown', + account, + secret: s.secret || '', + algorithm: s.algorithm || 'SHA1', + digits: s.digits || 6, + period: s.period || 30, + } + }) + } else if (Array.isArray(json)) rawVault = json + } + else if (type === '2fas') { + const json = typeof content === 'string' ? JSON.parse(content) : content + if (Array.isArray(json.services)) { + rawVault = json.services.map(s => { + let account = s.otp?.account || s.account || s.username || ''; + if (typeof account === 'string' && account.includes(':')) { + account = account.split(':').pop()?.trim() || account; + } + return { + service: s.otp?.issuer || s.name || 'Unknown', + account, + secret: s.secret || '', + algorithm: (s.otp?.algorithm || s.algorithm || 'SHA1').toUpperCase(), + digits: s.otp?.digits || s.digits || 6, + period: s.otp?.period || s.period || 30, + } + }) + } + } + else if (type === 'bwauth_json') { + const json = typeof content === 'string' ? JSON.parse(content) : content + if (Array.isArray(json.items)) { + json.items.forEach(item => { + if (item.login && item.login.totp) { + const accInfo = parseOtpUri(item.login.totp) + if (accInfo) { + accInfo.service = item.name || accInfo.service + accInfo.account = item.login.username || accInfo.account + rawVault.push(accInfo) + } + } + }) + } + } + else if (type === 'aegis') { + const json = typeof content === 'string' ? JSON.parse(content) : content + const entries = json.entries || (json.db && json.db.entries) || [] + rawVault = entries.map(s => ({ + service: s.issuer || s.name || 'Unknown', + account: s.name || '', + secret: s.info?.secret || '', + algorithm: s.info?.algo || 'SHA1', + digits: s.info?.digits || 6, + period: s.info?.period || 30, + })) + } + else if (type === 'text') { + const lines = content.split('\n') + lines.forEach(line => { + const acc = parseOtpUri(line.trim()) + if (acc) rawVault.push(acc) + }) + } + else if (type === 'bwauth_csv' || type === 'generic_csv') rawVault = csvStrategy.parseCsv(content) + + return rawVault.map(acc => { + if (typeof acc.account === 'string' && acc.account.includes(':')) { + acc.account = acc.account.split(':').pop()?.trim() || acc.account + } + if (!acc.account || acc.account.trim() === '') acc.account = acc.service || 'Unknown Account' + return acc + }).filter(acc => acc && acc.secret && acc.service) + }, + + /** + * 3.1 解析 GA 二维码图片 + * @param {File} file + * @returns {Promise} + */ + async parseGaQrImageFile(file) { + return new Promise((resolve, reject) => { + const img = new Image() + const url = URL.createObjectURL(file) + img.onload = () => { + URL.revokeObjectURL(url) + const canvas = document.createElement('canvas') + const context = canvas.getContext('2d', { willReadFrequently: true }) + const scales = [1.0, 1.5, 0.5, 2.0, 0.8] + let code = null + for (const scale of scales) { + canvas.width = img.width * scale + canvas.height = img.height * scale + context.imageSmoothingEnabled = false + context.drawImage(img, 0, 0, canvas.width, canvas.height) + const imageData = context.getImageData(0, 0, canvas.width, canvas.height) + code = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: "attemptBoth" }) + if (code) break; + } + + if (!code) return reject(new migrationError('未能识别出二维码,请确认为完整清晰的截图。', 'QR_RECOGNITION_FAILED')) + const uri = code.data + if (!uri.startsWith('otpauth-migration://offline?data=')) return reject(new migrationError('不是有效的 Google Authenticator 迁移二维码', 'INVALID_GA_QR')) + + try { + const urlParams = new URL(uri).searchParams + const dataBase64Url = urlParams.get('data') + let base64 = dataBase64Url.replace(/-/g, '+').replace(/_/g, '/') + while (base64.length % 4) base64 += '=' + const raw = atob(base64) + const dataBytes = new Uint8Array(raw.length) + for (let i = 0; i < raw.length; i++) dataBytes[i] = raw.charCodeAt(i) + resolve(gaMigrationStrategy.decodePayload(dataBytes)) + } catch (e) { reject(new migrationError('解析 Google Authenticator 数据失败', 'GA_DECODE_FAILED', e)) } + } + img.onerror = () => { + URL.revokeObjectURL(url) + reject(new migrationError('图片读取失败,文件可能已损坏', 'IMAGE_LOAD_FAILED')) + } + img.src = url + }) + }, + + /** + * 4. 批量保存到后端 + * @param {Object[]} vault + */ + async saveImportedVault(vault) { + return await vaultService.importVault(vault) + } +} diff --git a/frontend/src/features/migration/views/dataExport.vue b/frontend/src/features/migration/views/dataExport.vue new file mode 100644 index 0000000..e4c0724 --- /dev/null +++ b/frontend/src/features/migration/views/dataExport.vue @@ -0,0 +1,343 @@ + + + + + diff --git a/frontend/src/features/migration/views/dataImport.vue b/frontend/src/features/migration/views/dataImport.vue new file mode 100644 index 0000000..9b4735d --- /dev/null +++ b/frontend/src/features/migration/views/dataImport.vue @@ -0,0 +1,254 @@ + + + + + diff --git a/frontend/src/features/migration/views/dataMigration.vue b/frontend/src/features/migration/views/dataMigration.vue new file mode 100644 index 0000000..366cea5 --- /dev/null +++ b/frontend/src/features/migration/views/dataMigration.vue @@ -0,0 +1,30 @@ + + + + + diff --git a/frontend/src/features/tools/components/timeSync.vue b/frontend/src/features/tools/components/timeSync.vue index 8656447..6bb93c4 100644 --- a/frontend/src/features/tools/components/timeSync.vue +++ b/frontend/src/features/tools/components/timeSync.vue @@ -38,54 +38,33 @@ \ No newline at end of file diff --git a/frontend/src/features/tools/components/totpSecret.vue b/frontend/src/features/tools/components/totpSecret.vue index e4a8bd4..c175eb2 100644 --- a/frontend/src/features/tools/components/totpSecret.vue +++ b/frontend/src/features/tools/components/totpSecret.vue @@ -154,203 +154,39 @@ \ No newline at end of file diff --git a/frontend/src/features/tools/composables/useTimeSync.js b/frontend/src/features/tools/composables/useTimeSync.js new file mode 100644 index 0000000..2c0b86a --- /dev/null +++ b/frontend/src/features/tools/composables/useTimeSync.js @@ -0,0 +1,65 @@ +import { ref, computed, onMounted, onUnmounted } from 'vue' +import { toolService } from '@/features/tools/service/toolService' + +/** + * 时间校准逻辑提取 + * + * @returns {Object} 包含校准状态与方法的响应式对象 + */ +export function useTimeSync() { + const localTime = ref(Date.now()) + const offset = ref(null) + const rtt = ref(null) + const isSyncing = ref(false) + let clockTimer = null + + const serverTime = computed(() => localTime.value + (offset.value || 0)) + + const syncStatus = computed(() => { + if (offset.value === null) return null + const abs = Math.abs(offset.value) + if (abs < 2000) return { title: '时间同步正常', type: 'success', desc: '本地时间与服务器误差极小,不影响 2FA 验证。' } + if (abs < 30000) return { title: '存在微小偏差', type: 'warning', desc: '建议校准本地时间,但通常仍可使用。' } + return { title: '时间偏差过大', type: 'error', desc: '严重偏差!2FA 验证码将失效,请务必校准设备时间。' } + }) + + const syncTime = async () => { + isSyncing.value = true + const start = Date.now() + try { + const res = await toolService.getServerTime() + const end = Date.now() + if (res.success) { + const serverTs = res.time + rtt.value = end - start + // 假设往返时间是对称的,服务器返回时间为收到请求时的 serverTs + 单程网络延迟 + const estimatedServerTime = serverTs + (rtt.value / 2) + offset.value = Math.round(estimatedServerTime - end) + return { success: true, offset: offset.value } + } + return { success: false, error: new Error('返回结构异常') } + } catch (error) { + return { success: false, error } + } finally { + isSyncing.value = false + } + } + + onMounted(() => { + clockTimer = setInterval(() => { localTime.value = Date.now() }, 1000) + }) + + onUnmounted(() => { + if (clockTimer) clearInterval(clockTimer) + }) + + return { + localTime, + serverTime, + offset, + rtt, + isSyncing, + syncStatus, + syncTime + } +} diff --git a/frontend/src/features/tools/composables/useTotpToolbox.js b/frontend/src/features/tools/composables/useTotpToolbox.js new file mode 100644 index 0000000..3dfe484 --- /dev/null +++ b/frontend/src/features/tools/composables/useTotpToolbox.js @@ -0,0 +1,159 @@ +import { ref, onMounted, onUnmounted } from 'vue' +import { base32ToBytes, bytesToBase32, hexToBytes, bytesToHex, asciiToBytes, bytesToAscii, generateTOTP, parseOtpUri } from '@/shared/utils/totp' + +/** + * 提取 TOTP 工具箱的状态机与计算逻辑 + * + * 架构说明 (Architecture Notes): + * 1. 状态内聚: 将 Base32, Hex, ASCII 相互转换的逻辑和时间便宜量计算封入本 Hook。 + * 2. 分离视图: 使得 Vue 组件单纯负责数据绑定,不再关心 bytes 到 string 的转换细节和 setInterval 时序。 + * + * @returns 包含状态与操作方法的响应式对象 + */ +export function useTotpToolbox() { + const activeTab = ref('base32') + const secretBase32 = ref('') + const secretHex = ref('') + const secretAscii = ref('') + + // Metadata & Settings + const issuer = ref('2FAuth-Tool') + const account = ref('TestUser') + const algorithm = ref('SHA-1') + const digits = ref(6) + const period = ref(30) + const timeOffset = ref(0) + + const currentUri = ref('') + const currentCode = ref('') + const remaining = ref(30) + let timer = null + + // --- 同步输入联动 --- + const updateAll = async (sourceType) => { + try { + // 1. 同步各类输入制式 + if (sourceType === 'base32') { + const bytes = base32ToBytes(secretBase32.value) + secretHex.value = bytesToHex(bytes) + secretAscii.value = bytesToAscii(bytes) + } else if (sourceType === 'hex') { + const bytes = hexToBytes(secretHex.value) + if (bytes.length > 0) secretBase32.value = bytesToBase32(bytes) + secretAscii.value = bytesToAscii(bytes) + } else if (sourceType === 'ascii') { + const bytes = asciiToBytes(secretAscii.value) + secretBase32.value = bytesToBase32(bytes) + secretHex.value = bytesToHex(bytes) + } + + // 2. URI 字符串拼接 + if (secretBase32.value) { + const label = encodeURIComponent(`${issuer.value}:${account.value}`) + const algoParam = algorithm.value.replace('-', '') // SHA-1 -> SHA1 + currentUri.value = `otpauth://totp/${label}?secret=${secretBase32.value}&issuer=${encodeURIComponent(issuer.value)}&algorithm=${algoParam}&period=${period.value}&digits=${digits.value}` + } else { + currentUri.value = '' + currentCode.value = '' + } + + // 3. 立即重算一次 TOTP + calcTotp() + } catch (e) { + console.error(e) + // 打字过程中的解码失败不做打断处理 + } + } + + const handleBase32Input = () => updateAll('base32') + const handleHexInput = () => updateAll('hex') + const handleAsciiInput = () => updateAll('ascii') + const updateUri = () => updateAll('settings') + + // --- 随机生成 --- + const refreshBase32 = () => { + const array = new Uint8Array(20) + window.crypto.getRandomValues(array) + secretBase32.value = bytesToBase32(array) + updateAll('base32') + } + + const refreshHex = () => { + const array = new Uint8Array(20) + window.crypto.getRandomValues(array) + secretHex.value = bytesToHex(array) + updateAll('hex') + } + + const refreshAscii = () => { + const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789!@#$%^&*()' + let result = '' + const array = new Uint32Array(20) + window.crypto.getRandomValues(array) + for (let i = 0; i < 20; i++) result += chars[array[i] % chars.length] + secretAscii.value = result + updateAll('ascii') + } + + // --- TOTP 主力计算核心 --- + const calcTotp = async () => { + if (!secretBase32.value) return + + const p = period.value + // 加入时空穿梭偏移量 + const now = (Date.now() / 1000) + timeOffset.value + remaining.value = Math.ceil(p - (now % p)) + + try { + const periodOffset = Math.floor(timeOffset.value / p) + currentCode.value = await generateTOTP(secretBase32.value, p, digits.value, algorithm.value, periodOffset) + } catch (e) { + currentCode.value = 'ERROR' + } + } + + // --- 时间调节器 (Time Travel) --- + const adjustTime = (delta, reset = false) => { + if (reset) timeOffset.value = 0 + else timeOffset.value += delta + calcTotp() + } + + // --- 扫码解析注入 --- + const handleParsedUri = (uri) => { + const parsed = parseOtpUri(uri) + if (parsed) { + if (parsed.secret) { + secretBase32.value = parsed.secret + updateAll('base32') + } + if (parsed.service) issuer.value = parsed.service + if (parsed.account) account.value = parsed.account + if (parsed.digits) digits.value = parsed.digits + if (parsed.period) period.value = parsed.period + if (parsed.algorithm) algorithm.value = parsed.algorithm + + updateAll('settings') + return true + } + return false + } + + onMounted(() => { + // 初始装载随机码 + refreshBase32() + timer = setInterval(calcTotp, 1000) + }) + + onUnmounted(() => { if (timer) clearInterval(timer) }) + + return { + activeTab, + secretBase32, secretHex, secretAscii, + issuer, account, algorithm, digits, period, timeOffset, + currentUri, currentCode, remaining, + handleBase32Input, handleHexInput, handleAsciiInput, updateUri, + refreshBase32, refreshHex, refreshAscii, + adjustTime, handleParsedUri + } +} diff --git a/frontend/src/features/tools/composables/useTotpToolboxActions.js b/frontend/src/features/tools/composables/useTotpToolboxActions.js new file mode 100644 index 0000000..3d89a22 --- /dev/null +++ b/frontend/src/features/tools/composables/useTotpToolboxActions.js @@ -0,0 +1,74 @@ +import { ref, watch } from 'vue' +import { ElMessage } from 'element-plus' +import QRCode from 'qrcode' +import { vaultService } from '@/features/vault/service/vaultService' + +/** + * TOTP 工具箱外部协同调度逻辑 + * + * 架构说明: + * 专门处理工具箱产生的与外部 API / 库的桥接:如触发二维码生成库 QRCode,与后端 Vault 保存 API 进行联调。 + * 这保证了 useTotpToolbox.js 作为纯状态机,不涉及第三方 UI 或非标准 API 边界效应。 + */ +export function useTotpToolboxActions(toolboxState, queryClient) { + const isSaving = ref(false) + const showScanner = ref(false) + const qrCodeUrl = ref('') + + // 监听 currentUri 自动重绘二维码 + watch(() => toolboxState.currentUri.value, async (newUri) => { + if (newUri) { + try { + qrCodeUrl.value = await QRCode.toDataURL(newUri, { width: 200, margin: 1 }) + } catch (e) { + qrCodeUrl.value = '' + } + } else { + qrCodeUrl.value = '' + } + }) + + const handleScanSuccess = (uri) => { + showScanner.value = false + const success = toolboxState.handleParsedUri(uri) + if (success) { + ElMessage.success('二维码解析成功') + } else { + ElMessage.warning('无效的 OTP URI') + } + } + + const saveToVault = async () => { + if (!toolboxState.secretBase32.value) return ElMessage.warning('密钥不能为空') + if (!toolboxState.issuer.value || !toolboxState.account.value) return ElMessage.warning('请填写服务商和账号') + + isSaving.value = true + try { + const res = await vaultService.createAccount({ + service: toolboxState.issuer.value, + account: toolboxState.account.value, + secret: toolboxState.secretBase32.value, + digits: toolboxState.digits.value, + period: toolboxState.period.value, + category: '工具箱添加' + }) + if (res.success) { + ElMessage.success('已保存到我的账户') + // 刷新账号列表缓存 + queryClient.invalidateQueries(['vault']) + } + } catch (e) { + // Error managed by axios request interceptor & vaultService wrapping + } finally { + isSaving.value = false + } + } + + return { + isSaving, + showScanner, + qrCodeUrl, + handleScanSuccess, + saveToVault + } +} diff --git a/frontend/src/features/tools/service/toolService.js b/frontend/src/features/tools/service/toolService.js new file mode 100644 index 0000000..8cf2ab5 --- /dev/null +++ b/frontend/src/features/tools/service/toolService.js @@ -0,0 +1,23 @@ +import { request } from '@/shared/utils/request' +import { toolsError } from '@/shared/utils/errors/toolsError' + +/** + * @typedef {Object} ServerTimeResponse + * @property {boolean} success - 请求是否成功 + * @property {number} time - 服务器时间戳 (毫秒) + */ + +export const toolService = { + /** + * 获取服务器当前时间 + * @returns {Promise} + * @throws {toolsError} + */ + async getServerTime() { + try { + return await request('/api/tools/server-time') + } catch (error) { + throw new toolsError(error.message || '获取服务器时间失败', error.status) + } + } +} diff --git a/frontend/src/features/vault/components/addVaultManual.vue b/frontend/src/features/vault/components/addVaultManual.vue index bb0bba9..79e013b 100644 --- a/frontend/src/features/vault/components/addVaultManual.vue +++ b/frontend/src/features/vault/components/addVaultManual.vue @@ -60,9 +60,9 @@ import { ref } from 'vue' import { ElMessage } from 'element-plus' import { Refresh } from '@element-plus/icons-vue' -import { request } from '@/shared/utils/request' import { bytesToBase32 } from '@/shared/utils/totp' import { useVaultStore } from '@/features/vault/store/vaultStore' +import { vaultService } from '@/features/vault/service/vaultService' const emit = defineEmits(['success']) @@ -106,10 +106,7 @@ const submitAddVault = async () => { if (valid) { submitting.value = true try { - const data = await request('/api/vault', { - method: 'POST', - body: JSON.stringify(newVault.value) - }) + const data = await vaultService.createAccount(newVault.value) if (data.success) { ElMessage.success('账号添加成功!') newVault.value = { service: '', account: '', secret: '', category: '', digits: 6, period: 30, algorithm: 'SHA1' } diff --git a/frontend/src/features/vault/components/addVaultScan.vue b/frontend/src/features/vault/components/addVaultScan.vue index c16c7cb..fc74ec0 100644 --- a/frontend/src/features/vault/components/addVaultScan.vue +++ b/frontend/src/features/vault/components/addVaultScan.vue @@ -15,9 +15,9 @@ + diff --git a/frontend/src/features/vault/composables/useTotpTimer.js b/frontend/src/features/vault/composables/useTotpTimer.js new file mode 100644 index 0000000..5bbd0c3 --- /dev/null +++ b/frontend/src/features/vault/composables/useTotpTimer.js @@ -0,0 +1,96 @@ +import { onMounted, onUnmounted, triggerRef } from 'vue' +import { generateTOTP } from '@/shared/utils/totp' + +/** + * 管理所有保险箱账号的 TOTP 验证码与倒计时刷新 + * + * 架构说明 (Architecture Notes): + * 1. 性能优化 (Parallel Computation): 循环列表触发 HMAC-SHA-1 等 Hash 计算是昂贵的 CPU 任务。 + * 因此,遍历 Vault 时摒弃串行 `await`,采取 `Promise.all` 全并发模式,榨干宏任务的吞吐量, + * 以实现成百上千条账号依然不阻塞验证码更新的渲染节奏。 + * 2. 批量渲染 (Reactivity Batches): 受益于 Vue 的依赖收集,将 `vault` 定义为 `shallowRef`, + * 在批量计算结束后调用唯一一次 `triggerRef(vault)`,从而使得深层的 1000 次元素级属性修改合并成了 + * Vue 的一次虚拟 DOM 树 diff 与重绘。 + * 3. 异步分流: 将 5 秒即将变色的逻辑与下次新 Code 的提前量生成机制解耦,确保证预计算。 + * + * @param {import('vue').ShallowRef} vault - 当前账号列表的 shallowRef + * @returns Composable lifecycle bindings (auto-mounted via onMounted/onUnmounted) + */ +export function useTotpTimer(vault) { + let globalTimer = null + + /** + * 并发计算所有账号的 TOTP 验证码与剩余时间 + * @param {Object[]} [targetList] - 可选,传入用于首次渲染前的预计算 + */ + const updateVaultStatus = async (targetList) => { + const list = Array.isArray(targetList) ? targetList : vault.value + if (!list || list.length === 0) return + + const now = Date.now() / 1000 + + // 使用 Promise.all 并行处理所有账号的计算,避免串行 await 导致验证码生成延迟 + await Promise.all(list.map(async (acc) => { + const period = acc.period || 30 + const remaining = Math.ceil(period - (now % period)) + acc.remaining = remaining + acc.percentage = (remaining / period) * 100 + + // 颜色逻辑 + if (remaining > 10) acc.color = '#67C23A' + else if (remaining > 5) acc.color = '#E6A23C' + else acc.color = '#F56C6C' + + // 计算当前验证码(epoch 变化时或首次计算) + const currentEpoch = Math.floor(now / period) + const algorithm = acc.algorithm + ? acc.algorithm.replace('SHA', 'SHA-').replace('SHA--', 'SHA-') + : 'SHA-1' + + if (acc.lastEpoch !== currentEpoch || !acc.currentCode || acc.currentCode === '------') { + acc.currentCode = await generateTOTP(acc.secret, period, acc.digits, algorithm) + acc.lastEpoch = currentEpoch + } + + // 剩余 5 秒时预计算下一段验证码 + if (remaining <= 5) { + if (!acc.nextCode || acc.lastNextEpoch !== currentEpoch + 1) { + acc.nextCode = await generateTOTP(acc.secret, period, acc.digits, algorithm, 1) + acc.lastNextEpoch = currentEpoch + 1 + } + } else { + acc.nextCode = null + } + })) + + // shallowRef 需要手动通知,合并所有深层属性变更为一次 UI 重绘 + // 渲染来源分流 (Trigger Control): + // 1. 无 targetList 传参时(由内部 1 秒定时器调用):因为仅原地修改属性,没改变浅引用,需手动 triggerRef 才能重绘 UI。 + // 2. 有 targetList 传参时(由外部 useVaultList 初筛调用):因为外边马上就要走 `vault.value = merged` 浅引用赋值逻辑, + // 所以这里绝对静默,不可调用 triggerRef,否则将打破单次重绘策略并造成 "------" 闪屏重演。 + if (!Array.isArray(targetList)) { + triggerRef(vault) + } + } + + const startTimer = () => { + updateVaultStatus() // 立即计算一次 + globalTimer = setInterval(() => updateVaultStatus(), 1000) + } + + const stopTimer = () => { + if (globalTimer) { + clearInterval(globalTimer) + globalTimer = null + } + } + + onMounted(startTimer) + onUnmounted(stopTimer) + + return { + updateVaultStatus, + startTimer, + stopTimer + } +} diff --git a/frontend/src/features/vault/composables/useVaultActions.js b/frontend/src/features/vault/composables/useVaultActions.js new file mode 100644 index 0000000..2c57820 --- /dev/null +++ b/frontend/src/features/vault/composables/useVaultActions.js @@ -0,0 +1,191 @@ +import { ref } from 'vue' +import { ElMessage, ElMessageBox } from 'element-plus' +import QRCode from 'qrcode' +import { useVaultStore } from '@/features/vault/store/vaultStore' +import { vaultService } from '@/features/vault/service/vaultService' +import { copyToClipboard } from '@/shared/utils/common' + +/** + * 管理账号的增删改查与二维码展示等弹窗行为 + * @param {Function} fetchVault - 操作成功后用于刷新列表的回调 + * @param {import('vue').ShallowRef} vault - 当前账号列表(用于全选) + * @returns Composable state and actions + */ +export function useVaultActions(fetchVault, vault) { + const vaultStore = useVaultStore() + + // --- 批量操作 --- + const selectedIds = ref([]) + const isBulkDeleting = ref(false) + + // --- 编辑弹窗 --- + const showEditDialog = ref(false) + const isEditing = ref(false) + const editVaultData = ref({ id: '', service: '', account: '', category: '' }) + + // --- 二维码弹窗 --- + const showQrDialog = ref(false) + const currentQrItem = ref(null) + const showSecret = ref(false) + const qrCodeUrl = ref('') + + // --- 批量删除 --- + const handleBulkDelete = async () => { + if (!selectedIds.value.length) return + try { + await ElMessageBox.confirm( + `确定要删除选中的 ${selectedIds.value.length} 个账号吗?此操作不可恢复。`, + '批量删除', + { type: 'warning', confirmButtonText: '确认删除', cancelButtonText: '取消' } + ) + isBulkDeleting.value = true + await vaultService.batchDelete(selectedIds.value) + ElMessage.success(`已成功删除 ${selectedIds.value.length} 个账号`) + selectedIds.value = [] + vaultStore.markDirty() + fetchVault() + } catch (e) { + if (e !== 'cancel') console.error(e) + } finally { + isBulkDeleting.value = false + } + } + + const toggleSelection = (id) => { + const idx = selectedIds.value.indexOf(id) + if (idx > -1) { + selectedIds.value.splice(idx, 1) + } else { + selectedIds.value.push(id) + } + } + + // Bug Fix: 全选已加载账号(直接访问 vault ref,无需跨 composable 传参) + const selectAllLoaded = () => { + if (vault?.value) { + selectedIds.value = vault.value.map(acc => acc.id) + } + } + + // --- 复制 TOTP 代码 --- + const copyCode = async (vaultItem) => { + if (!vaultItem.currentCode || vaultItem.currentCode === '------') { + return ElMessage.warning('验证码尚未生成,请稍候') + } + await copyToClipboard(vaultItem.currentCode, '验证码已复制') + } + + // --- 编辑账号 --- + const openEditDialog = (vaultItem) => { + editVaultData.value = { + id: vaultItem.id, + service: vaultItem.service, + account: vaultItem.account, + category: vaultItem.category || '' + } + showEditDialog.value = true + } + + const submitEditVault = async () => { + isEditing.value = true + try { + const { id, ...updateData } = editVaultData.value + const res = await vaultService.updateAccount(id, updateData) + if (res.success) { + ElMessage.success('账号信息已更新') + showEditDialog.value = false + vaultStore.markDirty() + fetchVault() + } + } catch (e) { + // Error is shown by request utility or vaultError handler + } finally { + isEditing.value = false + } + } + + // --- 删除单个账号 --- + const deleteVault = async (vaultItem) => { + try { + await ElMessageBox.confirm(`确定要删除 ${vaultItem.service} 吗?`, '删除确认', { + type: 'warning', + confirmButtonText: '确认删除', + cancelButtonText: '取消' + }) + await vaultService.deleteAccount(vaultItem.id) + ElMessage.success('账号已删除') + vaultStore.markDirty() + fetchVault() + } catch (e) { + if (e !== 'cancel') console.error(e) + } + } + + // --- 二维码导出 --- + const openQrDialog = async (vaultItem) => { + currentQrItem.value = vaultItem + showSecret.value = false + showQrDialog.value = true + + const algorithm = vaultItem.algorithm + ? vaultItem.algorithm.replace('SHA', 'SHA-').replace('SHA--', 'SHA-') + : 'SHA-1' + const uri = `otpauth://totp/${encodeURIComponent(vaultItem.service)}:${encodeURIComponent(vaultItem.account)}?secret=${vaultItem.secret}&issuer=${encodeURIComponent(vaultItem.service)}&algorithm=${algorithm}&digits=${vaultItem.digits || 6}&period=${vaultItem.period || 30}` + qrCodeUrl.value = await QRCode.toDataURL(uri, { width: 240, margin: 1 }) + } + + const copySecret = () => { + if (currentQrItem.value) { + copyToClipboard(currentQrItem.value.secret) + ElMessage.success('密钥已复制到剪贴板') + } + } + + const copyOtpUrl = () => { + if (currentQrItem.value) { + const item = currentQrItem.value + const algorithm = item.algorithm + ? item.algorithm.replace('SHA', 'SHA-').replace('SHA--', 'SHA-') + : 'SHA-1' + const uri = `otpauth://totp/${encodeURIComponent(item.service)}:${encodeURIComponent(item.account)}?secret=${item.secret}&issuer=${encodeURIComponent(item.service)}&algorithm=${algorithm}&digits=${item.digits || 6}&period=${item.period || 30}` + copyToClipboard(uri) + ElMessage.success('OTP URI 已复制') + } + } + + const formatSecret = (secret) => { + return (secret || '').match(/.{1,4}/g)?.join(' ') || secret + } + + // --- 统一命令分发 --- + const handleCommand = (cmd, vaultItem) => { + if (cmd === 'edit') openEditDialog(vaultItem) + else if (cmd === 'qr') openQrDialog(vaultItem) + else if (cmd === 'delete') deleteVault(vaultItem) + } + + return { + selectedIds, + isBulkDeleting, + showEditDialog, + isEditing, + editVaultData, + showQrDialog, + currentQrItem, + showSecret, + qrCodeUrl, + + toggleSelection, + selectAllLoaded, + handleBulkDelete, + copyCode, + openEditDialog, + submitEditVault, + deleteVault, + openQrDialog, + copySecret, + copyOtpUrl, + formatSecret, + handleCommand, + } +} diff --git a/frontend/src/features/vault/composables/useVaultList.js b/frontend/src/features/vault/composables/useVaultList.js new file mode 100644 index 0000000..f2c61f1 --- /dev/null +++ b/frontend/src/features/vault/composables/useVaultList.js @@ -0,0 +1,144 @@ +import { ref, shallowRef, computed, watch, triggerRef } from 'vue' +import { useInfiniteQuery, useQueryClient } from '@tanstack/vue-query' +import { useVaultStore } from '@/features/vault/store/vaultStore' +import { vaultService } from '@/features/vault/service/vaultService' + +/** + * 管理金库账号的分页列表数据获取与搜索 + * + * 架构说明 (Architecture Notes): + * 1. 渲染时序防闪烁: 采用「两级渲染策略」。针对已有账号(已包含计算好的 TOTP 验证码)直接复用; + * 针对新加载分页的新账号,先触发 `afterLoadRef` 在后台预计算验证码,完成之后再赋值给 `vault.value`。 + * 这种先计算后一次性赋值的浅引用 (shallowRef) 机制,避免了验证码变为 "------" 然后闪换的情况。 + * 2. 缓存秒开机制: `staleTime` 设置为 60s。当用户从外部组件切回主页时,若在 60s 内, + * 直接利用 Vue Query 的现有缓存(保留已有账号的 TOTP `currentCode` 和动态倒计时 `remaining`), + * 消除 Loading 空白期的出现。也不会触发多余的重复分页加载以及昂贵的 PBKDF2 (`vaultStore.saveData`)加解密迭代开销。 + * 3. 并发解耦: 本 Composable `useVaultList` 独立负责 Vue Query 分页和本地缓存维护(`vaultStore.saveData`)。 + * 对于后续验证码计算的具体逻辑,委托给外部通过参数 `afterLoadRef` 回调。 + * + * @param {import('vue').Ref} afterLoadRef - 可选的 ref,指向数据合并后的异步回调(如 updateVaultStatus) + * @returns Composable state and actions + */ +export function useVaultList(afterLoadRef = null) { + const vaultStore = useVaultStore() + const queryClient = useQueryClient() + + const vault = shallowRef([]) + const searchQuery = ref('') + const pageSize = ref(12) + + // --- Vue Query 分页逻辑 --- + const fetchVaultPage = async ({ pageParam = 1 }) => { + if (!vaultStore.isUnlocked) return { vault: [], pagination: { totalPages: 0 } } + return await vaultService.getVault({ + page: pageParam, + limit: pageSize.value, + search: searchQuery.value + }) + } + + const { + data, + fetchNextPage, + hasNextPage, + isFetchingNextPage, + isLoading, + isFetching, + isError, + refetch + } = useInfiniteQuery({ + queryKey: ['vault', searchQuery], + queryFn: fetchVaultPage, + getNextPageParam: (lastPage) => { + if (!lastPage || !lastPage.pagination) return undefined + const { page, totalPages } = lastPage.pagination + return page < totalPages ? page + 1 : undefined + }, + enabled: computed(() => vaultStore.isUnlocked), + // 60 秒内切换回来直接用缓存,不触发逐页重拉(避免多次 PBKDF2 saveData 开销) + // invalidateQueries / resetQueries 调用时会忽略 staleTime,强制立即刷新 + staleTime: 60 * 1000, + }) + + // 监听数据变化,同步到本地 vault 引用 + watch(data, async (newData) => { + if (!newData) return + + // 智能合并:保留已有账号的 TOTP 实时状态,新增账号初始化响应式属性 + const flatVault = newData.pages.flatMap(p => p.vault || []) + const merged = flatVault.map(newAcc => { + const existing = vault.value.find(a => a.id === newAcc.id) + if (existing) { + return { ...existing, ...newAcc } // 保留 currentCode / remaining 等动态字段 + } + return { + ...newAcc, + currentCode: '------', + nextCode: null, + remaining: 30, + percentage: 0, + color: '' + } + }) + + // 两级渲染策略,兼顾「无 ------ 闪烁」与「切换回来秒开」: + // 1. 找出真正的新增项(已有项 currentCode 有值,直接复用,无需重算) + const newItems = merged.filter(acc => !acc.currentCode || acc.currentCode === '------') + + if (newItems.length > 0) { + // 2. 只为新增项预计算 TOTP(通常 12 条/页,耗时极短),避免显示 "------" + // updateVaultStatus(newItems) 仅修改 newItems 元素属性,不调用 triggerRef + const cb = afterLoadRef?.value ?? afterLoadRef + if (typeof cb === 'function') { + await cb(newItems) + } + } + + // 3. 一次赋值完成渲染:已有项复用原有验证码,新增项验证码已预计算完毕 + // shallowRef .value = 新引用会自动通知 Vue,无需额外 triggerRef + vault.value = merged + + // 无搜索词时保存全量缓存(使用正确的 vaultStore API) + if (!searchQuery.value && vaultStore.isUnlocked) { + try { + await vaultStore.saveData({ vault: merged }) + vaultStore.clearDirty() + } catch (e) { + // 缓存写入失败静默处理 + } + } + }, { deep: false }) + + // 公开的 fetchVault 方法(用于父组件通过 ref 手工刷新) + // 使用 invalidateQueries 而非 resetQueries:保留缓存仅标记为过期, + // 切换回账号列表时可立即从缓存显示,后台静默刷新,无需重新拉取所有分页。 + const fetchVault = () => { + queryClient.invalidateQueries({ queryKey: ['vault'] }) + } + + // 无限滚动加载 + const isLoadMoreDisabled = computed(() => + !hasNextPage.value || isFetchingNextPage.value || !vaultStore.isUnlocked || isError.value + ) + + const handleLoadMore = () => { + if (!isLoadMoreDisabled.value) { + fetchNextPage() + } + } + + return { + vault, + searchQuery, + pageSize, + isLoading, + isFetching, + isFetchingNextPage, + hasNextPage, + isError, + isLoadMoreDisabled, + fetchVault, + handleLoadMore, + refetch + } +} diff --git a/frontend/src/features/vault/service/vaultService.js b/frontend/src/features/vault/service/vaultService.js new file mode 100644 index 0000000..5e53661 --- /dev/null +++ b/frontend/src/features/vault/service/vaultService.js @@ -0,0 +1,150 @@ +import { request } from '@/shared/utils/request' +import { vaultError } from '@/shared/utils/errors/vaultError' + +/** + * @typedef {Object} VaultAccount + * @property {string} [id] - Account ID + * @property {string} service - Service name (e.g. "Google") + * @property {string} account - Account identifier (e.g. email) + * @property {string} secret - Base32-encoded TOTP secret + * @property {string} [category] - Optional category label + * @property {6|8} [digits] - OTP digit count (default: 6) + * @property {30|60} [period] - TOTP period in seconds (default: 30) + * @property {'SHA1'|'SHA256'|'SHA512'} [algorithm] - HMAC algorithm (default: 'SHA1') + */ + +/** + * @typedef {Object} VaultPagination + * @property {number} page - Current page number + * @property {number} totalPages - Total number of pages + * @property {number} total - Total item count + */ + +/** + * @typedef {Object} VaultListResponse + * @property {boolean} success + * @property {VaultAccount[]} vault - List of accounts for THIS page + * @property {VaultPagination} pagination + */ + +export const vaultService = { + /** + * 分页获取账号列表 + * @param {{ page?: number, limit?: number, search?: string }} params + * @returns {Promise} + * @throws {vaultError} + */ + async getVault(params = {}) { + try { + const query = new URLSearchParams(params).toString() + return await request(`/api/vault?${query}`) + } catch (e) { + throw new vaultError('Failed to fetch vault list', 'VAULT_FETCH_FAILED', e) + } + }, + + /** + * 创建新账号 + * @param {VaultAccount} vaultData + * @returns {Promise<{success: boolean, id: string}>} + * @throws {vaultError} + */ + async createAccount(vaultData) { + try { + return await request('/api/vault', { + method: 'POST', + body: JSON.stringify(vaultData) + }) + } catch (e) { + throw new vaultError('Failed to create account', 'ACCOUNT_CREATE_FAILED', e) + } + }, + + /** + * 更新账号信息 + * @param {string} id + * @param {Partial} updateData + * @returns {Promise<{success: boolean}>} + * @throws {vaultError} + */ + async updateAccount(id, updateData) { + try { + return await request(`/api/vault/${id}`, { + method: 'PUT', + body: JSON.stringify(updateData) + }) + } catch (e) { + throw new vaultError('Failed to update account', 'ACCOUNT_UPDATE_FAILED', e) + } + }, + + /** + * 删除单个账号 + * @param {string} id + * @returns {Promise<{success: boolean}>} + * @throws {vaultError} + */ + async deleteAccount(id) { + try { + return await request(`/api/vault/${id}`, { method: 'DELETE' }) + } catch (e) { + throw new vaultError('Failed to delete account', 'ACCOUNT_DELETE_FAILED', e) + } + }, + + /** + * 批量删除账号 + * @param {string[]} ids + * @returns {Promise<{success: boolean, deleted: number}>} + * @throws {vaultError} + */ + async batchDelete(ids) { + try { + return await request('/api/vault/batch-delete', { + method: 'POST', + body: JSON.stringify({ ids }) + }) + } catch (e) { + throw new vaultError('Failed to batch delete accounts', 'ACCOUNTS_BATCH_DELETE_FAILED', e) + } + }, + + /** + * 通过 URI 添加账号 + * @param {string} uri - otpauth://totp/... URI string + * @param {string} [category='扫码添加'] + * @returns {Promise<{success: boolean, id: string}>} + * @throws {vaultError} + */ + async addFromUri(uri, category = '扫码添加') { + try { + return await request('/api/vault/add-from-uri', { + method: 'POST', + body: JSON.stringify({ uri, category }) + }) + } catch (e) { + throw new vaultError('Failed to add account from URI', 'ACCOUNT_ADD_URI_FAILED', e) + } + }, + + /** + * 导入账号数据 + * @param {VaultAccount[]|string} vault - 账号数组或加密字符串 + * @param {'raw'|'encrypted'} [type='raw'] + * @returns {Promise<{success: boolean, count: number}>} + * @throws {vaultError} + */ + async importVault(vault, type = 'raw') { + try { + return await request('/api/vault/import', { + method: 'POST', + body: JSON.stringify({ + type, + content: typeof vault === 'string' ? vault : JSON.stringify(vault) + }) + }) + } catch (e) { + throw new vaultError('Failed to import vault data', 'VAULT_IMPORT_FAILED', e) + } + } +} diff --git a/frontend/src/features/vault/views/home.vue b/frontend/src/features/vault/views/home.vue index 08b61ab..2ae7050 100644 --- a/frontend/src/features/vault/views/home.vue +++ b/frontend/src/features/vault/views/home.vue @@ -54,8 +54,8 @@ const layoutStore = useLayoutStore() const VaultList = defineAsyncComponent(() => import('@/features/vault/components/vaultList.vue')) const AddVaultScan = defineAsyncComponent(() => import('@/features/vault/components/addVaultScan.vue')) const AddVaultManual= defineAsyncComponent(() => import('@/features/vault/components/addVaultManual.vue')) -const DataExport = defineAsyncComponent(() => import('@/features/backup/views/dataExport.vue')) -const DataImport = defineAsyncComponent(() => import('@/features/backup/views/dataImport.vue')) +const DataExport = defineAsyncComponent(() => import('@/features/migration/views/dataExport.vue')) +const DataImport = defineAsyncComponent(() => import('@/features/migration/views/dataImport.vue')) const DataBackup = defineAsyncComponent(() => import('@/features/backup/views/dataBackup.vue')) const UtilityTools = defineAsyncComponent(() => import('@/features/tools/views/utilityTools.vue')) diff --git a/frontend/src/main.js b/frontend/src/main.js index 17cab6b..309ad64 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -24,9 +24,26 @@ themeStore.initTheme() // PWA Service Worker 注册 registerSW({ onOfflineReady() { - ElMessage.success({ - message: '应用已缓存,支持离线访问', - duration: 3000 + import('element-plus').then(({ ElNotification }) => { + ElNotification({ + title: '更新可用', + message: ` +
+ 🎉 Cloudflare Worker 新部署已就绪! +
+
+ +
+ `, + dangerouslyUseHTMLString: true, + type: 'success', + duration: 0 + }) }) }, }) diff --git a/frontend/src/shared/components/icons/icon2FAS.vue b/frontend/src/shared/components/icons/icon2FAS.vue new file mode 100644 index 0000000..96ce787 --- /dev/null +++ b/frontend/src/shared/components/icons/icon2FAS.vue @@ -0,0 +1,11 @@ + + diff --git a/frontend/src/shared/components/icons/iconAegis.vue b/frontend/src/shared/components/icons/iconAegis.vue new file mode 100644 index 0000000..8f5abe2 --- /dev/null +++ b/frontend/src/shared/components/icons/iconAegis.vue @@ -0,0 +1,8 @@ + + diff --git a/frontend/src/shared/components/icons/iconBitwarden.vue b/frontend/src/shared/components/icons/iconBitwarden.vue new file mode 100644 index 0000000..e374a90 --- /dev/null +++ b/frontend/src/shared/components/icons/iconBitwarden.vue @@ -0,0 +1,8 @@ + + diff --git a/frontend/src/shared/components/icons/iconCloudflare.vue b/frontend/src/shared/components/icons/iconCloudflare.vue new file mode 100644 index 0000000..95928ce --- /dev/null +++ b/frontend/src/shared/components/icons/iconCloudflare.vue @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/frontend/src/shared/components/icons/iconGitee.vue b/frontend/src/shared/components/icons/iconGitee.vue new file mode 100644 index 0000000..1144b1a --- /dev/null +++ b/frontend/src/shared/components/icons/iconGitee.vue @@ -0,0 +1,9 @@ + + \ No newline at end of file diff --git a/frontend/src/shared/components/icons/iconGithub.vue b/frontend/src/shared/components/icons/iconGithub.vue new file mode 100644 index 0000000..98c44d2 --- /dev/null +++ b/frontend/src/shared/components/icons/iconGithub.vue @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/frontend/src/shared/components/icons/iconGoogle.vue b/frontend/src/shared/components/icons/iconGoogle.vue new file mode 100644 index 0000000..4bd2b7a --- /dev/null +++ b/frontend/src/shared/components/icons/iconGoogle.vue @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/frontend/src/shared/components/icons/iconGoogleAuth.vue b/frontend/src/shared/components/icons/iconGoogleAuth.vue new file mode 100644 index 0000000..fb002cc --- /dev/null +++ b/frontend/src/shared/components/icons/iconGoogleAuth.vue @@ -0,0 +1,8 @@ + + diff --git a/frontend/src/shared/components/icons/iconMicrosoftAuth.vue b/frontend/src/shared/components/icons/iconMicrosoftAuth.vue new file mode 100644 index 0000000..2c8c9ab --- /dev/null +++ b/frontend/src/shared/components/icons/iconMicrosoftAuth.vue @@ -0,0 +1,13 @@ + + + + + diff --git a/frontend/src/shared/components/icons/iconNodeloc.vue b/frontend/src/shared/components/icons/iconNodeloc.vue new file mode 100644 index 0000000..4bccf50 --- /dev/null +++ b/frontend/src/shared/components/icons/iconNodeloc.vue @@ -0,0 +1,21 @@ + + diff --git a/frontend/src/shared/components/icons/iconTelegram.vue b/frontend/src/shared/components/icons/iconTelegram.vue new file mode 100644 index 0000000..36ddba8 --- /dev/null +++ b/frontend/src/shared/components/icons/iconTelegram.vue @@ -0,0 +1,8 @@ + + \ No newline at end of file diff --git a/frontend/src/shared/components/theFooter.vue b/frontend/src/shared/components/theFooter.vue index 40ff2d2..ee24a41 100644 --- a/frontend/src/shared/components/theFooter.vue +++ b/frontend/src/shared/components/theFooter.vue @@ -5,7 +5,7 @@ ©{{ new Date().getFullYear() }} 2FAuth Worker | - Serverless 2FA Manager + A Secure 2FA Management Tool | diff --git a/frontend/src/shared/components/theHeader.vue b/frontend/src/shared/components/theHeader.vue index 4db49e7..841d42f 100644 --- a/frontend/src/shared/components/theHeader.vue +++ b/frontend/src/shared/components/theHeader.vue @@ -1,7 +1,7 @@