refactor: 重构项目2.0

This commit is contained in:
nap0o committed 2026-02-28 10:51:50 +08:00
1 parent f112031f4a
commit 694714a11d
92 files changed
+5194 -3421

No files matched your search

+151 -139
View File
@@ -1,160 +1,172 @@
# 2FAuth Worker - Serverless 2FA Manager
**2FAuth Worker** 是一个基于 Cloudflare Workers + D1 (SQLite) + Vue 3 构建的轻量级、安全、无服务器的二步验证(2FA/TOTP)管理工具。
# 2FAuth Worker
它允许你完全掌控自己的 2FA 数据,支持多端同步、WebDAV 备份、S3存储桶、加密导入导出,并且完全免费部署在 Cloudflare 的边缘网络上。
**基于 Cloudflare Workers + D1 的新型 Serverless 二步验证 (2FA/TOTP) 管理器**
## ✨ 功能特性
[![Deploy to Cloudflare Workers](https://github.com/nap0o/2fauth-worker/actions/workflows/deploy.yml/badge.svg)](https://github.com/nap0o/2fauth-worker/actions/workflows/deploy.yml)
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Vue3](https://img.shields.io/badge/Vue.js-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org/)
[![Cloudflare Workers](https://img.shields.io/badge/Cloudflare-Workers-F38020?style=flat&logo=cloudflare)](https://workers.cloudflare.com/)
- ☁️ **Serverless 架构**:后端运行在 Cloudflare Workers,数据库使用 D1,无需购买服务器。
- ⚡️ **极速部署**: 利用 Github Actions 自动化部署,数分钟即可完成。
- 🔐 **OAuth2登录**:集成 GitHub 和 Cloudflare Access 安全登录。
- 🔒 **安全隐私**:数据存储在你的私有 D1 数据库中,支持 AES-GCM 高强度加密导出。
- 🖥️ **多端适配**:响应式设计,完美支持 PC 和移动端访问。
- 📷 **扫码添加**:支持调用摄像头扫描二维码或上传图片识别添加账号。
- 🔄 **数据同步**:支持 WebDAV,S3对象存储自动备份,数据永不丢失。
- 📂 **导入导出**:支持导出导入 加密JSON 格式、2FAS、标准 JSON 格式、otpauth格式数据。
- ⚙ **实用工具**:原生内置TOTP 密钥工具、时间校准器、二维码解析器等多款实用小工具。
## 🛠️ 技术栈
- **Frontend**: Vue 3, Vite, Element Plus, Pinia (State Management via Reactive)
- **Backend**: Cloudflare Workers, Hono (Web Framework)
- **Database**: Cloudflare D1 (Serverless SQLite)
- **Deployment**: GitHub Actions
## 🚀 部署指南 (Deployment)
本项目设计为通过 GitHub Actions 自动部署到 Cloudflare。
### 1. 准备工作
1. 注册一个 [Cloudflare](https://dash.cloudflare.com/) 账号。
2. Fork 本仓库到你的 GitHub 账户。
### 2. 配置 Cloudflare D1 数据库
1. 登录 Cloudflare Dashboard。
2. 进入左侧菜单的 **存储和数据库** -> **D1 SQL 数据库**。
3. 点击 **+ 创建数据库** 创建新数据库,命名为 **2fauth-db**,点击 **创建** 确认。
4. 创建完成后,在数据库详情页找到 **Database ID** 并复制。
5. 请妥善保存这个 ID,稍后我们需要将其配置到 GitHub Secrets (`CLOUDFLARE_D1_DATABASE_ID`) 中。
**以下GitHub OAuth 和 Cloudflare Access 至少配置一项用于登录**
### 3. 配置 GitHub OAuth
1. 访问 GitHub Developer Settings -> **OAuth Apps** -> **New OAuth App**。
2. 填写信息:
- **Application Name**: 2FAuth (或任意名称)
- **Homepage URL**: `https://你的Worker子域名.workers.dev` (部署后可修改为Worker绑定的自定义域名)
- **Authorization callback URL**: `https://你的Worker子域名.workers.dev/oauth/callback` (部署后可修改为Worker绑定的自定义域名)
3. 创建后,保存 `Client ID` 并生成一个新的 `Client Secret`。
### 4. 配置 Cloudflare Access
要启用 Cloudflare Access 登录,你需要:
1. 进入 **Cloudflare Zero Trust Dashboard** -> **Access** -> **Applications**。
2. 创建一个 **SaaS** 应用 (Select "SaaS")。
3. 配置 **Application**:
- **应用程序**: `输入任意名称都行,如 2fauth`
- **选择身份验证协议**: `OIDC`
- **重定向 URL**: ``https://你的Worker子域名.workers.dev/oauth/callback` (部署后可修改为Worker绑定的自定义域名)`
4. 复制保存关键信息备用, `客户端 ID` 和 `客户端密码`。
5. 配置 Access 策略
6. 其余均不需要额外配置,一直下一步到完成
### 5. 配置 GitHub Secrets
在你的 GitHub 仓库中,进入 **Settings** -> **Secrets and variables** -> **Actions** -> **New repository secret**,添加以下变量:
| Secret Name | 说明 | 示例值 |
| `JWT_SECRET` | 用于签发登录 Token 的密钥 (随机字符串) | `random_string_here` |
| :--- | :--- | :--- |
| `ENCRYPTION_KEY` | 用于加密敏感数据的密钥 (32位随机字符串) | `openssl rand -hex 16` 生成 |
| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare 账户 ID (在 Dash 面板右下角) | `a1b2...` |
| `CLOUDFLARE_API_TOKEN` | Cloudflare API 令牌 (需有 Workers 和 D1 读写权限) | `r4_...` |
| `CLOUDFLARE_D1_DATABASE_ID` | 你的 D1 数据库 ID (步骤 2 中生成的) | `xxxxxxxx-xxxx...` |
| `CLOUDFLARE_D1_DATABASE_NAME` | 你的 D1 数据库 名称 (步骤 2 中生成的) | 默认`2fauth-db` |
| `OAUTH_ALLOWED_USERS` | 允许登录的邮箱或用户名白名单 (逗号分隔) | `me@example.com,my_github_id` |
| `OAUTH_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | `Ov23...` |
| `OAUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | `a1b2...` |
| `OAUTH_GITHUB_REDIRECT_URI` | 你的 Worker 回调地址 | `https://xxx.workers.dev/oauth/callback` |
| `OAUTH_CLOUDFLARE_CLIENT_ID` | Cloudflare Access 客户端 ID | `...` |
| `OAUTH_CLOUDFLARE_CLIENT_SECRET` | Cloudflare Access 客户端密码 | `..
| `OAUTH_CLOUDFLARE_ORG_DOMAIN` | Access 组织域名 | `https://<team>.cloudflareaccess.com` |
| `OAUTH_CLOUDFLARE_REDIRECT_URI` | 你的 Worker 回调地址 | `https://xxx.workers.dev/oauth/callback` |
| `OAUTH_NODELOC_CLIENT_ID` | NodeLoc OAuth Client ID | `Ov23...` |
| `OAUTH_NODELOC_CLIENT_SECRET` | NodeLoc OAuth Client Secret | `a1b2...` |
| `OAUTH_NODELOC_REDIRECT_URI` | 你的 Worker 回调地址 | `https://xxx.workers.dev/oauth/callback` |
### 6. 自动部署
1. 进入 GitHub 仓库的 **Actions** 页面。
2. 手动触发 `Deploy to Cloudflare Workers` 工作流,或者直接 Push 代码到 `main` 分支。
3. 等待工作流执行完毕。
4. 部署成功后,访问你的 Worker URL 即可使用!
> **注意**:首次部署时,GitHub Action 会自动执行 `schema.sql` 初始化数据库表结构。
[**Demo演示**](https://2fa.nezha.pp.ua) https://2fa.nezha.pp.ua
- 设置任何用户都能登录,不受登录白名单限制,正常部署,只有设置的白名单用户才能登录
- 演示数据可以任意修改、删除;
---
## 💻 本地开发 (Local Development)
**2FAuth Worker** 是一个轻量级、安全、无服务器的 TOTP 二步验证管理工具。它让你完全掌控自己的 2FA 数据,支持多端跨平台使用,无需购买任何服务器,**完全免费部署在 Cloudflare 边缘网络上**。
### 环境要求
- Node.js 20+
- npm
## ✨ 核心优势与功能
### 🌩️ 真正的 Serverless 架构
- **零成本运维**:由 Cloudflare Workers 处理后端逻辑,Cloudflare D1 (SQLite) 存储数据,完全抛弃传统服务器。
- **全球边缘加速**:应用直接运行在 Cloudflare 全球数百个数据中心,极速响应。
### 📱 渐进式 Web 应用 (PWA)
- **安装如原生应用**:完美支持 PWA,你可以将 2FAuth 直接安装到电脑桌面或手机主屏幕,享受无浏览器边框的沉浸沉浸式极致体验。
- **真正的离线可用**:支持基于 Service Worker 的深度离线缓存,即使在**断网、无信号**的极端环境下,应用依然能秒开并为你生成准确无误的动态验证码。
### 🔐 极简却强大的身份验证
- 抛弃传统的账密注册,全面拥抱 **OAuth 2.0 第三方登录**。
- 支持对接:**GitHub, Google, Telegram, Cloudflare Access (Zero Trust), Gitee, NodeLoc**。
- 支持基于邮箱/用户名的**硬核白名单控制**,谢绝任何陌生人访问你的 2FA 数据库。
### 🛡️ 固若金汤的数据安全
- **私有数据库**:你的验证器密钥永远只存在于你的私有 D1 数据库中。
- **高强度加密存储**:数据库中的 TOTP Secret 强制进行 AES 加密存储。
- **加密导出**:支持标准 JSON、2FAS 以及采用 AES-GCM 高强度加密打包的定制导出格式。
### 🧰 全能的实用工具箱
除了 2FA 管理外,系统原生内置了多款高频安全工具:
- 🔑 **高强度密码生成器**
- ⏳ **时间校准器** (精准检测并解决 TOTP 失效的时钟偏移问题)
- 📷 **二维码解析提取器**
- 🛡️ **TOTP 密钥分析与生成工具**
### 🔄 企业级数据灾备
- **WebDAV / S3 自动备份**:连接你的Nextcloud、阿里云 OSS 或 AWS S3。
- 自动化定时任务(Cron Triggers)实现无感数据双重灾备。
---
## 🏗️ 全栈项目架构
本项目遵循现代化的 **Feature-based 垂直模块化架构**:
- **Frontend (Vue 3 + Vite)**:完全模块化,包含 `vault` (账户管理), `backup` (云端灾备), `auth` (身份验证), `tools` (工具箱) 等独立领域模块。状态管理由 Pinia 驱动。
- **Backend (Hono.js)**:极其轻量的边缘框架。配合 `Drizzle ORM` 丝滑读写 D1 数据库。
- **前后端天然一体**:由单代码库 (Monorepo) 管理,GitHub Actions 一次触发,同时构建前端产物并以 `Asset/SPA` 模式挂载至 Worker。
---
## 🚀 极速部署指南
本项目设计为通过 **GitHub Actions** 自动化一键部署到你的 Cloudflare 账号下。只需几分钟即可获得你专属的 2FA 管理器。
### 第一步:准备工作
1. 注册一个 [Cloudflare](https://dash.cloudflare.com/) 账号。
2. **Fork** 本仓库到你的 GitHub 账户。
### 第二步:配置 Cloudflare D1 数据库
1. 登录 Cloudflare Dashboard。
2. 依次点击左侧菜单的 **存储和数据库** -> **D1 SQL 数据库**。
3. 点击 **创建数据库**,命名为 `2fauth-db` (或自定义名称)。
4. 创建成功后,复制数据库详情页提供的 **`Database ID`**,备用。
### 第三步:配置第三方登录
*注:系统支持 6 种登录方式,你**至少需要配置一种***。
#### 方案一:使用 GitHub 登录
1. 访问 GitHub `Settings` -> `Developer Settings` -> `OAuth Apps` -> **New OAuth App**。
2. 填写应用信息:
- **名称**: `2FAuth`
- **Homepage URL**: 填入你预期的站点域名,例如 `https://2fa.yourdomain.workers.dev` (部署后可绑定自定义域名)
- **Authorization callback URL**: 重点!必须填写 `https://<你的域名>/oauth/callback`
3. 保存生成的 **`Client ID`** 和 **`Client Secret`**。
#### 方案二:使用 Telegram 登录
1. 在 Telegram 中搜索并添加官方机器人 **[@BotFather](https://t.me/BotFather)**。
2. 发送 `/newbot` 指令,按照提示为你的 2FAuth 创建一个机器人,并获取它的 **`Bot Token`**(如 `123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11`)。
3. 获取机器人的 **`Bot Name`**(就是你刚才设置的以 `bot` 结尾的用户名,例如 `my_2fa_bot`)
4. (非常重要)发送 `/setdomain` 指令,选择你刚才创建的机器人,然后发送你的应用域名(不需要 `https://`,例如:`2fa.yourdomain.workers.dev`)。
5. (非常重要)配置 Webhook
```bash
curl "https://api.telegram.org/bot<你的BotToken>/setWebhook?url=https://<你设置的setdomain>/api/telegram/webhook"
```
### 第四步:配置 GitHub Actions Secrets
前往你 Fork 的 GitHub 仓库的 **Settings** -> **Secrets and variables** -> **Actions** -> **New repository secret**,添加以下必填环境变量:
#### ☁️ 必填:Cloudflare 资源授权
| Secret Name | 说明 | 示例值 |
| :--- | :--- | :--- |
| `CLOUDFLARE_ACCOUNT_ID` | 你的 CF 账户 ID | `a1b2c3d4e5f6g7h8i9j0` (CF 面板的概述页右下角) |
| `CLOUDFLARE_API_TOKEN` | CF 部署令牌 [前往获取](https://dash.cloudflare.com/profile/api-tokens) <br> 点击“创建令牌” -> “创建自定义令牌” <br/> 权限 1: **账户** -> **D1** -> **编辑** <br/>权限 2: **账户** -> **Workers 脚本** -> **编辑** | `r4_xyz...` |
| `CLOUDFLARE_D1_DATABASE_ID` | D1 数据库 ID | xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` |
#### 🔐 必填:系统安全参数
| Secret Name | 说明 | 示例值 |
| :--- | :--- | :--- |
| `OAUTH_ALLOWED_USERS` | 允许登录的账户白名单!防止陌生人登录。 | `me@example.com,github_user` (逗号分隔) |
| `JWT_SECRET` | 签发登录状态的系统密钥 | `random_string_here`,越长越好,建议32位 |
| `ENCRYPTION_KEY` | 对数据库中关键数据底层加密密钥 | 建议使用 `openssl rand -hex 16` 生成 |
#### 🔑 必填:Cloudflare D1数据库参数
| Secret Name | 说明 | 示例值 |
| :--- | :--- | :--- |
| `CLOUDFLARE_D1_DATABASE_ID` | Cloudflare D1 数据库 ID | `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx` |
| `ENCRYPTION_KEY` | Cloudflare D1 数据库名称 | 默认为 `2fauth-db` |
#### 🔑 必填:OAuth 身份信息 (按需选择对应的组合)
> 如果你用 GitHub 登录,就填下面三个;如果你用 Google,就填 Google 的那三个。
| 平台 | Client ID 变量组 (示例) | Client Secret 变量组 (示例) | Redirect URI 变量组 (示例) |
| :--- | :--- | :--- | :--- |
| **GitHub** | `OAUTH_GITHUB_CLIENT_ID`<br>`Ov23...` | `OAUTH_GITHUB_CLIENT_SECRET`<br>`a1b2...` | `OAUTH_GITHUB_REDIRECT_URI`<br>`https://xxx.workers.dev/oauth/callback` |
| **Telegram** | `OAUTH_TELEGRAM_BOT_NAME`<br>`my_2fa_bot` | `OAUTH_TELEGRAM_BOT_TOKEN`<br>`123:ABC...` | *Telegram 无需 URI 变量* |
| **Google** | `OAUTH_GOOGLE_CLIENT_ID`<br>`123...apps.googleusercontent.com` | `OAUTH_GOOGLE_CLIENT_SECRET`<br>`GOCSPX-...` | `OAUTH_GOOGLE_REDIRECT_URI`<br>`https://xxx.workers.dev/oauth/callback` |
| **Cloudflare Access** | `OAUTH_CLOUDFLARE_CLIENT_ID`<br>`abc.access` | `OAUTH_CLOUDFLARE_CLIENT_SECRET`<br>`def...` | `OAUTH_CLOUDFLARE_REDIRECT_URI`<br>`https://xxx.workers.dev/oauth/callback`<br>*(该平台还需配置 `OAUTH_CLOUDFLARE_ORG_DOMAIN`,如: `https://<team>.cloudflareaccess.com`)* |
| **Gitee** | `OAUTH_GITEE_CLIENT_ID`<br>`bcd...` | `OAUTH_GITEE_CLIENT_SECRET`<br>`efg...` | `OAUTH_GITEE_REDIRECT_URI`<br>`https://xxx.workers.dev/oauth/callback` |
| **NodeLoc** | `OAUTH_NODELOC_CLIENT_ID`<br>`cde...` | `OAUTH_NODELOC_CLIENT_SECRET`<br>`fgh...` | `OAUTH_NODELOC_REDIRECT_URI`<br>`https://xxx.workers.dev/oauth/callback` |
### 第五步:触发自动化部署
1. 进入你的 GitHub 仓库的 **Actions** 页面。
2. 找到 `Deploy to Cloudflare Workers` 工作流。
3. 点击 **Run workflow** 手动触发执行(或者随便 Push 一下 main 分支)。
4. **喝杯咖啡 ☕,等待自动化脚本完成:**
- 📦 自动构建 Vue 3 SPA 前端。
- 🗄️ 自动帮你初始化部署 D1 数据库内的表结构(Schema)。
- ⚡ 自动将带有静态前端的后端服务绑定至 Cloudflare 边缘节点。
5. 部署成功后,控制台会输出你的 `xxx.workers.dev` 链接。登录进去,开始你的私有化 2FA 之旅!
---
## 💻 本地极客开发 (Local Development)
如果你想自己修改 UI 或增加功能,可以很方便地在本地把跑起来(需要 Node.js 20+):
### 1. 克隆项目
```bash
git clone https://github.com/nap0o/2fauth-worker.git
cd 2fauth-worker
```
### 2. 安装依赖
```bash
# 安装前端依赖
cd frontend
npm install
# 1. 给前后端分别安装依赖
cd frontend && npm install
cd ../backend && npm install
# 安装后端依赖
cd ../backend
npm install
```
# 2. 在 backend 目录,修改 example.dev.vars 为 .dev.vars, 按说明写入开发用的测试密钥
### 3. 配置本地环境变量
在 `backend` 目录下创建 `.dev.vars` 文件,填入开发所需的 Secrets:
```ini
JWT_SECRET="本地测试用的JWT密钥"
ENCRYPTION_KEY="本地测试用的32位密钥"
OAUTH_ALLOWED_USERS="你的邮箱@example.com,你的GitHub用户名"
OAUTH_GITHUB_CLIENT_ID="Github测试AppID"
OAUTH_GITHUB_CLIENT_SECRET="Github测试AppSecret"
OAUTH_GITHUB_REDIRECT_URI="http://localhost:5173/oauth/callback"
OAUTH_CLOUDFLARE_CLIENT_ID="Cloudflare Access 测试客户端ID"
OAUTH_CLOUDFLARE_CLIENT_SECRET="Cloudflare Access 测试客户端密码"
OAUTH_CLOUDFLARE_REDIRECT_URI=http://localhost:8787/oauth/callback
OAUTH_CLOUDFLARE_ORG_DOMAIN=https://<你的组织名>.cloudflareaccess.com
```
### 4. 初始化本地数据库
```bash
# 3. 初始化本地的 SQLite Sandbox 数据库
cd backend
npx wrangler d1 execute 2fauth-db-dev --local --env dev --file=schema.sql
# 4. 终端启动应用
# 终端 1:
cd backend && npm run dev
```
### 5. 启动开发服务器
---
**终端 1 (后端):**
```bash
cd backend
npm run dev
```
**终端 2 (前端):**
```bash
cd frontend
npm run dev
```
## 📄 License
本项目基于 [MIT License](LICENSE) 协议完全开源。你可以自由地二次开发、分发或用于商业场景,只要包含原作者和版权声明即可。
+48
View File
@@ -0,0 +1,48 @@
# 本地开发环境变量配置实例,请根据实际情况修改,获取方式请参照项目README.md
# [必填]系统安全参数
JWT_SECRET=YKPc4G**** # 越长越好,建议32位
ENCRYPTION_KEY=U6p5y95OKkK**** # 越长越好,建议32位
# [必填]Cloudflare D1 数据库参数
CLOUDFLARE_ACCOUNT_ID=fbce5f4***
CLOUDFLARE_API_TOKEN=yx5fupqCxK****
# [必填]Cloudflare 资源授权
CLOUDFLARE_D1_DATABASE_ID=9e591736-****
CLOUDFLARE_D1_DATABASE_NAME=2fauth-db-dev
# [必填]登录白名单,支持用户名和邮箱,多个用","分隔
OAUTH_ALLOWED_USERS=abc,abc@gmail.com
#必填:OAuth 身份信息 (按需选择对应的组合),必选一种方式,建议github
# Github
OAUTH_GITHUB_CLIENT_ID=Ov23liIt****
OAUTH_GITHUB_CLIENT_SECRET=cd6de****
OAUTH_GITHUB_REDIRECT_URI=http://localhost:8787/oauth/callback
# Gitee
# OAUTH_GITEE_CLIENT_ID=eecbfa3984c169a5450c4****
# OAUTH_GITEE_CLIENT_SECRET=5dd899196567c800cb6****
# OAUTH_GITEE_REDIRECT_URI=http://localhost:8787/oauth/callback
# Cloudflare Access
# OAUTH_CLOUDFLARE_CLIENT_ID=2c9b737461248bc9f*****
# OAUTH_CLOUDFLARE_CLIENT_SECRET=8bd224be2ca8247*****
# OAUTH_CLOUDFLARE_REDIRECT_URI=http://localhost:8787/oauth/callback
# OAUTH_CLOUDFLARE_ORG_DOMAIN=https://<团队名称>.cloudflareaccess.com
# Telegram
# OAUTH_TELEGRAM_BOT_NAME=Worke*****
# OAUTH_TELEGRAM_BOT_TOKEN=874449***:AAHMrRR*****
# Google
# OAUTH_GOOGLE_CLIENT_ID=3572****-5tdsrdp*****.apps.googleusercontent.com
# OAUTH_GOOGLE_CLIENT_SECRET=GOCSPX-GeCJsHC****
# OAUTH_GOOGLE_REDIRECT_URI=http://localhost:8787/oauth/callback
# Nodeloc
# OAUTH_NODELOC_CLIENT_ID=a0caf693-f9f9-4dda-****
# OAUTH_NODELOC_CLIENT_SECRET=15acd861a9f3a2c12fe2a398086b15****
# OAUTH_NODELOC_REDIRECT_URI=http://localhost:8787/oauth/callback
+6 -3
View File
@@ -2,15 +2,18 @@
"name": "backend",
"type": "module",
"scripts": {
"dev": "wrangler dev src/index.ts --env dev --host 0.0.0.0 --port 8787 --test-scheduled",
"deploy": "wrangler deploy --minify src/index.ts"
"dev": "wrangler dev --config wrangler.dev.jsonc --host 0.0.0.0 --port 8787 --test-scheduled",
"deploy": "wrangler deploy --minify"
},
"dependencies": {
"aws4fetch": "^1.0.19",
"drizzle-orm": "^0.30.9",
"hono": "^4.11.10",
"webdav": "^5.9.0"
},
"devDependencies": {
"@cloudflare/workers-types": "^4.20260226.1",
"drizzle-kit": "^0.20.14",
"wrangler": "^3.0.0"
}
}
}
+6 -6
View File
@@ -1,6 +1,5 @@
-- 账号表:每一行存储一个 2FA 凭据
-- 账号表:存储 2FA 凭据
CREATE TABLE IF NOT EXISTS accounts (
CREATE TABLE IF NOT EXISTS vault (
id TEXT PRIMARY KEY,
service TEXT NOT NULL,
account TEXT NOT NULL,
@@ -8,14 +7,14 @@ CREATE TABLE IF NOT EXISTS accounts (
secret TEXT NOT NULL, -- 加密存储 {encrypted, iv, salt}
digits INTEGER DEFAULT 6,
period INTEGER DEFAULT 30,
algorithm TEXT DEFAULT 'SHA1',
created_at INTEGER,
created_by TEXT,
updated_at INTEGER,
updated_by TEXT
);
-- 备份源配置表
DROP TABLE IF EXISTS webdav_configs;
-- 云端备份源配置表
CREATE TABLE IF NOT EXISTS backup_providers (
id INTEGER PRIMARY KEY AUTOINCREMENT,
type TEXT NOT NULL, -- 类型: 'webdav', 's3'
@@ -24,6 +23,7 @@ CREATE TABLE IF NOT EXISTS backup_providers (
config TEXT NOT NULL, -- 配置 JSON (敏感字段加密)
auto_backup BOOLEAN DEFAULT 0, -- 自动备份开关
auto_backup_password TEXT, -- 自动备份加密密码 (加密存储)
auto_backup_retain INTEGER DEFAULT 30, -- 自动备份保留份数,0为无限
last_backup_at INTEGER, -- 最后备份时间戳
last_backup_status TEXT, -- 状态: 'success' | 'failed'
created_at INTEGER,
@@ -31,5 +31,5 @@ CREATE TABLE IF NOT EXISTS backup_providers (
);
-- 索引
CREATE INDEX IF NOT EXISTS idx_accounts_service ON accounts(service);
CREATE INDEX IF NOT EXISTS idx_accounts_created_at ON accounts(created_at DESC);
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);
@@ -3,7 +3,7 @@ export const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export const SECURITY_CONFIG = {
MAX_LOGIN_ATTEMPTS: 5,
LOCKOUT_TIME: 15 * 60 * 1000,
JWT_EXPIRY: 2 * 60 * 60, // 2小时
JWT_EXPIRY: 24 * 60 * 60, // 24小时
MAX_INPUT_LENGTH: 100,
MIN_EXPORT_PASSWORD_LENGTH: 12,
MAX_OAUTH_ATTEMPTS: 3,
@@ -68,7 +68,7 @@ export type EnvBindings = {
OAUTH_GOOGLE_CLIENT_ID?: string;
OAUTH_GOOGLE_CLIENT_SECRET?: string;
OAUTH_GOOGLE_REDIRECT_URI?: string;
OAUTH_TELEGRAM_BOT_NAME?: string; // e.g. MyAuthBot
OAUTH_TELEGRAM_BOT_NAME?: string;
OAUTH_TELEGRAM_BOT_TOKEN?: string;
OAUTH_ALLOWED_USERS: string; // 允许登录的 Email 或 Username 白名单 (必填)
JWT_SECRET: string;
@@ -2,13 +2,14 @@ import { Hono } from 'hono';
import { cors } from 'hono/cors';
import { logger } from 'hono/logger';
import { secureHeaders } from 'hono/secure-headers';
import { EnvBindings, CSP_POLICY } from './config';
import { EnvBindings, CSP_POLICY } from '@/app/config';
// 稍后我们会在这里引入拆分好的路由模块
import authRoutes from './routes/auth';
import accountsRoutes from './routes/accounts';
import backupRoutes, { handleScheduledBackup } from './routes/backups';
import telegramRoutes from './routes/telegram';
import authRoutes from '@/features/auth/authRoutes';
import vaultRoutes from '@/features/vault/vaultRoutes';
import backupRoutes, { handleScheduledBackup } from '@/features/backup/backupRoutes';
import telegramRoutes from '@/features/telegram/telegramRoutes';
import toolsRoutes from '@/features/tools/toolsRoutes';
// 扩展 EnvBindings 以包含 ASSETS (Cloudflare Pages/Workers Assets)
type Bindings = EnvBindings & { ASSETS: { fetch: (req: Request) => Promise<Response> } };
@@ -46,9 +47,10 @@ app.get('/api', (c) => c.text('🔐 2FA Secure Manager API is running!'));
// 3. 挂载子路由
app.route('/api/oauth', authRoutes);
app.route('/api/accounts', accountsRoutes);
app.route('/api/vault', vaultRoutes); // 'accounts' is now 'vault'
app.route('/api/backups', backupRoutes);
app.route('/api/telegram', telegramRoutes); // 挂载 Telegram Webhook 路由
app.route('/api/telegram', telegramRoutes);
app.route('/api/tools', toolsRoutes);
// 4. API 404 处理 (必须在静态资源 fallback 之前,确保 API 路径返回 JSON)
app.all('/api/*', (c) => {
@@ -72,10 +74,13 @@ app.onError((err, c) => {
}
console.error(`[Server Error] ${err.message}`);
// 标准化响应
return c.json({
code: statusCode,
success: false,
error: err.message || 'Internal Server Error'
}, statusCode);
message: err.message || 'Internal Server Error',
data: null
}, statusCode as any);
});
// 6. 导出默认处理函数,实现前后端路由分发
+104
View File
@@ -0,0 +1,104 @@
import { Hono } from 'hono';
import { setCookie, deleteCookie } from 'hono/cookie';
import { EnvBindings, AppError } from '@/app/config';
import { authMiddleware } from '@/shared/middleware/auth';
import { getAvailableProviders } from '@/features/auth/providers/index';
import { AuthService } from '@/features/auth/authService';
const auth = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
const getService = (c: any) => new AuthService(c.env);
// 获取可用登录方式列表
auth.get('/providers', (c) => {
const providers = getAvailableProviders(c.env);
if (providers.length === 0) {
console.warn('[OAuth] No providers configured. Please check environment variables.');
}
const enhancedProviders = providers.map(p => {
if (p.id === 'telegram') {
const rawName = c.env.OAUTH_TELEGRAM_BOT_NAME || '';
return { ...p, botName: rawName.replace(/^@/, '') };
}
return p;
});
return c.json({ success: true, providers: enhancedProviders });
});
// 获取授权地址
auth.get('/authorize/:provider', async (c) => {
const providerName = c.req.param('provider');
const service = getService(c);
// 生成包含唯一 state 防御 CSRF 的跳转地址
const authData = await service.generateAuthorizeUrl(providerName);
return c.json({
success: true,
authUrl: authData.url,
state: authData.state,
codeVerifier: authData.codeVerifier
});
});
// 核心逻辑:用 Code 换取系统的 JWT 令牌
auth.post('/callback/:provider', async (c) => {
const providerName = c.req.param('provider');
const body = await c.req.json();
const service = getService(c);
// 调用 Service 层处理登录
const { token, userInfo } = await service.handleOAuthCallback(providerName, body);
// 1. 设置 httpOnly 的鉴权 Cookie
setCookie(c, 'auth_token', token, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 7 * 24 * 60 * 60, // 7天
path: '/',
});
// 2. 设置 CSRF Token Cookie
const csrfToken = crypto.randomUUID();
setCookie(c, 'csrf_token', csrfToken, {
httpOnly: false,
secure: true,
sameSite: 'Strict',
maxAge: 7 * 24 * 60 * 60,
path: '/',
});
return c.json({
success: true,
userInfo
});
});
// 退出登录
auth.post('/logout', (c) => {
const cookieOpts = { path: '/', secure: true, sameSite: 'Strict' as const };
deleteCookie(c, 'auth_token', cookieOpts);
deleteCookie(c, 'csrf_token', cookieOpts);
c.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
return c.json({
success: true,
message: 'Logged out successfully'
});
});
// 获取当前用户信息
auth.get('/me', authMiddleware, (c) => {
const user = c.get('user');
c.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
c.header('Pragma', 'no-cache');
c.header('Expires', '0');
return c.json({
success: true,
userInfo: user
});
});
export default auth;
+107
View File
@@ -0,0 +1,107 @@
import { EnvBindings, AppError } from '@/app/config';
import { generateSecureJWT } from '@/shared/utils/crypto';
import { getOAuthProvider } from '@/features/auth/providers/index';
import type { OAuthUserInfo } from '@/features/auth/providers/baseOAuthProvider';
export class AuthService {
private env: EnvBindings;
constructor(env: EnvBindings) {
this.env = env;
}
/**
* 生成提供商的授权重定向地址
*/
async generateAuthorizeUrl(providerName: string): Promise<{ url: string, state: string, codeVerifier?: string }> {
const provider = getOAuthProvider(providerName, this.env);
const state = crypto.randomUUID();
const result = await provider.getAuthorizeUrl(state);
return {
url: result.url,
state: state,
codeVerifier: result.codeVerifier
};
}
/**
* OAuth Callback 处理,进行鉴权并签发系统内部 JWT token
*/
async handleOAuthCallback(providerName: string, body: any): Promise<{ token: string, userInfo: OAuthUserInfo }> {
const provider = getOAuthProvider(providerName, this.env);
let params: string | URLSearchParams;
if (providerName === 'telegram') {
const searchParams = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
params = searchParams;
} else {
if (!body.code) throw new AppError('Missing OAuth code', 400);
params = body.code;
}
const userInfo = await provider.handleCallback(params, body.codeVerifier);
// 白名单检查
this.verifyWhitelist(userInfo, provider.whitelistFields);
// 签发 Token
const token = await this.generateSystemToken(userInfo);
return { token, userInfo };
}
/**
* 白名单校验
*/
private verifyWhitelist(userInfo: OAuthUserInfo, whitelistFields: string[]) {
const allowedUsersStr = this.env.OAUTH_ALLOWED_USERS || '';
const allowedIdentities = allowedUsersStr.split(',').map((e: string) => e.trim().toLowerCase()).filter(Boolean);
const userEmail = (userInfo.email || '').toLowerCase();
const userName = (userInfo.username || '').toLowerCase();
// 仅在配置了白名单且 OAUTH_ALLOWED_USERS 开发用的特定值不同时校验
if (allowedIdentities.length > 0 && this.env.OAUTH_ALLOWED_USERS !== this.env.JWT_SECRET) {
let isAllowed = false;
if (whitelistFields.includes('email') && userEmail && allowedIdentities.includes(userEmail)) {
isAllowed = true;
}
if (whitelistFields.includes('username') && userName && allowedIdentities.includes(userName)) {
isAllowed = true;
}
if (!isAllowed) {
throw new AppError(`Unauthorized user. Email: ${userEmail}, Username: ${userName}`, 403);
}
}
}
/**
* 生成系统内部 Token
*/
private async generateSystemToken(userInfo: OAuthUserInfo): Promise<string> {
const payload = {
userInfo: {
id: userInfo.id,
username: userInfo.username,
email: userInfo.email,
avatar: userInfo.avatar,
provider: userInfo.provider
}
};
if (!this.env.JWT_SECRET) {
throw new AppError('Server configuration error: JWT_SECRET is missing', 500);
}
return await generateSecureJWT(payload, this.env.JWT_SECRET);
}
}
@@ -1,4 +1,4 @@
import { EnvBindings } from '../../config';
import { EnvBindings } from '@/app/config';
export interface OAuthUserInfo {
id: string;
@@ -1,5 +1,5 @@
import { BaseOAuthProvider, OAuthUserInfo } from './BaseOAuthProvider';
import { EnvBindings, AppError } from '../../config';
import { BaseOAuthProvider, OAuthUserInfo } from '@/features/auth/providers/baseOAuthProvider';
import { EnvBindings, AppError } from '@/app/config';
// Move to crypto.ts
// PKCE 辅助函数
@@ -1,5 +1,5 @@
import { BaseOAuthProvider, OAuthUserInfo } from './BaseOAuthProvider';
import { EnvBindings, AppError } from '../../config';
import { BaseOAuthProvider, OAuthUserInfo } from '@/features/auth/providers/baseOAuthProvider';
import { EnvBindings, AppError } from '@/app/config';
export class GiteeProvider extends BaseOAuthProvider {
readonly id = 'gitee';
@@ -1,5 +1,5 @@
import { BaseOAuthProvider, OAuthUserInfo } from './BaseOAuthProvider';
import { EnvBindings, AppError } from '../../config';
import { BaseOAuthProvider, OAuthUserInfo } from '@/features/auth/providers/baseOAuthProvider';
import { EnvBindings, AppError } from '@/app/config';
export class GitHubProvider extends BaseOAuthProvider {
readonly id = 'github';
@@ -1,5 +1,5 @@
import { BaseOAuthProvider, OAuthUserInfo } from './BaseOAuthProvider';
import { EnvBindings, AppError } from '../../config';
import { BaseOAuthProvider, OAuthUserInfo } from '@/features/auth/providers/baseOAuthProvider';
import { EnvBindings, AppError } from '@/app/config';
export class GoogleProvider extends BaseOAuthProvider {
readonly id = 'google';
@@ -1,11 +1,11 @@
import { EnvBindings, AppError } from '../../config';
import { BaseOAuthProvider } from './BaseOAuthProvider';
import { GitHubProvider } from './GitHubProvider';
import { CloudflareAccessProvider } from './CloudflareAccessProvider';
import { NodeLocProvider } from './NodeLocProvider';
import { GiteeProvider } from './GiteeProvider';
import { TelegramProvider } from './TelegramProvider';
import { GoogleProvider } from './GoogleProvider';
import { EnvBindings, AppError } from '@/app/config';
import { BaseOAuthProvider } from '@/features/auth/providers/baseOAuthProvider';
import { GitHubProvider } from '@/features/auth/providers/githubProvider';
import { CloudflareAccessProvider } from '@/features/auth/providers/cloudflareAccessProvider';
import { NodeLocProvider } from '@/features/auth/providers/nodeLocProvider';
import { GiteeProvider } from '@/features/auth/providers/giteeProvider';
import { TelegramProvider } from '@/features/auth/providers/telegramProvider';
import { GoogleProvider } from '@/features/auth/providers/googleProvider';
export function getOAuthProvider(providerId: string, env: EnvBindings): BaseOAuthProvider {
switch (providerId.toLowerCase()) {
@@ -1,5 +1,5 @@
import { BaseOAuthProvider, OAuthUserInfo } from './BaseOAuthProvider';
import { EnvBindings, AppError } from '../../config';
import { BaseOAuthProvider, OAuthUserInfo } from '@/features/auth/providers/baseOAuthProvider';
import { EnvBindings, AppError } from '@/app/config';
export class NodeLocProvider extends BaseOAuthProvider {
readonly id = 'nodeloc';
@@ -1,5 +1,5 @@
import { BaseOAuthProvider, OAuthUserInfo } from './BaseOAuthProvider';
import { EnvBindings, AppError } from '../../config';
import { BaseOAuthProvider, OAuthUserInfo } from '@/features/auth/providers/baseOAuthProvider';
import { EnvBindings, AppError } from '@/app/config';
export class TelegramProvider extends BaseOAuthProvider {
readonly id = 'telegram';
@@ -0,0 +1,73 @@
import { Hono } from 'hono';
import { EnvBindings } from '@/app/config';
import { authMiddleware } from '@/shared/middleware/auth';
import { BackupService } from '@/features/backup/backupService';
const backups = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
backups.use('*', authMiddleware);
backups.get('/providers', async (c) => {
const service = new BackupService(c.env);
const providers = await service.getProvidersList();
return c.json({ success: true, providers });
});
backups.post('/providers', async (c) => {
const service = new BackupService(c.env);
const data = await c.req.json();
const id = await service.addProvider(data);
return c.json({ success: true, id });
});
backups.put('/providers/:id', async (c) => {
const service = new BackupService(c.env);
const id = Number(c.req.param('id'));
const data = await c.req.json();
await service.updateProvider(id, data);
return c.json({ success: true });
});
backups.delete('/providers/:id', async (c) => {
const service = new BackupService(c.env);
const id = Number(c.req.param('id'));
await service.deleteProvider(id);
return c.json({ success: true });
});
backups.post('/providers/test', async (c) => {
const service = new BackupService(c.env);
const { type, config } = await c.req.json();
await service.testConnection(type, config);
return c.json({ success: true, message: 'Connection successful' });
});
backups.post('/providers/:id/backup', async (c) => {
const service = new BackupService(c.env);
const id = Number(c.req.param('id'));
const body = await c.req.json();
await service.executeManualBackup(id, body);
return c.json({ success: true, message: 'Backup successful' });
});
backups.get('/providers/:id/files', async (c) => {
const service = new BackupService(c.env);
const id = Number(c.req.param('id'));
const files = await service.getFiles(id);
return c.json({ success: true, files });
});
backups.post('/providers/:id/download', async (c) => {
const service = new BackupService(c.env);
const id = Number(c.req.param('id'));
const { filename } = await c.req.json();
const content = await service.downloadFile(id, filename);
return c.json({ success: true, content });
});
export async function handleScheduledBackup(env: EnvBindings) {
const service = new BackupService(env);
await service.handleScheduledBackup();
}
export default backups;
@@ -0,0 +1,319 @@
import { eq, desc } from 'drizzle-orm';
import { EnvBindings, AppError } from '@/app/config';
import { BackupRepository } from '@/shared/db/repositories/backupRepository';
import { encryptData, decryptData, encryptBackupFile } from '@/shared/utils/crypto';
import { BackupProvider, WebDavProvider, S3Provider } from '@/features/backup/providers';
import { createDb, decryptField } from '@/shared/db/db';
import { vault as vaultTable, backupProviders } from '@/shared/db/schema';
export class BackupService {
private repository: BackupRepository;
private env: EnvBindings;
private db: any;
constructor(env: EnvBindings) {
this.env = env;
this.db = createDb(env.DB);
this.repository = new BackupRepository(env.DB);
}
private async getProvider(type: string, config: any): Promise<BackupProvider> {
switch (type) {
case 'webdav':
return new WebDavProvider(config);
case 's3':
return new S3Provider(config);
default:
throw new AppError(`Unknown provider type: ${type}`, 400);
}
}
private async processConfigForStorage(type: string, config: any, key: string) {
const processed = { ...config };
if (type === 'webdav' && processed.password) {
processed.password = await encryptData(processed.password, key);
}
if (type === 's3' && processed.secretAccessKey) {
processed.secretAccessKey = await encryptData(processed.secretAccessKey, key);
}
return JSON.stringify(processed);
}
public async processConfigForUsage(type: string, configStr: string, key: string) {
const config = JSON.parse(configStr);
if (type === 'webdav' && config.password) {
config.password = await decryptData(config.password, key);
}
if (type === 's3' && config.secretAccessKey) {
config.secretAccessKey = await decryptData(config.secretAccessKey, key);
}
return config;
}
async getProvidersList() {
const results = await this.repository.findAllSettings();
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
return await Promise.all(results.map(async (row: any) => {
const config = await this.processConfigForUsage(row.type, row.config, key);
return {
...row,
config,
auto_backup: !!row.autoBackup,
auto_backup_password: !!row.autoBackupPassword,
auto_backup_retain: row.autoBackupRetain !== null ? row.autoBackupRetain : 30
};
}));
}
async addProvider(data: any): Promise<number> {
const { type, name, config, autoBackup, autoBackupPassword, autoBackupRetain } = data;
if (!type || !name || !config) throw new AppError('Missing required fields', 400);
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
const encryptedConfig = await this.processConfigForStorage(type, config, key);
let encryptedAutoBackupPwd = null;
if (autoBackup && autoBackupPassword) {
if (autoBackupPassword.length < 12) throw new AppError('Auto-backup password must be at least 12 characters', 400);
encryptedAutoBackupPwd = JSON.stringify(await encryptData(autoBackupPassword, key));
} else if (autoBackup) {
throw new AppError('Auto-backup password is required (min 12 chars)', 400);
}
const res = await this.db.insert(backupProviders).values({
type,
name,
config: encryptedConfig,
autoBackup: autoBackup ? true : false,
autoBackupPassword: encryptedAutoBackupPwd,
autoBackupRetain: autoBackupRetain !== undefined ? parseInt(autoBackupRetain, 10) : 30,
createdAt: Date.now(),
updatedAt: Date.now()
}).returning({ id: backupProviders.id });
return res[0].id;
}
async updateProvider(id: number, data: any) {
const { name, config, type, autoBackup, autoBackupPassword, autoBackupRetain } = data;
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
const encryptedConfig = await this.processConfigForStorage(type, config, key);
const current = await this.db.select({ autoBackupPassword: backupProviders.autoBackupPassword }).from(backupProviders).where(eq(backupProviders.id, id)).get();
let finalAutoPwd = current?.autoBackupPassword;
if (autoBackupPassword) {
if (autoBackupPassword.length < 12) throw new AppError('Auto-backup password must be at least 12 characters', 400);
finalAutoPwd = JSON.stringify(await encryptData(autoBackupPassword, key));
} else if (autoBackup && !finalAutoPwd) {
throw new AppError('Auto-backup password is required (min 12 chars)', 400);
}
await this.db.update(backupProviders).set({
name,
config: encryptedConfig,
autoBackup: autoBackup ? true : false,
autoBackupPassword: finalAutoPwd,
autoBackupRetain: autoBackupRetain !== undefined ? parseInt(autoBackupRetain, 10) : 30,
updatedAt: Date.now()
}).where(eq(backupProviders.id, id)).run();
}
async deleteProvider(id: number) {
await this.db.delete(backupProviders).where(eq(backupProviders.id, id)).run();
}
async testConnection(type: string, config: any) {
try {
const provider = await this.getProvider(type, config);
await provider.testConnection();
} catch (e: any) {
throw new AppError(`Connection failed: ${e.message}`, 400);
}
}
async executeManualBackup(id: number, data: any) {
const { filename, content, password } = data;
let finalFilename: string;
let finalContent: string;
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
if (filename && content) {
finalFilename = filename;
finalContent = content;
} else if (password !== undefined) {
let backupPassword = password;
const providerRow = await this.db.select({ autoBackupPassword: backupProviders.autoBackupPassword }).from(backupProviders).where(eq(backupProviders.id, id)).get();
if (password === '') {
if (!providerRow || !providerRow.autoBackupPassword) {
throw new AppError('Manual backup with auto-password requires auto-backup to be configured with a password.', 400);
}
backupPassword = await decryptData(JSON.parse(providerRow.autoBackupPassword), key);
}
if (!backupPassword || backupPassword.length < 12) {
throw new AppError('Backup password must be at least 12 characters.', 400);
}
const vaultResults = await this.db.select().from(vaultTable).all();
const accounts = (await Promise.all(vaultResults.map(async (row: any) => {
const secret = await decryptField(row.secret, key);
if (!secret) return null;
return {
service: row.service,
account: row.account,
category: row.category,
secret: secret,
digits: row.digits,
period: row.period
};
}))).filter(Boolean);
const exportPayload = {
version: "2.0",
app: "2fauth",
encrypted: true,
timestamp: new Date().toISOString(),
accounts
};
const userEncrypted = await encryptBackupFile(exportPayload, backupPassword);
const fileContent = JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined });
finalFilename = `2fa-backup-manual-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
finalContent = fileContent;
} else {
throw new AppError('Request must include {filename, content} or {password}', 400);
}
const providerRow = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
if (!providerRow) throw new AppError('Provider not found', 404);
const configObj = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
const provider = await this.getProvider(providerRow.type, configObj);
try {
await provider.uploadBackup(finalFilename, finalContent);
await this.db.update(backupProviders).set({
lastBackupAt: Date.now(),
lastBackupStatus: 'success'
}).where(eq(backupProviders.id, id)).run();
} catch (e: any) {
await this.db.update(backupProviders).set({ lastBackupStatus: 'failed' }).where(eq(backupProviders.id, id)).run();
throw new AppError(`Backup failed: ${e.message}`, 500);
}
}
async getFiles(id: number) {
const providerRow = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
if (!providerRow) throw new AppError('Provider not found', 404);
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
const config = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
const provider = await this.getProvider(providerRow.type, config);
return await provider.listBackups();
}
async downloadFile(id: number, filename: string) {
if (!filename) throw new AppError('Filename is required', 400);
const providerRow = await this.db.select().from(backupProviders).where(eq(backupProviders.id, id)).get();
if (!providerRow) throw new AppError('Provider not found', 404);
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
const config = await this.processConfigForUsage(providerRow.type, providerRow.config, key);
const provider = await this.getProvider(providerRow.type, config);
try {
return await provider.downloadBackup(filename);
} catch (e: any) {
throw new AppError(`Download failed: ${e.message}`, 500);
}
}
async handleScheduledBackup() {
console.log('[Backup] Starting scheduled backup...');
const providers = await this.db.select().from(backupProviders).all();
if (!providers || providers.length === 0) {
console.log('[Backup] No backup providers configured.');
return;
}
const key = this.env.ENCRYPTION_KEY || this.env.JWT_SECRET;
const vaultResults = await this.db.select().from(vaultTable).all();
const accounts = (await Promise.all(vaultResults.map(async (row: any) => {
const secret = await decryptField(row.secret, key);
if (!secret) return null;
return {
service: row.service,
account: row.account,
category: row.category,
secret: secret,
digits: row.digits,
period: row.period
};
}))).filter(Boolean);
const exportPayload = {
version: "2.0",
app: "2fauth",
encrypted: true,
timestamp: new Date().toISOString(),
accounts
};
const filename = `2fa-backup-auto-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
for (const row of providers) {
if (!row.autoBackup || !row.autoBackupPassword) continue;
try {
const backupPassword = await decryptData(JSON.parse(row.autoBackupPassword), key);
const userEncrypted = await encryptBackupFile(exportPayload, backupPassword);
const fileContent = JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined });
const config = await this.processConfigForUsage(row.type as string, row.config as string, key);
const provider = await this.getProvider(row.type as string, config);
await provider.uploadBackup(filename, fileContent);
await this.db.update(backupProviders).set({
lastBackupAt: Date.now(),
lastBackupStatus: 'success'
}).where(eq(backupProviders.id, row.id)).run();
console.log(`[Backup] Successfully backed up to ${row.name}`);
const retainCount = row.autoBackupRetain !== null ? row.autoBackupRetain : 30;
if (retainCount > 0) {
try {
const files = await provider.listBackups();
const autoFiles = files.filter((f: any) => f.filename.startsWith('2fa-backup-auto-'))
.sort((a: any, b: any) => b.filename.localeCompare(a.filename));
if (autoFiles.length > retainCount) {
const filesToDelete = autoFiles.slice(retainCount);
for (const fileToDelete of filesToDelete) {
try {
await provider.deleteBackup(fileToDelete.filename);
console.log(`[Backup Prune] Deleted old auto-backup: ${fileToDelete.filename}`);
} catch (delErr: any) {
console.error(`[Backup Prune] Failed to delete ${fileToDelete.filename}: ${delErr.message}`);
}
}
}
} catch (listErr: any) {
console.error(`[Backup Prune] Failed to list backups for pruning: ${listErr.message}`);
}
}
} catch (e: any) {
console.error(`[Backup] Failed to backup to ${row.name}: ${e.message}`);
await this.db.update(backupProviders).set({ lastBackupStatus: 'failed' }).where(eq(backupProviders.id, row.id)).run();
}
}
}
}
@@ -9,4 +9,5 @@ export interface BackupProvider {
listBackups(): Promise<BackupFile[]>;
uploadBackup(filename: string, data: string): Promise<void>;
downloadBackup(filename: string): Promise<string>;
deleteBackup(filename: string): Promise<void>;
}
@@ -0,0 +1,3 @@
export * from '@/features/backup/providers/backupProvider';
export * from '@/features/backup/providers/webDavProvider';
export * from '@/features/backup/providers/s3Provider';
@@ -1,5 +1,5 @@
import { AwsClient } from 'aws4fetch';
import { BackupProvider, BackupFile } from './BackupProvider';
import { BackupProvider, BackupFile } from '@/features/backup/providers/backupProvider';
export class S3Provider implements BackupProvider {
private client: AwsClient;
@@ -22,7 +22,7 @@ export class S3Provider implements BackupProvider {
this.bucket = config.bucket;
// 移除 endpoint 末尾的斜杠
this.endpoint = config.endpoint.replace(/\/+$/, '');
// 处理前缀 (目录),确保不以 / 开头,但以 / 结尾
this.prefix = (config.saveDir || '').trim();
if (this.prefix.startsWith('/')) this.prefix = this.prefix.slice(1);
@@ -51,20 +51,20 @@ export class S3Provider implements BackupProvider {
const url = this.getUrl('', { 'list-type': '2', 'prefix': this.prefix });
const res = await this.client.fetch(url);
if (!res.ok) throw new Error(`Failed to list S3 objects: ${res.status}`);
const text = await res.text();
// 简单的 XML 解析 (避免引入庞大的 XML 解析库)
// 提取 <Contents> 块中的 Key, LastModified, Size
const backups: BackupFile[] = [];
const regex = /<Contents>[\s\S]*?<Key>(.*?)<\/Key>[\s\S]*?<LastModified>(.*?)<\/LastModified>[\s\S]*?<Size>(.*?)<\/Size>[\s\S]*?<\/Contents>/g;
let match;
while ((match = regex.exec(text)) !== null) {
const fullKey = match[1];
const lastModified = match[2];
const size = parseInt(match[3], 10);
// 过滤掉非 JSON 备份文件
const filename = fullKey.replace(this.prefix, '');
if (filename.startsWith('2fa-backup-') && filename.endsWith('.json')) {
@@ -75,20 +75,20 @@ export class S3Provider implements BackupProvider {
});
}
}
return backups.sort((a, b) => b.filename.localeCompare(a.filename));
}
async uploadBackup(filename: string, data: string): Promise<void> {
const key = this.prefix + filename;
const url = this.getUrl(key);
const res = await this.client.fetch(url, {
method: 'PUT',
body: data,
headers: { 'Content-Type': 'application/json' }
});
if (!res.ok) throw new Error(`S3 Upload Failed: ${res.status}`);
}
@@ -99,4 +99,13 @@ export class S3Provider implements BackupProvider {
if (!res.ok) throw new Error(`S3 Download Failed: ${res.status}`);
return await res.text();
}
async deleteBackup(filename: string): Promise<void> {
const key = this.prefix + filename;
const url = this.getUrl(key);
const res = await this.client.fetch(url, {
method: 'DELETE'
});
if (!res.ok) throw new Error(`S3 Delete Failed: ${res.status}`);
}
}
@@ -0,0 +1,159 @@
import { createClient, WebDAVClient } from 'webdav';
import { BackupProvider, BackupFile } from '@/features/backup/providers/backupProvider';
export class WebDavProvider implements BackupProvider {
private client: WebDAVClient;
private saveDir: string;
private config: any;
constructor(config: any) {
if (!config.url || !config.username || !config.password) {
throw new Error('WebDAV configuration incomplete');
}
this.config = config;
this.client = createClient(config.url, {
username: config.username,
password: config.password
});
// 标准化 saveDir: 确保以 / 开头,且不以 / 结尾 (除非是根目录)
let dir = (config.saveDir || '/').trim();
if (!dir.startsWith('/')) dir = '/' + dir;
if (dir.length > 1 && dir.endsWith('/')) dir = dir.slice(0, -1);
this.saveDir = dir;
}
async testConnection(): Promise<boolean> {
await this.client.getDirectoryContents('/');
return true;
}
async listBackups(): Promise<BackupFile[]> {
const items = await this.client.getDirectoryContents(this.saveDir);
return (items as any[])
.filter(item => item.type === 'file' && item.basename.startsWith('2fa-backup-') && item.basename.endsWith('.json'))
.map(item => {
// Try to parse date from filename if lastmod is missing or generic
let displayTime = item.lastmod;
return {
filename: item.basename,
path: item.filename, // 返回 WebDAV 服务器提供的绝对路径
size: item.size,
lastModified: displayTime
};
})
.sort((a, b) => b.filename.localeCompare(a.filename));
}
private getAuthHeader() {
const str = `${this.config.username}:${this.config.password}`;
try { return 'Basic ' + btoa(unescape(encodeURIComponent(str))); }
catch { return 'Basic ' + btoa(str); }
}
private getRequestUrl(path: string) {
let baseUrl = this.config.url.trim();
if (baseUrl.endsWith('/')) baseUrl = baseUrl.slice(0, -1);
let cleanPath = path.replace(/\/+/g, '/');
if (!cleanPath.startsWith('/')) cleanPath = '/' + cleanPath;
const encodedPath = cleanPath.split('/').map(encodeURIComponent).join('/');
const cleanUrl = new URL(baseUrl + encodedPath);
cleanUrl.username = '';
cleanUrl.password = '';
return cleanUrl.toString();
}
async uploadBackup(filename: string, data: string): Promise<void> {
const fullPath = this.saveDir === '/' ? `/${filename}` : `${this.saveDir}/${filename}`;
// 核心修复: 将 payload 字符串强制转换为 ArrayBuffer (Uint8Array的底层Buffer),
// 这将阻止底层 fetch / axios 遇到大文本时采用 "Transfer-Encoding: chunked" 分块流传输。
// Nextcloud (SabreDAV) 出于严格审查拒绝接受分块流,从而避免报出 8KB 截断或 400 Bad Request。
const bufferData = new TextEncoder().encode(data).buffer;
try {
if (this.saveDir !== '/') {
const dirExists = await this.client.exists(this.saveDir);
if (!dirExists) {
await this.client.createDirectory(this.saveDir);
}
}
await this.client.putFileContents(fullPath, bufferData as any);
} catch (e: any) {
console.error('[WebDAV Fallback] Original upload failed:', e.message);
// Fallback for strict WebDAV servers like Nextcloud (400 Bad Request, 405 Method Not Allowed)
if (this.saveDir !== '/') {
const dirUrl = this.getRequestUrl(this.saveDir);
await fetch(dirUrl, { method: 'MKCOL', headers: { 'Authorization': this.getAuthHeader() } }).catch(() => { });
}
const fileUrl = this.getRequestUrl(fullPath);
const res = await fetch(fileUrl, {
method: 'PUT',
body: bufferData,
headers: {
'Authorization': this.getAuthHeader(),
'Content-Type': 'application/json',
'Content-Length': bufferData.byteLength.toString() // 依然显式声明长度确保稳妥
}
});
if (!res.ok) {
const errText = await res.text().catch(() => '');
throw new Error(`WebDAV Fallback Upload Failed: ${res.status} ${res.statusText} ${errText}`);
}
}
}
async downloadBackup(filename: string): Promise<string> {
const cleanFilename = filename.trim().replace(/^\/+/, '');
const fullPath = this.saveDir === '/' ? `/${cleanFilename}` : `${this.saveDir}/${cleanFilename}`;
try {
const content = await this.client.getFileContents(fullPath, { format: 'text' });
return content as string;
} catch (e: any) {
console.error('[WebDAV Fallback] Original download failed:', e.message);
// 应对重定向跨站或者特殊 CDN 的强壮 Native Fetch Fallback
const fileUrl = this.getRequestUrl(fullPath);
// 阶段 1:尝试不跟随重定向,拦截 301/302 以获取被保护的实际下载直链
const res = await fetch(fileUrl, {
headers: { 'Authorization': this.getAuthHeader() },
redirect: 'manual'
});
if (res.status >= 300 && res.status < 400) {
const redirectUrl = res.headers.get('location');
if (redirectUrl) {
// 由于跨域重定向规范限制,直接抓取 location 发出裸请求(通常是带有签名的 CDN 下载直链,已无需附带 Authorization)
const finalRes = await fetch(redirectUrl);
if (!finalRes.ok) throw new Error(`Redirect fetch failed: ${finalRes.status}`);
return await finalRes.text();
}
} else if (!res.ok) {
throw new Error(`WebDAV Fallback Download Failed: ${res.status} ${res.statusText}`);
}
return await res.text();
}
}
async deleteBackup(filename: string): Promise<void> {
const cleanFilename = filename.trim().replace(/^\/+/, '');
const fullPath = this.saveDir === '/' ? `/${cleanFilename}` : `${this.saveDir}/${cleanFilename}`;
try {
await this.client.deleteFile(fullPath);
} catch (e: any) {
console.error('[WebDAV Fallback] Original delete failed:', e.message);
const fileUrl = this.getRequestUrl(fullPath);
const res = await fetch(fileUrl, {
method: 'DELETE',
headers: { 'Authorization': this.getAuthHeader() }
});
if (!res.ok) throw new Error(`WebDAV Fallback Delete Failed: ${res.status} ${res.statusText}`);
}
}
}
@@ -1,5 +1,5 @@
import { Hono } from 'hono';
import { EnvBindings } from '../config';
import { EnvBindings } from '@/app/config';
const telegram = new Hono<{ Bindings: EnvBindings }>();
@@ -0,0 +1,2 @@
// This file is a placeholder for Telegram-related business logic.
export const telegramService = {};
+14
View File
@@ -0,0 +1,14 @@
import { Hono } from 'hono';
import { EnvBindings } from '@/app/config';
import { authMiddleware } from '@/shared/middleware/auth';
const tools = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
tools.use('/*', authMiddleware);
// 获取服务器时间 (用于前端本地时间校准)
tools.get('/server-time', (c) => {
return c.json({ success: true, time: Date.now() });
});
export default tools;
+161
View File
@@ -0,0 +1,161 @@
import { Hono } from 'hono';
import { EnvBindings, AppError } from '@/app/config';
import { authMiddleware } from '@/shared/middleware/auth';
import { VaultService } from '@/features/vault/vaultService';
import { VaultRepository } from '@/shared/db/repositories/vaultRepository';
import { createDb } from '@/shared/db/db'; // Ensure D1 database is mapped correctly temporarily
const vault = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
// 在路由中使用单例/工厂
const getService = (c: any) => {
// 假设你还需要 createDb 这样封装 D1
const repo = new VaultRepository(c.env.DB);
return new VaultService(c.env, repo);
};
vault.use('/*', authMiddleware);
// 获取所有账户 (分页+搜索)
vault.get('/', async (c) => {
const user = c.get('user');
// 解析查询参数
const page = parseInt(c.req.query('page') || '1', 10);
const limit = parseInt(c.req.query('limit') || '12', 10);
const search = c.req.query('search') || '';
const service = getService(c);
const result = await service.getAccountsPaginated(page, limit, search);
return c.json({
success: true,
vault: result.items,
pagination: {
page,
limit,
totalItems: result.totalCount,
totalPages: result.totalPages
}
});
});
// 添加新账户
vault.post('/', async (c) => {
const user = c.get('user');
const data = await c.req.json();
const service = getService(c);
const item = await service.createAccount(user.username, data);
return c.json({ success: true, item });
});
// 更新账户
vault.put('/:id', async (c) => {
const user = c.get('user');
const id = c.req.param('id');
const data = await c.req.json();
const service = getService(c);
const item = await service.updateAccount(id, data);
return c.json({ success: true, item });
});
// 删除账户
vault.delete('/:id', async (c) => {
const user = c.get('user');
const id = c.req.param('id');
const service = getService(c);
await service.deleteAccount(id);
return c.json({ success: true, message: 'Deleted successfully' });
});
// 批量删除账户
vault.post('/batch-delete', async (c) => {
const user = c.get('user');
const { ids } = await c.req.json();
if (!Array.isArray(ids) || ids.length === 0) {
return c.json({ success: false, error: 'ids must be a non-empty array' }, 400);
}
const service = getService(c);
const result = await service.batchDeleteAccounts(ids);
return c.json({ success: true, count: result.count });
});
// 导出账户
vault.post('/export', async (c) => {
const user = c.get('user');
const service = getService(c);
const { type, password } = await c.req.json();
const result = await service.exportAccounts(type, password);
if (result.isText) {
return c.text(result.data as string);
}
return c.json(result.data);
});
// 导入账户
vault.post('/import', async (c) => {
const user = c.get('user');
const service = getService(c);
const { content, type, password } = await c.req.json();
const result = await service.importAccounts(user.username, type, content, password);
return c.json({ success: true, ...result });
});
// 从 otpauth:// URI 添加账户 (扫码流程使用)
vault.post('/add-from-uri', async (c) => {
const user = c.get('user');
const { uri, category } = await c.req.json();
if (!uri || typeof uri !== 'string') {
return c.json({ success: false, error: 'URI is required' }, 400);
}
// 宽松解析:兼容实际扫码场景(不严格限制 secret 长度和 period 范围)
let parsed: { issuer: string; account: string; secret: string; algorithm: string; digits: number; period: number } | null = null;
try {
const url = new URL(uri);
if (url.protocol === 'otpauth:' && (url.hostname === 'totp' || url.hostname === 'hotp')) {
const label = decodeURIComponent(url.pathname.substring(1));
const [issuerFromLabel, accountPart] = label.includes(':') ? label.split(':', 2) : ['', label];
const params = url.searchParams;
const secret = params.get('secret');
if (secret) {
parsed = {
issuer: params.get('issuer') || issuerFromLabel || 'Unknown',
account: accountPart?.trim() || label.trim(),
secret: secret.replace(/\s/g, '').toUpperCase(),
algorithm: (params.get('algorithm') || 'SHA1').toUpperCase(),
digits: parseInt(params.get('digits') || '6', 10),
period: parseInt(params.get('period') || '30', 10),
};
}
}
} catch { /* invalid URL */ }
if (!parsed) {
return c.json({ success: false, error: '无效的 otpauth:// URI 格式' }, 400);
}
const service = getService(c);
const item = await service.createAccount(user.username, {
service: parsed.issuer,
account: parsed.account,
secret: parsed.secret,
algorithm: parsed.algorithm,
digits: parsed.digits,
period: parsed.period,
category: category || '手机扫码',
});
return c.json({ success: true, item });
});
// 弃用的迁移接口
vault.post('/migrate-crypto', async (c) => {
return c.json({ success: true, message: '不再支持旧版盐值迁移逻辑,所有数据默认已使用新版逻辑', migrated: 0, remaining: 0 });
});
export default vault;
+291
View File
@@ -0,0 +1,291 @@
import { EnvBindings, AppError } from '@/app/config';
import { VaultRepository } from '@/shared/db/repositories/vaultRepository';
import { encryptField, decryptField, batchInsertVaultItems } from '@/shared/db/db';
import { encryptData, decryptData } from '@/shared/utils/crypto';
import { parseOTPAuthURI, validateBase32Secret } from '@/shared/utils/totp';
export class VaultService {
private repository: VaultRepository;
private env: EnvBindings;
private encryptionKey: string;
constructor(env: EnvBindings, repository: VaultRepository) {
this.env = env;
this.repository = repository;
this.encryptionKey = env.ENCRYPTION_KEY || env.JWT_SECRET;
}
/**
* 获取所有账户 (解密)
*/
async getAllAccounts() {
const items = await this.repository.findAll();
return Promise.all(items.map(async (item) => ({
...item,
secret: await decryptField(item.secret, this.encryptionKey) || ''
})));
}
/**
* 获取分页和搜索条件后的所有账户 (解密)
*/
async getAccountsPaginated(page: number, limit: number, search: string) {
const items = await this.repository.findPaginated(page, limit, search);
const totalCount = await this.repository.count(search);
const decryptedItems = await Promise.all(items.map(async (item) => ({
...item,
secret: await decryptField(item.secret, this.encryptionKey) || ''
})));
return {
items: decryptedItems,
totalCount,
totalPages: Math.ceil(totalCount / limit) || 1
};
}
/**
* 创建账户
*/
async createAccount(userId: string, data: any) {
const { service, account, category, secret, digits, period, algorithm } = data;
if (!service || !account || !secret || !validateBase32Secret(secret)) {
throw new AppError('Invalid account data or secret format', 400);
}
const normalizedSecret = secret.replace(/\s/g, '').toUpperCase();
const encryptedSecret = await encryptField(normalizedSecret, this.encryptionKey);
return await this.repository.create({
id: crypto.randomUUID(),
service,
account,
category: category || '',
secret: encryptedSecret,
algorithm: algorithm || 'SHA1',
digits: digits || 6,
period: period || 30,
createdAt: Date.now(),
createdBy: userId
});
}
/**
* 更新账户
*/
async updateAccount(id: string, data: any) {
const { service, account, category, secret, digits, period, algorithm } = data;
if (!service || !account) {
throw new AppError('Service and account are required', 400);
}
// secret 可选:若未传则保留数据库中已有的加密值
let encryptedSecret: string;
if (secret) {
if (!validateBase32Secret(secret)) {
throw new AppError('Invalid secret format (must be Base32, min 16 chars)', 400);
}
const normalizedSecret = secret.replace(/\s/g, '').toUpperCase();
encryptedSecret = await encryptField(normalizedSecret, this.encryptionKey);
} else {
// 取出现有记录,复用已有加密值
const existing = await this.repository.findById(id);
if (!existing) throw new AppError('Account not found', 404);
encryptedSecret = existing.secret;
}
const updated = await this.repository.update(id, {
service,
account,
category: category || '',
secret: encryptedSecret,
...(algorithm && { algorithm }),
...(digits && { digits }),
...(period && { period }),
});
if (!updated) throw new AppError('Account not found', 404);
return updated;
}
/**
* 删除账户
*/
async deleteAccount(id: string) {
const success = await this.repository.delete(id);
if (!success) throw new AppError('Account not found', 404);
}
async batchDeleteAccounts(ids: string[]) {
if (!ids || ids.length === 0) throw new AppError('No IDs provided', 400);
const count = await this.repository.batchDelete(ids);
return { count };
}
/**
* 处理导出
*/
async exportAccounts(type: string, password?: string) {
const SECURITY_CONFIG = { MIN_EXPORT_PASSWORD_LENGTH: 5 };
if (!['encrypted', 'json', '2fas', 'text'].includes(type)) {
throw new AppError('Invalid export type', 400);
}
if (type === 'encrypted') {
if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) {
throw new AppError(`导出密码至少需要 ${SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH} 个字符`, 400);
}
}
const plainItems = await this.getAllAccounts();
const timestamp = new Date().toISOString();
const baseData = { version: "2.0", app: "2fauth", timestamp };
if (type === 'encrypted') {
const exportData = { ...baseData, encrypted: true, accounts: plainItems };
// 注意:encryptData 已经在 shared/utils/crypto 引入
const encryptedContent = await encryptData(exportData, password!);
return {
data: { ...baseData, encrypted: true, data: encryptedContent, note: "This file is encrypted with your export password. Keep it safe!" },
isText: false
};
} else if (type === 'json') {
return { data: { ...baseData, encrypted: false, accounts: plainItems }, isText: false };
} else if (type === '2fas') {
// 2FAS 导出格式:字段放在 otp 子对象中,尤其是 account/digits/period/algorithm
const services = plainItems.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 { data: { schemaVersion: 4, appOrigin: 'export', services }, isText: false };
} else if (type === 'text') {
const lines = plainItems.map(acc => {
const label = encodeURIComponent(`${acc.service}:${acc.account}`);
const issuer = encodeURIComponent(acc.service);
const algo = acc.algorithm || 'SHA1';
return `otpauth://totp/${label}?secret=${acc.secret}&issuer=${issuer}&algorithm=${algo}&digits=${acc.digits}&period=${acc.period}`;
});
return { data: lines.join('\n'), isText: true };
}
throw new AppError('Unhandled export type', 500);
}
/**
* 处理导入
*/
async importAccounts(userId: string, type: string, content: string, password?: string) {
if (!content || !type) throw new AppError('内容和类型不能为空', 400);
let validAccounts: any[] = [];
try {
if (type === 'encrypted') {
if (!password) throw new AppError('加密导入需要密码', 400);
const encryptedFile = JSON.parse(content);
const decryptedData = await decryptData(encryptedFile.data, password);
validAccounts = decryptedData.accounts || [];
} else if (type === 'json') {
const data = JSON.parse(content);
// 格式 A: 本项目自身导出的 JSON (data.accounts)
if (data.accounts) {
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 || '',
}));
// 格式 C: 旧版 2FAuth 开源项目导出 (data.data[])
} else if (data.app && data.app.includes('2fauth') && Array.isArray(data.data)) {
validAccounts = data.data.map((item: any) => ({ ...item, category: '' }));
// 格式 D: 简单数组
} else if (Array.isArray(data)) {
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 || '',
}));
}
} 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 || '',
}));
}
} else if (type === 'text') {
const lines = content.split('\n').filter((line: string) => line.trim());
for (const line of lines) {
if (line.trim().startsWith('otpauth://')) {
const parsed = parseOTPAuthURI(line.trim());
if (parsed) validAccounts.push({
service: parsed.issuer, account: parsed.account,
secret: parsed.secret, algorithm: parsed.algorithm,
digits: parsed.digits, period: parsed.period, category: ''
});
}
}
}
} catch (e) {
throw new AppError('解析失败,请检查文件格式或密码是否正确', 400);
}
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 uniqueAccountsToInsert = [];
const seenInBatch = new Set();
for (const acc of validAccounts) {
if (acc.service && acc.account && validateBase32Secret(acc.secret)) {
const signature = `${acc.service}:${acc.account}`;
if (!existingSet.has(signature) && !seenInBatch.has(signature)) {
uniqueAccountsToInsert.push(acc);
seenInBatch.add(signature);
}
}
}
const insertedCount = await batchInsertVaultItems(this.env.DB, uniqueAccountsToInsert, this.encryptionKey, userId);
return { count: insertedCount, total: validAccounts.length, duplicates: validAccounts.length - insertedCount };
}
}
@@ -1,62 +0,0 @@
import { createClient, WebDAVClient } from 'webdav';
import { BackupProvider, BackupFile } from './BackupProvider';
export class WebDavProvider implements BackupProvider {
private client: WebDAVClient;
private saveDir: string;
constructor(config: any) {
if (!config.url || !config.username || !config.password) {
throw new Error('WebDAV configuration incomplete');
}
this.client = createClient(config.url, {
username: config.username,
password: config.password
});
// 标准化 saveDir: 确保以 / 开头,且不以 / 结尾 (除非是根目录)
let dir = (config.saveDir || '/').trim();
if (!dir.startsWith('/')) dir = '/' + dir;
if (dir.length > 1 && dir.endsWith('/')) dir = dir.slice(0, -1);
this.saveDir = dir;
}
async testConnection(): Promise<boolean> {
await this.client.getDirectoryContents('/');
return true;
}
async listBackups(): Promise<BackupFile[]> {
const items = await this.client.getDirectoryContents(this.saveDir);
return (items as any[])
.filter(item => item.type === 'file' && item.basename.startsWith('2fa-backup-') && item.basename.endsWith('.json'))
.map(item => {
// Try to parse date from filename if lastmod is missing or generic
let displayTime = item.lastmod;
return {
filename: item.basename,
path: item.filename, // 返回 WebDAV 服务器提供的绝对路径
size: item.size,
lastModified: displayTime
};
})
.sort((a, b) => b.filename.localeCompare(a.filename));
}
async uploadBackup(filename: string, data: string): Promise<void> {
if (this.saveDir !== '/' && await this.client.exists(this.saveDir) === false) {
await this.client.createDirectory(this.saveDir);
}
const fullPath = this.saveDir === '/' ? `/${filename}` : `${this.saveDir}/${filename}`;
await this.client.putFileContents(fullPath, data);
}
async downloadBackup(filename: string): Promise<string> {
const cleanFilename = filename.trim().replace(/^\/+/, '');
const fullPath = this.saveDir === '/' ? `/${cleanFilename}` : `${this.saveDir}/${cleanFilename}`;
const content = await this.client.getFileContents(fullPath, { format: 'text' });
return content as string;
}
}
-3
View File
@@ -1,3 +0,0 @@
export * from './BackupProvider';
export * from './WebDavProvider';
export * from './S3Provider';
-417
View File
@@ -1,417 +0,0 @@
import { Hono } from 'hono';
import { EnvBindings, AppError, SECURITY_CONFIG } from '../config';
import { authMiddleware, sanitizeInput, validateBase32Secret, validateServiceName, validateAccountName, parseOTPAuthURI } from '../utils/helper';
import { encryptData, decryptData, generateTOTP } from '../utils/crypto';
import { encryptField, decryptField, batchInsertAccounts, batchDeleteAccounts } from '../utils/db';
const accounts = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
// 挂载鉴权中间件
accounts.use('*', authMiddleware);
// 获取所有账号
accounts.get('/', async (c) => {
const pageStr = c.req.query('page');
const limitStr = c.req.query('limit');
const search = c.req.query('search') || '';
const page = pageStr ? parseInt(pageStr) : 1;
const limit = limitStr ? parseInt(limitStr) : 50; // 恢复默认 50 条,配合自动迁移策略
let whereClause = '';
let params: any[] = [];
if (search) {
whereClause = " WHERE (service LIKE ? OR account LIKE ? OR category LIKE ?)";
const likeTerm = `%${search}%`;
params.push(likeTerm, likeTerm, likeTerm);
}
const countResult = await c.env.DB.prepare(`SELECT COUNT(*) as total FROM accounts${whereClause}`).bind(...params).first();
const total = countResult?.total as number || 0;
const query = `SELECT * FROM accounts${whereClause} ORDER BY created_at DESC LIMIT ? OFFSET ?`;
params.push(limit, (page - 1) * limit);
const { results } = await c.env.DB.prepare(query).bind(...params).all();
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
// 并行解密所有账号的 secret
const accountsList = await Promise.all(results.map(async (row: any) => {
const secret = await decryptField(row.secret, key);
// [自动迁移] 旧格式 (含 salt) 异步升级为极速格式
try {
const raw = JSON.parse(row.secret);
if (raw.salt && raw.salt.length > 0) {
const newSecret = await encryptField(secret, key);
c.executionCtx.waitUntil(
c.env.DB.prepare("UPDATE accounts SET secret = ? WHERE id = ?")
.bind(newSecret, row.id)
.run()
);
}
} catch (e) { /* ignore error */ }
return {
id: row.id,
service: row.service,
account: row.account,
category: row.category,
secret: secret || '', // 解密失败则为空
digits: row.digits,
period: row.period,
createdAt: row.created_at
};
}));
const response: any = { success: true, accounts: accountsList };
response.pagination = {
page,
limit,
total,
totalPages: Math.ceil(total / limit)
};
// 关键修复:禁止浏览器缓存账号列表数据,确保用户添加/删除后能立即看到最新状态
c.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
return c.json(response);
});
// 获取服务器当前时间 (用于时间校准)
accounts.get('/server-time', (c) => {
return c.json({ success: true, time: Date.now() });
});
// 添加新账号
accounts.post('/', async (c) => {
const { service, category, account, secret, digits = 6, period = 30 } = await c.req.json();
const user = c.get('user');
if (!validateServiceName(service) || !validateAccountName(account) || !validateBase32Secret(secret)) {
throw new AppError('Invalid input format', 400);
}
if (![6, 8].includes(digits) || ![30, 60].includes(period)) {
throw new AppError('Invalid digits or period', 400);
}
// 查重
const existing = await c.env.DB.prepare(
"SELECT id FROM accounts WHERE service = ? AND account = ?"
).bind(service, account).first();
if (existing) throw new AppError('Account already exists', 409);
const id = crypto.randomUUID();
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
const secretEncrypted = await encryptField(secret.replace(/\s/g, '').toUpperCase(), key);
// 插入
await c.env.DB.prepare(
`INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).bind(
id, sanitizeInput(service, 50), sanitizeInput(account, 100), category ? sanitizeInput(category, 30) : '',
secretEncrypted, digits, period, Date.now(), user.username
).run();
const newAccount = { id, service, account, category, digits, period };
return c.json({ success: true, account: { ...newAccount, secret: '[PROTECTED]' } });
});
// 修改账号信息
accounts.put('/:id', async (c) => {
const id = c.req.param('id');
const { service, category, account, digits, period } = await c.req.json();
const user = c.get('user');
if (!validateServiceName(service) || !validateAccountName(account)) {
throw new AppError('Invalid input format', 400);
}
if (digits && ![6, 8].includes(digits)) throw new AppError('Invalid digits', 400);
if (period && ![30, 60].includes(period)) throw new AppError('Invalid period', 400);
const result = await c.env.DB.prepare(
`UPDATE accounts
SET service = ?, account = ?, category = ?, digits = COALESCE(?, digits), period = COALESCE(?, period), updated_at = ?, updated_by = ?
WHERE id = ?`
).bind(
sanitizeInput(service, 50), sanitizeInput(account, 100), category ? sanitizeInput(category, 30) : '',
digits || null, period || null, Date.now(), user.username, id
).run();
if (!result.meta.changes) throw new AppError('Account not found', 404);
return c.json({ success: true, message: 'Account updated successfully' });
});
// 删除指定账号
accounts.delete('/:id', async (c) => {
const id = c.req.param('id');
const result = await c.env.DB.prepare("DELETE FROM accounts WHERE id = ?").bind(id).run();
if (!result.meta.changes) throw new AppError('Account not found', 404);
return c.json({ success: true, message: 'Account deleted successfully' });
});
// 批量删除账号
accounts.post('/batch-delete', async (c) => {
const { ids } = await c.req.json();
if (!Array.isArray(ids) || ids.length === 0) throw new AppError('No IDs provided', 400);
const count = await batchDeleteAccounts(c.env.DB, ids);
return c.json({ success: true, count, message: `Successfully deleted ${count} accounts` });
});
// 清空所有账号
accounts.delete('/clear-all', async (c) => {
await c.env.DB.prepare("DELETE FROM accounts").run();
return c.json({ success: true, message: 'All accounts cleared' });
});
// 生成 TOTP 验证码
accounts.post('/generate-totp', async (c) => {
const { secret, period = 30, digits = 6 } = await c.req.json();
if (!validateBase32Secret(secret)) throw new AppError('Invalid secret format', 400);
if (![6, 8].includes(digits) || ![30, 60].includes(period)) throw new AppError('Invalid parameters', 400);
const code = await generateTOTP(secret, period, digits);
return c.json({ success: true, code });
});
// 解析 TOTP URI
accounts.post('/parse-uri', async (c) => {
const { uri } = await c.req.json();
if (!uri) throw new AppError('URI is required', 400);
const account = parseOTPAuthURI(uri);
if (!account) throw new AppError('Invalid OTP Auth URI', 400);
return c.json({ success: true, account });
});
// 从 URI 添加账户
accounts.post('/add-from-uri', async (c) => {
const { uri, category } = await c.req.json();
const user = c.get('user');
if (!uri) throw new AppError('URI is required', 400);
const parsedAccount = parseOTPAuthURI(uri);
if (!parsedAccount) throw new AppError('Invalid OTP Auth URI', 400);
// 查重
const existing = await c.env.DB.prepare(
"SELECT id FROM accounts WHERE service = ? AND account = ?"
).bind(parsedAccount.issuer, parsedAccount.account).first();
if (existing) throw new AppError('该账号已经存在金库中', 409);
const id = crypto.randomUUID();
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
const secretEncrypted = await encryptField(parsedAccount.secret, key);
await c.env.DB.prepare(
`INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
).bind(
id, sanitizeInput(parsedAccount.issuer || 'Unknown', 50), sanitizeInput(parsedAccount.account || 'Unknown', 100),
category ? sanitizeInput(category, 30) : '', secretEncrypted, parsedAccount.digits, parsedAccount.period, Date.now(), user.username
).run();
const newAccount = { id, service: parsedAccount.issuer, account: parsedAccount.account };
return c.json({ success: true, account: { ...newAccount, secret: '[PROTECTED]' } });
});
// 通用导出接口 (支持 加密/JSON/2FAS/文本)
accounts.post('/export', async (c) => {
const { type, password } = await c.req.json();
if (!['encrypted', 'json', '2fas', 'text'].includes(type)) {
throw new AppError('Invalid export type', 400);
}
if (type === 'encrypted') {
if (!password || password.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) {
throw new AppError(`导出密码至少需要 ${SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH} 个字符`, 400);
}
}
const { results } = await c.env.DB.prepare("SELECT * FROM accounts").all();
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
// 解密所有数据
const plainAccounts = await Promise.all(results.map(async (row: any) => ({
service: row.service,
account: row.account,
category: row.category,
secret: await decryptField(row.secret, key) || '',
digits: row.digits,
period: row.period
})));
const timestamp = new Date().toISOString();
const baseData = { version: "2.0", app: "2fauth", timestamp };
if (type === 'encrypted') {
const exportData = { ...baseData, encrypted: true, accounts: plainAccounts };
const encryptedContent = await encryptData(exportData, password);
return c.json({
...baseData, encrypted: true, data: encryptedContent,
note: "This file is encrypted with your export password. Keep it safe!"
});
} else if (type === 'json') {
return c.json({ ...baseData, encrypted: false, accounts: plainAccounts });
} else if (type === '2fas') {
// 2FAS 兼容格式
const services = plainAccounts.map(acc => ({
name: acc.service,
secret: acc.secret,
otp: { account: acc.account, digits: acc.digits, period: acc.period, algorithm: "SHA1", issuer: acc.service }
}));
return c.json({ services });
} else if (type === 'text') {
// 纯文本 URI 格式
const lines = plainAccounts.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}`;
});
return c.text(lines.join('\n'));
}
});
// 导入数据 (支持 加密/JSON/2FAS/文本)
accounts.post('/import', async (c) => {
const { content, type, password } = await c.req.json();
const user = c.get('user');
if (!content || !type) throw new AppError('内容和类型不能为空', 400);
let validAccounts: any[] = [];
try {
if (type === 'encrypted') {
if (!password) throw new AppError('加密导入需要密码', 400);
const encryptedFile = JSON.parse(content);
const decryptedData = await decryptData(encryptedFile.data, password);
validAccounts = decryptedData.accounts || [];
} else if (type === 'json') {
const data = JSON.parse(content);
if (data.app && data.app.includes('2fauth') && Array.isArray(data.data)) {
validAccounts = data.data.map((item: any) => ({...item, category: ''}));
} else if (data.accounts) {
validAccounts = data.accounts;
} else if (Array.isArray(data)) {
validAccounts = data;
} else if (data.services) {
validAccounts = data.services.map((s: any) => ({
service: s.service || s.name, account: s.account || s.login,
secret: s.secret, digits: s.digits, period: s.period, category: s.group
}));
}
} else if (type === '2fas') {
const data = JSON.parse(content);
if (data.services) {
validAccounts = data.services.map((s: any) => ({
service: s.name, account: s.account || s.username,
secret: s.secret, digits: s.digits, period: s.period, category: s.category || s.group
}));
}
} else if (type === 'text') {
const lines = content.split('\n').filter((line: string) => line.trim());
for (const line of lines) {
if (line.trim().startsWith('otpauth://')) {
const parsed = parseOTPAuthURI(line.trim());
if (parsed) validAccounts.push({
service: parsed.issuer, account: parsed.account,
secret: parsed.secret, digits: parsed.digits, period: parsed.period, category: ''
});
}
}
}
} catch (e) {
throw new AppError('解析失败,请检查文件格式或密码是否正确', 400);
}
// 过滤有效账户并去重保存
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
// 1. 预先获取所有已存在的账号标识,避免 N+1 查询
const { results: existingRows } = await c.env.DB.prepare("SELECT service, account FROM accounts").all();
// 内存中快速查重 (格式: service:account)
const existingSet = new Set(existingRows.map((row: any) => `${row.service}:${row.account}`));
// 2. 内存过滤与并行加密
const uniqueAccountsToInsert = [];
const seenInBatch = new Set();
for (const acc of validAccounts) {
if (acc.service && acc.account && validateBase32Secret(acc.secret)) {
const signature = `${acc.service}:${acc.account}`;
// 查重:既不在数据库中,也不在当前待插入的批次中
if (!existingSet.has(signature) && !seenInBatch.has(signature)) {
uniqueAccountsToInsert.push(acc);
seenInBatch.add(signature);
}
}
}
// 3. 使用封装好的批量插入函数
const insertedCount = await batchInsertAccounts(c.env.DB, uniqueAccountsToInsert, key, user.username);
return c.json({ success: true, count: insertedCount, total: validAccounts.length, duplicates: validAccounts.length - insertedCount });
});
// 手动触发数据迁移 (旧格式 -> 极速格式)
accounts.post('/migrate-crypto', async (c) => {
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
const limit = 50; // 每次处理 50 条以防超时
// D1 JSON 查询:查找仍包含 salt 的记录
const { results } = await c.env.DB.prepare(
"SELECT id, secret FROM accounts WHERE json_extract(secret, '$.salt') IS NOT NULL LIMIT ?"
).bind(limit).all();
if (results.length === 0) {
return c.json({ success: true, message: '所有数据已是最新格式', migrated: 0, remaining: 0 });
}
// 并行处理解密和加密
const updates = (await Promise.all(results.map(async (row: any) => {
try {
const plain = await decryptField(row.secret as string, key);
const newSecret = await encryptField(plain, key);
return c.env.DB.prepare("UPDATE accounts SET secret = ? WHERE id = ?").bind(newSecret, row.id);
} catch (e) {
console.error(`Failed to migrate account ${row.id}`, e);
return null;
}
}))).filter(Boolean);
// 分批执行更新
const BATCH_SIZE = 20;
for (let i = 0; i < updates.length; i += BATCH_SIZE) {
const chunk = updates.slice(i, i + BATCH_SIZE);
if (chunk.length > 0) await c.env.DB.batch(chunk);
}
// 检查剩余数量
const remaining = await c.env.DB.prepare(
"SELECT COUNT(*) as count FROM accounts WHERE json_extract(secret, '$.salt') IS NOT NULL"
).first('count');
return c.json({
success: true,
migrated: updates.length,
remaining: remaining,
message: `已迁移 ${updates.length} 条数据,剩余 ${remaining} 条。请继续点击直到剩余为 0。`
});
});
export default accounts;
-175
View File
@@ -1,175 +0,0 @@
import { Hono } from 'hono';
import { setCookie, deleteCookie } from 'hono/cookie';
import { EnvBindings, AppError } from '../config';
import { generateSecureJWT } from '../utils/crypto';
import { authMiddleware } from '../utils/helper';
import { getOAuthProvider, getAvailableProviders } from '../providers/oauth/index';
const auth = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
// ==========================================
// 0. 获取可用登录方式列表 (新增)
// ==========================================
auth.get('/providers', (c) => {
const providers = getAvailableProviders(c.env);
if (providers.length === 0) {
console.warn('[OAuth] No providers configured. Please check environment variables (e.g. .dev.vars).');
}
// 注入 Telegram Bot Name 给前端 Widget 使用
const enhancedProviders = providers.map(p => {
if (p.id === 'telegram') {
// 自动移除可能存在的 @ 前缀,防止 Widget 加载失败
const rawName = c.env.OAUTH_TELEGRAM_BOT_NAME || '';
return { ...p, botName: rawName.replace(/^@/, '') };
}
return p;
});
return c.json({ success: true, providers: enhancedProviders });
});
// ==========================================
// 1. 获取授权地址 (支持动态 Provider)
// ==========================================
auth.get('/authorize/:provider', async (c) => {
const providerName = c.req.param('provider');
const state = crypto.randomUUID();
const env = c.env;
try {
const provider = getOAuthProvider(providerName, env);
const result = await provider.getAuthorizeUrl(state);
return c.json({ success: true, authUrl: result.url, state, codeVerifier: result.codeVerifier });
} catch (e: any) {
throw new AppError(e.message || 'Provider not supported', 400);
}
});
// ==========================================
// 2. 核心逻辑:用 Code 换取系统的 JWT 令牌
// ==========================================
auth.post('/callback/:provider', async (c) => {
const providerName = c.req.param('provider');
const body = await c.req.json(); // 获取完整 Body
const env = c.env;
const provider = getOAuthProvider(providerName, env);
// 构造参数:Telegram 需要验证所有字段,其他 Provider 只需要 code
let params: string | URLSearchParams;
if (providerName === 'telegram') {
params = new URLSearchParams();
Object.entries(body).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
params.append(key, String(value));
}
});
} else {
if (!body.code) throw new AppError('Missing OAuth code', 400);
params = body.code;
}
const userInfo = await provider.handleCallback(params, body.codeVerifier);
// 严密的安全校验:基于 Email 或 Username 的白名单 (OAUTH_WHITELIST)
const allowedUsersStr = env.OAUTH_ALLOWED_USERS || '';
const allowedIdentities = allowedUsersStr.split(',').map((e: string) => e.trim().toLowerCase()).filter(Boolean);
const userEmail = (userInfo.email || '').toLowerCase();
const userName = (userInfo.username || '').toLowerCase();
// 如果白名单不为空,则必须匹配允许的字段 (Email 或 Username)
if (allowedIdentities.length && env.OAUTH_ALLOWED_USERS !== env.JWT_SECRET) {
let isAllowed = false;
// 1. 检查 Email (如果 Provider 允许)
if (provider.whitelistFields.includes('email') && userEmail && allowedIdentities.includes(userEmail)) {
isAllowed = true;
}
// 2. 检查 Username (如果 Provider 允许)
if (provider.whitelistFields.includes('username') && userName && allowedIdentities.includes(userName)) {
isAllowed = true;
}
if (! isAllowed) {
throw new AppError(`Unauthorized user. Email: ${userEmail}, Username: ${userName}`, 403);
}
}
// 签发我们系统的专属 JWT 令牌
const payload = {
userInfo: {
id: userInfo.id,
username: userInfo.username,
email: userInfo.email,
avatar: userInfo.avatar,
provider: userInfo.provider
}
};
if (!env.JWT_SECRET) {
throw new AppError('Server configuration error: JWT_SECRET is missing', 500);
}
const token = await generateSecureJWT(payload, env.JWT_SECRET);
// 1. 设置 httpOnly 的鉴权 Cookie (前端无法读取,防 XSS)
setCookie(c, 'auth_token', token, {
httpOnly: true,
secure: true,
sameSite: 'Strict',
maxAge: 7 * 24 * 60 * 60, // 7天
path: '/',
});
// 2. 设置 CSRF Token Cookie (前端可以读取,用于请求头)
const csrfToken = crypto.randomUUID();
setCookie(c, 'csrf_token', csrfToken, {
httpOnly: false, // 允许前端 JS 读取
secure: true,
sameSite: 'Strict',
maxAge: 7 * 24 * 60 * 60,
path: '/',
});
return c.json({
success: true,
userInfo: payload.userInfo
});
});
// ==========================================
// 2.5 退出登录 (清除 Cookies)
// ==========================================
auth.post('/logout', (c) => {
// 关键修复:删除 Cookie 时必须显式指定 path: '/',否则默认是当前路径 (/api/oauth/),导致无法删除根路径下的 Cookie
// 同时建议匹配 secure 和 sameSite 属性以确保精准命中
const cookieOpts = { path: '/', secure: true, sameSite: 'Strict' as const };
deleteCookie(c, 'auth_token', cookieOpts);
deleteCookie(c, 'csrf_token', cookieOpts);
// 增加防缓存头,防止某些浏览器或代理缓存了该请求的响应
c.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
return c.json({
success: true,
message: 'Logged out successfully'
});
});
// ==========================================
// 3. 获取当前用户信息 (前端 Token 变为 httpOnly 后需要此接口)
// ==========================================
auth.get('/me', authMiddleware, (c) => {
const user = c.get('user');
// 禁止浏览器缓存此接口,防止登录状态更新不及时
c.header('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
c.header('Pragma', 'no-cache');
c.header('Expires', '0');
return c.json({
success: true,
userInfo: user
});
});
export default auth;
-342
View File
@@ -1,342 +0,0 @@
import { Hono } from 'hono';
import { EnvBindings, AppError, SECURITY_CONFIG } from '../config';
import { authMiddleware, sanitizeInput } from '../utils/helper';
import { encryptData, decryptData } from '../utils/crypto';
import { BackupProvider, WebDavProvider, S3Provider } from '../providers/backup';
import { batchInsertAccounts, decryptField } from '../utils/db';
const backups = new Hono<{ Bindings: EnvBindings, Variables: { user: any } }>();
backups.use('*', authMiddleware);
// 工厂函数:获取 Provider 实例
async function getProvider(type: string, config: any): Promise<BackupProvider> {
switch (type) {
case 'webdav':
return new WebDavProvider(config);
case 's3':
return new S3Provider(config);
default:
throw new AppError(`Unknown provider type: ${type}`, 400);
}
}
// 辅助函数:加密配置敏感字段 (存储前)
async function processConfigForStorage(type: string, config: any, key: string) {
const processed = { ...config };
if (type === 'webdav') {
if (processed.password) {
processed.password = await encryptData(processed.password, key);
}
}
if (type === 's3') {
if (processed.secretAccessKey) {
processed.secretAccessKey = await encryptData(processed.secretAccessKey, key);
}
}
return JSON.stringify(processed);
}
// 辅助函数:解密配置敏感字段 (使用前)
async function processConfigForUsage(type: string, configStr: string, key: string) {
const config = JSON.parse(configStr);
if (type === 'webdav') {
if (config.password) {
config.password = await decryptData(config.password, key);
}
}
if (type === 's3') {
if (config.secretAccessKey) {
config.secretAccessKey = await decryptData(config.secretAccessKey, key);
}
}
return config;
}
// 获取备份源列表
backups.get('/providers', async (c) => {
const { results } = await c.env.DB.prepare("SELECT * FROM backup_providers ORDER BY created_at DESC").all();
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
const providers = await Promise.all(results.map(async (row: any) => {
// 解密配置供前端回显 (前端需自行处理掩码)
const config = await processConfigForUsage(row.type, row.config, key);
return {
...row,
config,
auto_backup: !!row.auto_backup,
auto_backup_password: !!row.auto_backup_password // 仅返回是否存在密码的标记
};
}));
return c.json({ success: true, providers });
});
// 添加备份源
backups.post('/providers', async (c) => {
const { type, name, config, autoBackup, autoBackupPassword } = await c.req.json();
if (!type || !name || !config) throw new AppError('Missing required fields', 400);
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
const encryptedConfig = await processConfigForStorage(type, config, key);
let encryptedAutoBackupPwd = null;
if (autoBackup && autoBackupPassword) {
if (autoBackupPassword.length < 12) throw new AppError('Auto-backup password must be at least 12 characters', 400);
encryptedAutoBackupPwd = JSON.stringify(await encryptData(autoBackupPassword, key));
} else if (autoBackup) {
throw new AppError('Auto-backup password is required (min 12 chars)', 400);
}
const res = await c.env.DB.prepare(
"INSERT INTO backup_providers (type, name, config, auto_backup, auto_backup_password, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)"
).bind(type, name, encryptedConfig, autoBackup ? 1 : 0, encryptedAutoBackupPwd, Date.now(), Date.now()).run();
return c.json({ success: true, id: res.meta.last_row_id });
});
// 更新备份源
backups.put('/providers/:id', async (c) => {
const id = c.req.param('id');
const { name, config, type, autoBackup, autoBackupPassword } = await c.req.json();
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
const encryptedConfig = await processConfigForStorage(type, config, key);
// 获取当前配置以处理密码更新
const current: any = await c.env.DB.prepare("SELECT auto_backup_password FROM backup_providers WHERE id = ?").bind(id).first();
let finalAutoPwd = current?.auto_backup_password;
if (autoBackupPassword) {
if (autoBackupPassword.length < 12) throw new AppError('Auto-backup password must be at least 12 characters', 400);
finalAutoPwd = JSON.stringify(await encryptData(autoBackupPassword, key));
} else if (autoBackup && !finalAutoPwd) {
// 开启了自动备份,没传新密码,且数据库里也没有旧密码
throw new AppError('Auto-backup password is required (min 12 chars)', 400);
}
await c.env.DB.prepare(
"UPDATE backup_providers SET name = ?, config = ?, auto_backup = ?, auto_backup_password = ?, updated_at = ? WHERE id = ?"
).bind(name, encryptedConfig, autoBackup ? 1 : 0, finalAutoPwd, Date.now(), id).run();
return c.json({ success: true });
});
// 删除备份源
backups.delete('/providers/:id', async (c) => {
const id = c.req.param('id');
await c.env.DB.prepare("DELETE FROM backup_providers WHERE id = ?").bind(id).run();
return c.json({ success: true });
});
// 测试连接
backups.post('/providers/test', async (c) => {
const { type, config } = await c.req.json();
try {
const provider = await getProvider(type, config);
await provider.testConnection();
return c.json({ success: true, message: 'Connection successful' });
} catch (e: any) {
throw new AppError(`Connection failed: ${e.message}`, 400);
}
});
// 执行备份 (导出)
backups.post('/providers/:id/backup', async (c) => {
const id = c.req.param('id');
const { password } = await c.req.json();
// 获取 Provider 配置
const providerRow: any = await c.env.DB.prepare("SELECT * FROM backup_providers WHERE id = ?").bind(id).first();
if (!providerRow) throw new AppError('Provider not found', 404);
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
// 确定加密密码 (优先使用传入密码,其次尝试自动备份密码)
let encryptionPassword = password;
if (!encryptionPassword && providerRow.auto_backup && providerRow.auto_backup_password) {
try {
encryptionPassword = await decryptData(JSON.parse(providerRow.auto_backup_password), key);
} catch (e) {
console.error('Failed to decrypt auto backup password', e);
}
}
if (!encryptionPassword || encryptionPassword.length < SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH) {
throw new AppError(`Password required (min ${SECURITY_CONFIG.MIN_EXPORT_PASSWORD_LENGTH} chars) or enable auto-backup with a valid password`, 400);
}
const config = await processConfigForUsage(providerRow.type, providerRow.config, key);
const provider = await getProvider(providerRow.type, config);
// 准备导出数据
const { results: accountResults } = await c.env.DB.prepare("SELECT * FROM accounts").all();
// 并行解密,忽略失败项
const accounts = (await Promise.all(accountResults.map(async (row: any) => {
const secret = await decryptField(row.secret, key);
if (!secret) return null; // 如果某条数据解密失败(如密钥轮换遗留的旧数据),则跳过,不影响整体备份
return {
service: row.service,
account: row.account,
category: row.category,
secret: secret,
digits: row.digits,
period: row.period
};
}))).filter(Boolean);
const exportPayload = {
version: "2.0",
app: "2fauth",
encrypted: true,
timestamp: new Date().toISOString(),
accounts
};
// 加密导出数据
const userEncrypted = await encryptData(exportPayload, encryptionPassword);
const fileContent = JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined });
// 上传文件
const filename = `2fa-backup-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
try {
await provider.uploadBackup(filename, fileContent);
// 更新状态
await c.env.DB.prepare("UPDATE backup_providers SET last_backup_at = ?, last_backup_status = 'success' WHERE id = ?")
.bind(Date.now(), id).run();
return c.json({ success: true, message: 'Backup successful' });
} catch (e: any) {
await c.env.DB.prepare("UPDATE backup_providers SET last_backup_status = 'failed' WHERE id = ?")
.bind(id).run();
throw new AppError(`Backup failed: ${e.message}`, 500);
}
});
// 获取文件列表
backups.get('/providers/:id/files', async (c) => {
const id = c.req.param('id');
const providerRow: any = await c.env.DB.prepare("SELECT * FROM backup_providers WHERE id = ?").bind(id).first();
if (!providerRow) throw new AppError('Provider not found', 404);
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
const config = await processConfigForUsage(providerRow.type, providerRow.config, key);
const provider = await getProvider(providerRow.type, config);
const files = await provider.listBackups();
return c.json({ success: true, files });
});
// 恢复备份
backups.post('/providers/:id/restore', async (c) => {
const id = c.req.param('id');
const { filename, password } = await c.req.json();
const providerRow: any = await c.env.DB.prepare("SELECT * FROM backup_providers WHERE id = ?").bind(id).first();
if (!providerRow) throw new AppError('Provider not found', 404);
const key = c.env.ENCRYPTION_KEY || c.env.JWT_SECRET;
const config = await processConfigForUsage(providerRow.type, providerRow.config, key);
const provider = await getProvider(providerRow.type, config);
// 下载文件
let content: string;
try {
content = await provider.downloadBackup(filename);
} catch (e: any) {
throw new AppError(`Restore download failed: ${e.message}`, 500);
}
// 解析并解密
let accounts = [];
try {
const backupFile = JSON.parse(content);
if (!backupFile.data) throw new Error('Legacy format or invalid file');
const decrypted = await decryptData(backupFile.data, password); // 使用用户输入的密码解密
accounts = decrypted.accounts || [];
} catch (e: any) {
throw new AppError('解密失败:密码错误或文件格式不兼容', 400);
}
// 重置数据库并批量插入
await c.env.DB.prepare("DELETE FROM accounts").run();
const insertedCount = await batchInsertAccounts(c.env.DB, accounts, key, 'restore');
return c.json({ success: true, message: 'Restore successful', count: insertedCount });
});
// 定时备份任务 (Cron Handler)
export async function handleScheduledBackup(env: EnvBindings) {
console.log('[Backup] Starting scheduled backup...');
// 获取所有备份源
const { results: providers } = await env.DB.prepare("SELECT * FROM backup_providers").all();
if (!providers || providers.length === 0) {
console.log('[Backup] No backup providers configured.');
return;
}
const key = env.ENCRYPTION_KEY || env.JWT_SECRET;
// 准备备份数据 (系统密钥解密)
const { results: accountResults } = await env.DB.prepare("SELECT * FROM accounts").all();
const accounts = (await Promise.all(accountResults.map(async (row: any) => {
const secret = await decryptField(row.secret, key);
if (!secret) return null;
return {
service: row.service,
account: row.account,
category: row.category,
secret: secret,
digits: row.digits,
period: row.period
};
}))).filter(Boolean);
const exportPayload = {
version: "2.0",
app: "2fauth",
encrypted: true,
timestamp: new Date().toISOString(),
accounts
};
const filename = `2fa-backup-auto-${new Date().toISOString().replace(/[:.]/g, '-')}.json`;
// 遍历上传
for (const row of providers) {
// 跳过未开启自动备份或未设置密码的
if (!row.auto_backup || !row.auto_backup_password) continue;
try {
// 解密该 Provider 的自动备份密码
const backupPassword = await decryptData(JSON.parse(row.auto_backup_password), key);
// 使用该密码加密数据
const userEncrypted = await encryptData(exportPayload, backupPassword);
const fileContent = JSON.stringify({ ...exportPayload, data: userEncrypted, accounts: undefined });
const config = await processConfigForUsage(row.type as string, row.config as string, key);
const provider = await getProvider(row.type as string, config);
await provider.uploadBackup(filename, fileContent);
await env.DB.prepare("UPDATE backup_providers SET last_backup_at = ?, last_backup_status = 'success' WHERE id = ?")
.bind(Date.now(), row.id).run();
console.log(`[Backup] Successfully backed up to ${row.name}`);
} catch (e: any) {
console.error(`[Backup] Failed to backup to ${row.name}: ${e.message}`);
await env.DB.prepare("UPDATE backup_providers SET last_backup_status = 'failed' WHERE id = ?")
.bind(row.id).run();
}
}
}
export default backups;
+95
View File
@@ -0,0 +1,95 @@
import { drizzle } from 'drizzle-orm/d1';
import { inArray } from 'drizzle-orm';
import { D1Database } from '@cloudflare/workers-types';
import * as schema from '@/shared/db/schema';
import { encryptData, decryptData } from '@/shared/utils/crypto';
import { sanitizeInput } from '@/shared/utils/common';
// 创建 Drizzle 实例的工厂函数
export const createDb = (d1: D1Database) => drizzle(d1, { schema });
// 加密并序列化字段
export async function encryptField(data: any, key: string) {
const encrypted = await encryptData(data, key);
return JSON.stringify(encrypted);
}
// 反序列化并解密字段
export async function decryptField(encryptedStr: string, key: string) {
try {
const encryptedObj = JSON.parse(encryptedStr);
return await decryptData(encryptedObj, key);
} catch (e) {
console.error('Decryption failed', e);
return null;
}
}
/**
* 批量插入金库项目 (数据清洗 + 并行加密 + 分批写入)
* @param db D1 数据库实例
* @param items 待插入的原始项目列表
* @param key 加密密钥
* @param createdBy 创建者标识 (username 或 'restore')
* @returns 成功插入的数量
*/
export async function batchInsertVaultItems(
db: D1Database,
items: any[],
key: string,
createdBy: string
): Promise<number> {
const dbClient = createDb(db);
// 1. 准备数据 (规范化、加密、生成ID)
const preparedItems = await Promise.all(items.map(async (item) => {
// 规范化密钥 (去除空格,转大写)
const normalizedSecret = (item.secret || '').replace(/\s/g, '').toUpperCase();
const secretEncrypted = await encryptField(normalizedSecret, key);
return {
id: crypto.randomUUID(),
service: sanitizeInput(item.service, 50),
account: sanitizeInput(item.account, 100),
category: item.category ? sanitizeInput(item.category, 30) : '',
secret: secretEncrypted, // Drizzle schema 字段名
algorithm: item.algorithm || 'SHA1',
digits: item.digits || 6,
period: item.period || 30,
createdAt: Date.now(), // camelCase 匹配 Drizzle schema
createdBy: createdBy, // camelCase 匹配 Drizzle schema
};
}));
// 2. 逐行写入:D1 每条 SQL 最多 100 个绑定参数,vault 表约 12 列
// 单行插入 = 12 变量,远低于限制,彻底规避超限问题
for (const item of preparedItems) {
await dbClient.insert(schema.vault).values(item).run();
}
return preparedItems.length;
}
/**
* 批量删除金库项目 (分批 + 并行)
* @param db D1 数据库实例
* @param ids 待删除的项目 ID 列表
* @returns 成功删除的数量
*/
export async function batchDeleteVaultItems(
db: D1Database,
ids: string[]
): Promise<number> {
if (!ids || ids.length === 0) return 0;
const dbClient = createDb(db);
// Drizzle 的 inArray 操作非常简洁
const CHUNK_SIZE = 50;
for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
const chunk = ids.slice(i, i + CHUNK_SIZE);
await dbClient.delete(schema.vault).where(inArray(schema.vault.id, chunk)).run();
}
return ids.length; // D1 delete 返回结果不包含 count,这里近似返回
}
@@ -0,0 +1,65 @@
import { eq, desc } from 'drizzle-orm';
import { D1Database } from '@cloudflare/workers-types';
import { createDb } from '@/shared/db/db';
import { backupProviders, type BackupProvider, type NewBackupProvider } from '@/shared/db/schema';
export class BackupRepository {
private db;
constructor(d1: D1Database) {
this.db = createDb(d1);
}
/**
* 获取所有启用的备份提供商配置 (按名称排序)
*/
async findAllSettings(): Promise<BackupProvider[]> {
return await this.db
.select()
.from(backupProviders)
.orderBy(desc(backupProviders.updatedAt));
}
/**
* 根据提供商类型获取配置
*/
async findByType(type: string): Promise<BackupProvider | undefined> {
const result = await this.db
.select()
.from(backupProviders)
.where(eq(backupProviders.type, type))
.limit(1);
return result[0];
}
/**
* 保存或更新某个提供商的配置
*/
async upsert(type: string, data: Partial<NewBackupProvider>): Promise<BackupProvider> {
const existing = await this.findByType(type);
if (existing) {
const result = await this.db
.update(backupProviders)
.set({ ...data, updatedAt: Date.now() })
.where(eq(backupProviders.type, type))
.returning();
return result[0];
} else {
// 需要构造默认参数
const insertData: NewBackupProvider = {
type,
name: data.name || type.toUpperCase(),
config: data.config || '{}',
isEnabled: data.isEnabled ?? true,
autoBackup: data.autoBackup ?? false,
autoBackupPassword: data.autoBackupPassword || null,
createdAt: Date.now(),
updatedAt: Date.now()
};
const result = await this.db.insert(backupProviders).values(insertData).returning();
return result[0];
}
}
}
@@ -0,0 +1,157 @@
import { eq, inArray, desc, like, or, and, sql } from 'drizzle-orm';
import { D1Database } from '@cloudflare/workers-types';
import { createDb } from '@/shared/db/db';
import { vault, type VaultItem, type NewVaultItem } from '@/shared/db/schema';
export class VaultRepository {
private db;
constructor(d1: D1Database) {
this.db = createDb(d1);
}
/**
* 获取所有的 vault items (2FA accounts)
*/
async findAll(): Promise<VaultItem[]> {
return await this.db
.select()
.from(vault)
.orderBy(desc(vault.createdAt));
}
/**
* 获取分页和搜索过滤的 vault items
*/
async findPaginated(page: number, limit: number, search: string): Promise<VaultItem[]> {
const offset = (page - 1) * limit;
// 构建条件
let condition;
if (search) {
const pattern = `%${search}%`;
condition = or(
like(vault.service, pattern),
like(vault.account, pattern),
like(vault.category, pattern)
);
}
let query = this.db
.select()
.from(vault);
if (condition) {
query = query.where(condition) as any;
}
return await query
.orderBy(desc(vault.createdAt))
.limit(limit)
.offset(offset);
}
/**
* 获取满足条件的总记录数,用于分页计算
*/
async count(search: string): Promise<number> {
let condition;
if (search) {
const pattern = `%${search}%`;
condition = or(
like(vault.service, pattern),
like(vault.account, pattern),
like(vault.category, pattern)
);
}
let query = this.db
.select({ count: sql<number>`count(*)` })
.from(vault);
if (condition) {
query = query.where(condition) as any;
}
const result = await query;
return result[0]?.count || 0;
}
/**
* 根据 ID 获取单个 item
*/
async findById(id: string): Promise<VaultItem | undefined> {
const result = await this.db
.select()
.from(vault)
.where(eq(vault.id, id))
.limit(1);
return result[0];
}
/**
* 创建一个新 item
*/
async create(item: NewVaultItem): Promise<VaultItem> {
const result = await this.db.insert(vault).values(item).returning();
return result[0];
}
/**
* 批量创建
*/
async batchCreate(items: NewVaultItem[]): Promise<void> {
if (!items || items.length === 0) return;
// SQLite D1 limits variables, chunking is good practice
const BATCH_SIZE = 50;
for (let i = 0; i < items.length; i += BATCH_SIZE) {
const chunk = items.slice(i, i + BATCH_SIZE);
await this.db.insert(vault).values(chunk).run();
}
}
/**
* 更新一个 item
*/
async update(id: string, data: Partial<NewVaultItem>): Promise<VaultItem | undefined> {
const existing = await this.findById(id);
if (!existing) return undefined;
const result = await this.db
.update(vault)
.set({ ...data, updatedAt: Date.now() })
.where(eq(vault.id, id))
.returning();
return result[0];
}
/**
* 删除单个 item
*/
async delete(id: string): Promise<boolean> {
const existing = await this.findById(id);
if (!existing) return false;
await this.db.delete(vault).where(eq(vault.id, id)).run();
return true;
}
/**
* 批量删除
*/
async batchDelete(ids: string[]): Promise<number> {
if (!ids || ids.length === 0) return 0;
let deletedCount = 0;
const BATCH_SIZE = 50;
for (let i = 0; i < ids.length; i += BATCH_SIZE) {
const chunk = ids.slice(i, i + BATCH_SIZE);
await this.db.delete(vault).where(inArray(vault.id, chunk)).run();
deletedCount += chunk.length;
}
return deletedCount;
}
}
+39
View File
@@ -0,0 +1,39 @@
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
// 1. 金库项目表 (Vault Items -> mapped to 'vault' table in D1)
export const vault = sqliteTable('vault', {
id: text('id').primaryKey(), // UUID
service: text('service').notNull(),
account: text('account').notNull(),
category: text('category'),
secret: text('secret').notNull(), // 加密后的密文
digits: integer('digits').default(6),
period: integer('period').default(30),
algorithm: text('algorithm').default('SHA-1'),
createdAt: integer('created_at').notNull(),
createdBy: text('created_by'), // 'username' or 'restore'
updatedAt: integer('updated_at'),
updatedBy: text('updated_by'),
});
// 2. 备份提供商表 (Backup Providers)
export const backupProviders = sqliteTable('backup_providers', {
id: integer('id').primaryKey({ autoIncrement: true }),
type: text('type').notNull(), // 'webdav' | 's3'
name: text('name').notNull(),
isEnabled: integer('is_enabled', { mode: 'boolean' }).default(true),
config: text('config').notNull(), // 加密后的 JSON 字符串
autoBackup: integer('auto_backup', { mode: 'boolean' }).default(false),
autoBackupPassword: text('auto_backup_password'), // 加密后的自动备份密码
autoBackupRetain: integer('auto_backup_retain').default(30), // 保留备份数,0代表无限
lastBackupAt: integer('last_backup_at'),
lastBackupStatus: text('last_backup_status'), // 'success' | 'failed'
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
});
// 导出类型定义
export type VaultItem = typeof vault.$inferSelect;
export type NewVaultItem = typeof vault.$inferInsert;
export type BackupProvider = typeof backupProviders.$inferSelect;
export type NewBackupProvider = typeof backupProviders.$inferInsert;
+30
View File
@@ -0,0 +1,30 @@
import { Context, Next } from 'hono';
import { getCookie } from 'hono/cookie';
import { AppError, EnvBindings } from '@/app/config';
import { verifySecureJWT } from '@/shared/utils/crypto';
export async function authMiddleware(c: Context<{ Bindings: EnvBindings, Variables: { user: any } }>, next: Next) {
// 1. 从 Cookie 获取 JWT
const token = getCookie(c, 'auth_token');
if (!token) {
throw new AppError('Unauthorized: Missing session cookie', 401);
}
// 2. CSRF 校验 (Double Submit Cookie)
const csrfCookie = getCookie(c, 'csrf_token');
const csrfHeader = c.req.header('X-CSRF-Token');
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
throw new AppError('Forbidden: CSRF token mismatch', 403);
}
// 3. 验证 JWT
const payload = await verifySecureJWT(token, c.env.JWT_SECRET);
if (!payload || !payload.userInfo) {
throw new AppError('Unauthorized: Token expired or invalid', 401);
}
// 验证通过,将用户信息挂载到上下文,供后续路由使用
c.set('user', payload.userInfo);
await next();
}
+16
View File
@@ -0,0 +1,16 @@
import { SECURITY_CONFIG } from '@/app/config';
export function sanitizeInput(input: any, maxLength = SECURITY_CONFIG.MAX_INPUT_LENGTH): string {
if (typeof input !== 'string') return '';
return input.replace(/[<>"'&\x00-\x1F\x7F]/g, '').trim().substring(0, maxLength);
}
export function validateServiceName(service: any): boolean {
const cleaned = sanitizeInput(service, 50);
return cleaned.length >= 1 && cleaned.length <= 50;
}
export function validateAccountName(account: any): boolean {
const cleaned = sanitizeInput(account, 100);
return cleaned.length >= 1 && cleaned.length <= 100;
}
@@ -1,72 +1,69 @@
import { BASE32_CHARS, SECURITY_CONFIG } from '../config';
// 统一加密标准模块 (Backend Version)
// 算法: AES-GCM-256 + PBKDF2 (SHA-256)
// 目标: 确保前后端生成的加密数据完全互通
// ==========================================
// 1. Base32 与 TOTP 核心算法
// ==========================================
export function base32Decode(encoded: string): Uint8Array {
const cleanInput = encoded.toUpperCase().replace(/[^A-Z2-7]/g, '');
const buffer = new Uint8Array(Math.floor(cleanInput.length * 5 / 8));
let bits = 0, value = 0, index = 0;
for (let i = 0; i < cleanInput.length; i++) {
const charValue = BASE32_CHARS.indexOf(cleanInput[i]);
if (charValue === -1) continue;
value = (value << 5) | charValue;
bits += 5;
if (bits >= 8) {
buffer[index++] = (value >>> (bits - 8)) & 255;
bits -= 8;
}
}
return buffer;
export const CRYPTO_CONFIG = {
ALGO_NAME: 'AES-GCM',
KDF_NAME: 'PBKDF2',
HASH: 'SHA-256',
ITERATIONS: 100000, // 平衡安全性与性能
KEY_LEN: 256,
SALT_LEN: 16,
IV_LEN: 12
};
// 核心:从密码派生密钥
async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const enc = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey(
"raw",
enc.encode(password),
{ name: CRYPTO_CONFIG.KDF_NAME },
false,
["deriveKey"]
);
return crypto.subtle.deriveKey(
{
name: CRYPTO_CONFIG.KDF_NAME,
salt: salt as any,
iterations: CRYPTO_CONFIG.ITERATIONS,
hash: CRYPTO_CONFIG.HASH
},
keyMaterial,
{ name: CRYPTO_CONFIG.ALGO_NAME, length: CRYPTO_CONFIG.KEY_LEN },
false,
["encrypt", "decrypt"]
);
}
export async function hmacSHA1(key: Uint8Array | string, data: number): Promise<Uint8Array> {
const keyBuffer = typeof key === 'string' ? new TextEncoder().encode(key) : key;
const dataBuffer = new ArrayBuffer(8);
new DataView(dataBuffer).setBigUint64(0, BigInt(data), false);
const cryptoKey = await crypto.subtle.importKey('raw', keyBuffer, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, dataBuffer);
return new Uint8Array(signature);
}
export async function generateTOTP(secret: string | Uint8Array, timeStep = 30, digits = 6): Promise<string> {
const time = Math.floor(Date.now() / 1000 / timeStep);
const secretBytes = typeof secret === 'string' ? base32Decode(secret) : secret;
const hmac = await hmacSHA1(secretBytes, time);
const offset = hmac[hmac.length - 1] & 0xf;
const code = (((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff)) % Math.pow(10, digits);
return code.toString().padStart(digits, '0');
}
// ==========================================
// 2. JWT 签发与验证 (原生轻量实现)
// JWT 签发与验证 (原生轻量实现)
// ==========================================
export async function generateSecureJWT(payload: Record<string, any>, secret: string): Promise<string> {
export async function generateSecureJWT(payload: Record<string, any>, secret: string, expiry: number = 86400 * 7): Promise<string> {
const header = { alg: 'HS256', typ: 'JWT', iat: Math.floor(Date.now() / 1000) };
const enhancedPayload = {
...payload,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + SECURITY_CONFIG.JWT_EXPIRY,
jti: crypto.randomUUID()
const enhancedPayload = {
...payload,
iat: Math.floor(Date.now() / 1000),
exp: Math.floor(Date.now() / 1000) + expiry,
jti: crypto.randomUUID()
};
const headerB64 = btoa(JSON.stringify(header)).replace(/[+/=]/g, (m) => ({'+':'-','/':'_','=':''}[m as keyof typeof map] || m));
const payloadB64 = btoa(JSON.stringify(enhancedPayload)).replace(/[+/=]/g, (m) => ({'+':'-','/':'_','=':''}[m as keyof typeof map] || m));
const headerB64 = btoa(JSON.stringify(header)).replace(/[+/=]/g, (m) => ({ '+': '-', '/': '_', '=': '' }[m as keyof { '+': string, '/': string, '=': string }] || m));
const payloadB64 = btoa(JSON.stringify(enhancedPayload)).replace(/[+/=]/g, (m) => ({ '+': '-', '/': '_', '=': '' }[m as keyof { '+': string, '/': string, '=': string }] || m));
const data = `${headerB64}.${payloadB64}`;
const encoder = new TextEncoder();
const cryptoKey = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign']);
const signature = await crypto.subtle.sign('HMAC', cryptoKey, encoder.encode(data));
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/[+/=]/g, (m) => ({'+':'-','/':'_','=':''}[m as keyof typeof map] || m));
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature))).replace(/[+/=]/g, (m) => ({ '+': '-', '/': '_', '=': '' }[m as keyof { '+': string, '/': string, '=': string }] || m));
return `${data}.${signatureB64}`;
}
@@ -77,23 +74,26 @@ export async function verifySecureJWT(token: string, secret: string): Promise<an
const data = `${headerB64}.${payloadB64}`;
const encoder = new TextEncoder();
const cryptoKey = await crypto.subtle.importKey('raw', encoder.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, ['verify']);
const signatureBytes = Uint8Array.from(atob(signatureB64.replace(/[-_]/g, (m) => ({'-':'+','_':'/'}[m as keyof typeof map] || m))), c => c.charCodeAt(0));
const isValid = await crypto.subtle.verify('HMAC', cryptoKey, signatureBytes, encoder.encode(data));
const signatureBytes = Uint8Array.from(atob(signatureB64.replace(/[-_]/g, (m) => ({ '-': '+', '_': '/' }[m as keyof { '-': string, '_': string }] || m))), c => c.charCodeAt(0));
// @ts-ignore Cloudflare WebCrypto types mismatch with standard BufferSource
const isValid = await crypto.subtle.verify('HMAC', cryptoKey, signatureBytes as any as ArrayBuffer, encoder.encode(data) as any as ArrayBuffer);
if (isValid) {
const payload = JSON.parse(atob(payloadB64.replace(/[-_]/g, (m) => ({'-':'+','_':'/'}[m as keyof typeof map] || m))));
const payload = JSON.parse(atob(payloadB64.replace(/[-_]/g, (m) => ({ '-': '+', '_': '/' }[m as keyof { '-': string, '_': string }] || m))));
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) return null;
return payload;
}
return null;
} catch {
return null;
} catch {
return null;
}
}
// PKCE 辅助函数
// ==========================================
// PKCE 辅助机制 (OAuth)
// ==========================================
export function base64UrlEncode(str: Uint8Array): string {
let binary = '';
const len = str.byteLength;
@@ -115,60 +115,69 @@ export async function generatePKCE() {
}
// ==========================================
// 3. AES-GCM 数据加解密 (用于保护 KV 里的 2FA 密钥)
// 极速与兼容模式加解密体系
// ==========================================
// ⚡️ 极速模式:直接使用密钥的哈希值作为 AES 密钥 (无需 PBKDF2 慢速派生)
// ⚡️ 极速模式:直接使用密钥的哈希值作为 AES 密钥 (无 PBKDF2)
async function getFastKey(secret: string): Promise<CryptoKey> {
const encoder = new TextEncoder();
// 使用 SHA-256 确保密钥长度固定为 256 位 (32字节)
const keyBuffer = await crypto.subtle.digest('SHA-256', encoder.encode(secret));
return crypto.subtle.importKey('raw', keyBuffer, { name: 'AES-GCM' }, false, ['encrypt', 'decrypt']);
}
// 🐢 兼容模式:PBKDF2 (用于解密旧数据)
export async function deriveKey(password: string, salt: Uint8Array): Promise<CryptoKey> {
const encoder = new TextEncoder();
const keyMaterial = await crypto.subtle.importKey('raw', encoder.encode(password), { name: 'PBKDF2' }, false, ['deriveKey']);
return await crypto.subtle.deriveKey(
{ name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' },
keyMaterial,
{ name: 'AES-GCM', length: 256 },
false,
['encrypt', 'decrypt']
);
// 🛡️ 兼容模式:生成与前端相匹配的 Vault Backup 文件 (PBKDF2 + AES-GCM 封装)
export async function encryptBackupFile(data: any, password: string): Promise<string> {
const salt = crypto.getRandomValues(new Uint8Array(CRYPTO_CONFIG.SALT_LEN));
const iv = crypto.getRandomValues(new Uint8Array(CRYPTO_CONFIG.IV_LEN));
const key = await deriveKey(password, salt);
const enc = new TextEncoder();
const encodedData = enc.encode(JSON.stringify(data));
const ciphertext = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encodedData);
const combined = new Uint8Array(salt.length + iv.length + ciphertext.byteLength);
combined.set(salt, 0);
combined.set(iv, salt.length);
combined.set(new Uint8Array(ciphertext), salt.length + iv.length);
let binary = '';
for (let i = 0; i < combined.byteLength; i++) {
binary += String.fromCharCode(combined[i]);
}
return btoa(binary);
}
// 🚀 新版内置加密封装,主要用于 KV 或 D1 里的敏感小字段
export async function encryptData(data: any, masterKey: string): Promise<{ encrypted: number[], iv: number[], salt?: number[] }> {
const encoder = new TextEncoder();
const iv = crypto.getRandomValues(new Uint8Array(12));
// 🚀 默认使用极速模式 (不再生成 salt,不再进行 PBKDF2 计算)
const key = await getFastKey(masterKey);
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, encoder.encode(JSON.stringify(data)));
return {
encrypted: Array.from(new Uint8Array(encrypted)),
return {
encrypted: Array.from(new Uint8Array(encrypted)),
iv: Array.from(iv)
// salt: undefined <-- 新数据不再包含 salt
};
}
// 🚀 新版内置解密封装,自带兼容回退机制
export async function decryptData(encryptedData: { encrypted: number[], iv: number[], salt?: number[] }, masterKey: string): Promise<any> {
const decoder = new TextDecoder();
const iv = new Uint8Array(encryptedData.iv);
const encrypted = new Uint8Array(encryptedData.encrypted);
let key: CryptoKey;
// 🔄 智能兼容:如果发现数据中有 salt,说明是旧数据,使用 PBKDF2 解密
if (encryptedData.salt && encryptedData.salt.length > 0) {
const salt = new Uint8Array(encryptedData.salt);
key = await deriveKey(masterKey, salt);
} else {
// 🚀 否则使用极速模式解密
key = await getFastKey(masterKey);
}
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encrypted);
// @ts-ignore Cloudflare WebCrypto types mismatch with standard BufferSource
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv: iv as any }, key, encrypted as any);
return JSON.parse(decoder.decode(decrypted));
}
+89
View File
@@ -0,0 +1,89 @@
import { sanitizeInput } from './common';
export function validateBase32Secret(secret: any): boolean {
if (!secret || typeof secret !== 'string') return false;
const cleaned = secret.replace(/\s/g, '').toUpperCase();
return /^[A-Z2-7]+=*$/.test(cleaned) && cleaned.length >= 16;
}
const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
export function base32Decode(encoded: string): Uint8Array {
const cleanInput = encoded.toUpperCase().replace(/[^A-Z2-7]/g, '');
const buffer = new Uint8Array(Math.floor(cleanInput.length * 5 / 8));
let bits = 0, value = 0, index = 0;
for (let i = 0; i < cleanInput.length; i++) {
const charValue = BASE32_CHARS.indexOf(cleanInput[i]);
if (charValue === -1) continue;
value = (value << 5) | charValue;
bits += 5;
if (bits >= 8) {
buffer[index++] = (value >>> (bits - 8)) & 255;
bits -= 8;
}
}
return buffer;
}
export async function hmacSHA1(key: Uint8Array | string, data: number): Promise<Uint8Array> {
const keyBuffer = typeof key === 'string' ? new TextEncoder().encode(key) : key;
const dataBuffer = new ArrayBuffer(8);
new DataView(dataBuffer).setBigUint64(0, BigInt(data), false);
const cryptoKey = await crypto.subtle.importKey('raw', keyBuffer, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']);
// @ts-ignore Cloudflare WebCrypto types mismatch with standard BufferSource
const signature = await crypto.subtle.sign('HMAC', cryptoKey, dataBuffer as any as ArrayBuffer);
return new Uint8Array(signature);
}
export async function generateTOTP(secret: string | Uint8Array, timeStep = 30, digits = 6): Promise<string> {
const time = Math.floor(Date.now() / 1000 / timeStep);
const secretBytes = typeof secret === 'string' ? base32Decode(secret) : secret;
const hmac = await hmacSHA1(secretBytes, time);
const offset = hmac[hmac.length - 1] & 0xf;
const code = (((hmac[offset] & 0x7f) << 24) |
((hmac[offset + 1] & 0xff) << 16) |
((hmac[offset + 2] & 0xff) << 8) |
(hmac[offset + 3] & 0xff)) % Math.pow(10, digits);
return code.toString().padStart(digits, '0');
}
export function parseOTPAuthURI(uri: string) {
try {
if (!uri || typeof uri !== 'string' || uri.length > 1000) return null;
const url = new URL(uri);
if (url.protocol !== 'otpauth:') return null;
const type = url.hostname;
if (type !== 'totp' && type !== 'hotp') return null;
const label = decodeURIComponent(url.pathname.substring(1));
const params = new URLSearchParams(url.search);
const secret = params.get('secret');
if (!validateBase32Secret(secret)) return null;
const [issuer, account] = label.includes(':') ? label.split(':', 2) : ['', label];
const digits = parseInt(params.get('digits') || '6');
const period = parseInt(params.get('period') || '30');
if (digits < 6 || digits > 8 || period < 15 || period > 300) return null;
return {
type,
label: sanitizeInput(label, 100),
issuer: sanitizeInput(params.get('issuer') || issuer, 50),
account: sanitizeInput(account || label, 100),
secret: secret!,
algorithm: (params.get('algorithm') || 'SHA1').toUpperCase(),
digits,
period
};
} catch {
return null;
}
}
-109
View File
@@ -1,109 +0,0 @@
import { D1Database } from '@cloudflare/workers-types';
import { encryptData, decryptData } from './crypto';
import { sanitizeInput } from './helper';
// 加密并序列化字段
export async function encryptField(data: any, key: string) {
const encrypted = await encryptData(data, key);
return JSON.stringify(encrypted);
}
// 反序列化并解密字段
export async function decryptField(encryptedStr: string, key: string) {
try {
const encryptedObj = JSON.parse(encryptedStr);
return await decryptData(encryptedObj, key);
} catch (e) {
console.error('Decryption failed', e);
return null;
}
}
/**
* 批量插入账号 (数据清洗 + 并行加密 + 分批写入)
* @param db D1 数据库实例
* @param accounts 待插入的原始账号列表
* @param key 加密密钥
* @param createdBy 创建者标识 (username 或 'restore')
* @returns 成功插入的数量
*/
export async function batchInsertAccounts(
db: D1Database,
accounts: any[],
key: string,
createdBy: string
): Promise<number> {
// 1. 准备数据 (规范化、加密、生成ID)
const preparedItems = await Promise.all(accounts.map(async (acc) => {
// 规范化密钥 (去除空格,转大写)
const normalizedSecret = (acc.secret || '').replace(/\s/g, '').toUpperCase();
const secretEncrypted = await encryptField(normalizedSecret, key);
return {
id: crypto.randomUUID(),
service: sanitizeInput(acc.service, 50),
account: sanitizeInput(acc.account, 100),
category: acc.category ? sanitizeInput(acc.category, 30) : '',
secretEncrypted,
digits: acc.digits || 6,
period: acc.period || 30,
created_at: Date.now(),
created_by: createdBy
};
}));
// 2. 构造插入语句
const stmt = db.prepare(
`INSERT INTO accounts (id, service, account, category, secret, digits, period, created_at, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
);
// 3. 分批写入 (每批 100 条)
const BATCH_SIZE = 100;
for (let i = 0; i < preparedItems.length; i += BATCH_SIZE) {
const chunk = preparedItems.slice(i, i + BATCH_SIZE);
const batch = chunk.map(item => stmt.bind(
item.id, item.service, item.account, item.category, item.secretEncrypted,
item.digits, item.period, item.created_at, item.created_by
));
if (batch.length > 0) await db.batch(batch);
}
return preparedItems.length;
}
/**
* 批量删除账号 (分批 + 并行)
* @param db D1 数据库实例
* @param ids 待删除的账号 ID 列表
* @returns 成功删除的数量
*/
export async function batchDeleteAccounts(
db: D1Database,
ids: string[]
): Promise<number> {
if (!ids || ids.length === 0) return 0;
// 优化:使用 IN 子句 + db.batch() 并行执行,减少 SQL 解析与网络 RTT
const CHUNK_SIZE = 50;
let deletedCount = 0;
const statements = [];
for (let i = 0; i < ids.length; i += CHUNK_SIZE) {
const chunk = ids.slice(i, i + CHUNK_SIZE);
const placeholders = chunk.map(() => '?').join(',');
const query = `DELETE FROM accounts WHERE id IN (${placeholders})`;
statements.push(db.prepare(query).bind(...chunk));
}
// D1 batch 限制每次最多 128 条语句
const BATCH_LIMIT = 100;
for (let i = 0; i < statements.length; i += BATCH_LIMIT) {
const batchChunk = statements.slice(i, i + BATCH_LIMIT);
const results = await db.batch(batchChunk);
results.forEach(res => deletedCount += (res.meta.changes || 0));
}
return deletedCount;
}
-90
View File
@@ -1,90 +0,0 @@
import { Context, Next } from 'hono';
import { getCookie } from 'hono/cookie';
import { SECURITY_CONFIG, AppError, EnvBindings } from '../config';
import { verifySecureJWT } from './crypto';
// 1. 输入净化与验证
export function sanitizeInput(input: any, maxLength = SECURITY_CONFIG.MAX_INPUT_LENGTH): string {
if (typeof input !== 'string') return '';
return input.replace(/[<>"'&\x00-\x1F\x7F]/g, '').trim().substring(0, maxLength);
}
export function validateBase32Secret(secret: any): boolean {
if (!secret || typeof secret !== 'string') return false;
const cleaned = secret.replace(/\s/g, '').toUpperCase();
return /^[A-Z2-7]+=*$/.test(cleaned) && cleaned.length >= 16;
}
export function validateServiceName(service: any): boolean {
const cleaned = sanitizeInput(service, 50);
return cleaned.length >= 1 && cleaned.length <= 50;
}
export function validateAccountName(account: any): boolean {
const cleaned = sanitizeInput(account, 100);
return cleaned.length >= 1 && cleaned.length <= 100;
}
// 2. 解析 二维码 URI
export function parseOTPAuthURI(uri: string) {
try {
if (!uri || typeof uri !== 'string' || uri.length > 1000) return null;
const url = new URL(uri);
if (url.protocol !== 'otpauth:') return null;
const type = url.hostname;
if (type !== 'totp' && type !== 'hotp') return null;
const label = decodeURIComponent(url.pathname.substring(1));
const params = new URLSearchParams(url.search);
const secret = params.get('secret');
if (!validateBase32Secret(secret)) return null;
const [issuer, account] = label.includes(':') ? label.split(':', 2) : ['', label];
const digits = parseInt(params.get('digits') || '6');
const period = parseInt(params.get('period') || '30');
if (digits < 6 || digits > 8 || period < 15 || period > 300) return null;
return {
type,
label: sanitizeInput(label, 100),
issuer: sanitizeInput(params.get('issuer') || issuer, 50),
account: sanitizeInput(account || label, 100),
secret: secret!,
algorithm: (params.get('algorithm') || 'SHA1').toUpperCase(),
digits,
period
};
} catch {
return null;
}
}
// 3. Hono JWT 鉴权中间件
export async function authMiddleware(c: Context<{ Bindings: EnvBindings, Variables: { user: any } }>, next: Next) {
// 1. 从 Cookie 获取 JWT
const token = getCookie(c, 'auth_token');
if (!token) {
throw new AppError('Unauthorized: Missing session cookie', 401);
}
// 2. CSRF 校验 (Double Submit Cookie)
const csrfCookie = getCookie(c, 'csrf_token');
const csrfHeader = c.req.header('X-CSRF-Token');
if (!csrfCookie || !csrfHeader || csrfCookie !== csrfHeader) {
throw new AppError('Forbidden: CSRF token mismatch', 403);
}
// 3. 验证 JWT
const payload = await verifySecureJWT(token, c.env.JWT_SECRET);
if (!payload || !payload.userInfo) {
throw new AppError('Unauthorized: Token expired or invalid', 401);
}
// 验证通过,将用户信息挂载到上下文,供后续路由使用
c.set('user', payload.userInfo);
await next();
}
+12 -1
View File
@@ -3,10 +3,21 @@
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "Bundler",
"baseUrl": "./",
"paths": {
"@/*": [
"src/*"
]
},
"strict": true,
"skipLibCheck": true,
"lib": [
"ESNext"
"ESNext",
"DOM",
"DOM.Iterable"
],
"types": [
"@cloudflare/workers-types"
],
"jsx": "react-jsx",
"jsxImportSource": "hono/jsx"
+30
View File
@@ -0,0 +1,30 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "2fauth-worker-demo",
"main": "src/app/index.ts",
"compatibility_date": "2024-04-01",
// ====== 静态资源托管 ======
"assets": {
"binding": "ASSETS",
"directory": "../frontend/dist",
"not_found_handling": "single-page-application",
"run_worker_first": true
},
// ====== 自动构建前端 ======
"build": {
"command": "npm run build --prefix ../frontend"
},
"d1_databases": [
{
"binding": "DB",
"database_name": "2fauth-db-demo",
"database_id": "082530be-db1b-4346-b4f2-dbeeb6305c51"
}
],
// ====== 定时任务 ======
"triggers": {
"crons": [
"0 2 * * *"
]
}
}
+6 -7
View File
@@ -1,17 +1,15 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "2fauth-worker-dev",
"main": "src/index.ts",
"main": "src/app/index.ts",
"compatibility_date": "2024-04-01",
// ====== 静态资源托管 ======
"assets": {
"binding": "ASSETS",
"directory": "../frontend/dist",
"directory": "../frontend/dist",
"not_found_handling": "single-page-application",
"run_worker_first": true
},
// ====== 自动构建前端 ======
"build": {
"command": "npm run build --prefix ../frontend"
@@ -20,12 +18,13 @@
{
"binding": "DB",
"database_name": "2fauth-db-dev",
"database_id": "0fb29ce6-1750-4f4e-bcae-ec8139c020ea"
"database_id": "9e591736-5dbb-4f09-8769-0270d7257731"
}
],
// ====== 定时任务 ======
"triggers": {
"crons": ["0 2 * * *"]
"crons": [
"0 2 * * *"
]
}
}
+8 -10
View File
@@ -1,17 +1,15 @@
{
"$schema": "node_modules/wrangler/config-schema.json",
"name": "2fauth-worker",
"main": "src/index.ts",
"main": "src/app/index.ts",
"compatibility_date": "2024-04-01",
// ====== 静态资源托管 ======
"assets": {
"binding": "ASSETS",
"directory": "../frontend/dist",
"directory": "../frontend/dist",
"not_found_handling": "single-page-application",
"run_worker_first": true
"run_worker_first": false
},
// ====== 自动构建前端 ======
"build": {
"command": "npm run build --prefix ../frontend"
@@ -19,16 +17,16 @@
"d1_databases": [
{
"binding": "DB",
"database_name": "2fauth-db",
"database_id": "d2ca3b30-408b-4529-9f09-eb1d3ade0dd5"
"database_name": "2fauth-db-dev",
"database_id": "9e591736-5dbb-4f09-8769-0270d7257731"
}
],
// ====== 定时任务 ======
"triggers": {
"crons": ["0 2 * * *"]
"crons": [
"0 2 * * *"
]
},
// ====== 本地开发环境配置 ======
"env": {
"dev": {
+5 -3
View File
@@ -10,8 +10,10 @@
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"@tanstack/vue-query": "^5.0.0",
"element-plus": "^2.13.2",
"jsqr": "^1.4.0",
"pinia": "^3.0.4",
"qrcode": "^1.5.3",
"vue": "^3.5.25",
"vue-router": "^5.0.2"
@@ -20,9 +22,9 @@
"@playwright/test": "^1.58.2",
"@types/node": "^25.3.0",
"@vitejs/plugin-vue": "^6.0.2",
"vite": "^6.0.0",
"vite-plugin-pwa": "^0.21.1",
"unplugin-auto-import": "^0.19.0",
"unplugin-vue-components": "^0.26.0"
"unplugin-vue-components": "^0.26.0",
"vite": "^6.0.0",
"vite-plugin-pwa": "^0.21.1"
}
}
@@ -1,14 +1,15 @@
<script setup>
import { onMounted, onBeforeUnmount } from 'vue'
import { RouterView, useRoute } from 'vue-router'
import TheHeader from './components/TheHeader.vue'
import TheFooter from './components/TheFooter.vue'
import { layoutState } from './states/layout'
import TheHeader from '@/shared/components/theHeader.vue'
import TheFooter from '@/shared/components/theFooter.vue'
import { useLayoutStore } from '@/shared/stores/layoutStore'
const route = useRoute()
const layoutStore = useLayoutStore()
const checkMobile = () => {
layoutState.isMobile = window.innerWidth < 768
layoutStore.isMobile = window.innerWidth < 768
}
onMounted(() => {
@@ -1,28 +1,28 @@
import { createRouter, createWebHistory } from 'vue-router'
import { userState } from '../states/user'
import { useUserStore } from '@/features/auth/store/userStore'
const routes = [
{
path: '/',
name: 'Home',
component: () => import('../views/Home.vue'),
component: () => import('@/features/vault/views/home.vue'),
meta: { requiresAuth: true } // 需要登录才能访问
},
{
path: '/login',
name: 'Login',
component: () => import('../views/Login.vue'),
component: () => import('@/features/auth/views/login.vue'),
meta: { guestOnly: true } // 仅限游客访问 (已登录会自动跳转首页)
},
{
path: '/oauth/callback', // 统一的回调路径 (GitHub, Telegram, etc.)
name: 'OAuthCallback',
component: () => import('../views/OAuthCallback.vue')
component: () => import('@/features/auth/views/oauthCallback.vue')
},
{
path: '/callback/:provider', // 兼容特定 Provider 的回调路径 (如 /callback/telegram)
name: 'ProviderCallback',
component: () => import('../views/OAuthCallback.vue')
component: () => import('@/features/auth/views/oauthCallback.vue')
}
]
@@ -33,16 +33,18 @@ const router = createRouter({
// 路由守卫:拦截未登录用户的访问
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
// 1. 检查内存中是否有用户信息 (乐观检查)
let isAuthenticated = !!(userState.userInfo && userState.userInfo.id)
let isAuthenticated = !!(userStore.userInfo && userStore.userInfo.id)
// 2. 如果内存无状态,但目标页需要登录 OR 是仅游客页面(如登录页),尝试通过 Cookie 恢复会话
// (Option 1B: 即使访问登录页,也先确认是否已登录,防止误判)
if (!isAuthenticated && (to.meta.requiresAuth || to.meta.guestOnly)) {
try {
await userState.fetchUserInfo()
isAuthenticated = !!(userState.userInfo && userState.userInfo.id)
} catch (e) {}
await userStore.fetchUserInfo()
isAuthenticated = !!(userStore.userInfo && userStore.userInfo.id)
} catch (e) { }
}
// 3. 处理需要登录的页面
@@ -13,5 +13,5 @@ html.dark {
/* 状态色块 */
--success-tip-bg: var(--el-fill-color-light);
--account-selected-bg: rgba(64, 158, 255, 0.15);
}
@@ -2,7 +2,9 @@
0. 强制同步修复 (Force Sync)
========================================= */
/* 核心修复:强制禁用所有元素的过渡动画,确保深浅色切换瞬间完成,消除不同步感 */
*, *::before, *::after {
*,
*::before,
*::after {
transition: none !important;
}
@@ -20,13 +22,15 @@
/* 状态色块 */
--success-tip-bg: var(--el-color-success-light-9);
--account-selected-bg: var(--el-color-primary-light-9);
--vault-selected-bg: var(--el-color-primary-light-9);
}
/* =========================================
1. 全局基础样式 (Global Reset & Base)
========================================= */
html, body, #app {
html,
body,
#app {
height: 100%;
margin: 0;
padding: 0;
@@ -50,13 +54,16 @@ a:hover {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: var(--el-border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--el-text-color-secondary);
}
@@ -80,6 +87,7 @@ a:hover {
height: 100vh;
overflow: hidden;
}
main {
flex: 1;
overflow: hidden;
@@ -101,13 +109,50 @@ main {
position: relative;
border-bottom: 1px solid var(--el-border-color-light);
}
.logo, .user-actions, .user-profile { display: flex; align-items: center; }
.logo h2 { margin: 0; color: var(--el-text-color-primary); font-size: 20px; }
.user-actions { gap: 20px; }
.user-profile { gap: 10px; background: var(--el-fill-color-light); padding: 4px 15px 4px 4px; border-radius: 20px; }
.username { font-weight: 600; color: var(--el-text-color-regular); font-size: 14px; }
.menu-icon { color: var(--el-text-color-regular); }
@media (max-width: 768px) { .header { padding: 0 15px; } .logo h2 { font-size: 18px; } }
.logo,
.user-actions,
.user-profile {
display: flex;
align-items: center;
}
.logo h2 {
margin: 0;
color: var(--el-text-color-primary);
font-size: 20px;
}
.user-actions {
gap: 20px;
}
.user-profile {
gap: 10px;
background: var(--el-fill-color-light);
padding: 4px 15px 4px 4px;
border-radius: 20px;
}
.username {
font-weight: 600;
color: var(--el-text-color-regular);
font-size: 14px;
}
.menu-icon {
color: var(--el-text-color-regular);
}
@media (max-width: 768px) {
.header {
padding: 0 15px;
}
.logo h2 {
font-size: 18px;
}
}
/* TheFooter.vue */
.global-footer {
@@ -118,17 +163,81 @@ main {
text-align: center;
margin-top: auto;
}
.footer-content { color: var(--el-text-color-secondary); font-size: 0.875rem; }
.divider { margin: 0 0.5rem; color: var(--el-border-color); }
.footer-content {
color: var(--el-text-color-secondary);
font-size: 0.875rem;
}
.divider {
margin: 0 0.5rem;
color: var(--el-border-color);
}
/* Home.vue */
.home-container { height: 100%; background-color: var(--el-bg-color-page); }
.main-body { height: 100%; display: flex; overflow: hidden; }
.left-aside { background: var(--el-bg-color); border-right: 1px solid var(--el-border-color-light); display: flex; flex-direction: column; }
.side-menu { border-right: none; padding-top: 10px; flex: 1; }
.main-content { padding: 30px 20px; width: 100%; background-color: var(--el-bg-color-page); overflow-y: auto; }
.sidebar-footer { padding: 20px; text-align: center; display: flex; justify-content: center; gap: 10px; }
.drawer-content { height: 100%; display: flex; flex-direction: column; }
.home-container {
height: 100%;
background-color: var(--el-bg-color-page);
}
.main-body {
height: 100%;
display: flex;
overflow: hidden;
}
.left-aside {
background: var(--el-bg-color);
border-right: 1px solid var(--el-border-color-light);
display: flex;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
white-space: nowrap;
}
/* 视觉优化:隐藏侧边栏滚动条,保持界面清爽 */
.left-aside::-webkit-scrollbar {
width: 0;
height: 0;
}
.side-menu {
border-right: none;
padding-top: 10px;
flex: 1;
}
.main-content {
padding: 30px 20px;
width: 100%;
background-color: var(--el-bg-color-page);
overflow-y: auto;
}
.sidebar-footer {
padding: 20px;
text-align: center;
display: flex;
justify-content: center;
gap: 10px;
}
.sidebar-footer .el-button {
margin-left: 0;
}
.sidebar-footer.is-collapsed {
flex-direction: column;
align-items: center;
padding: 10px 0;
}
.drawer-content {
height: 100%;
display: flex;
flex-direction: column;
}
/* =========================================
3. 页面/功能组件 (Pages & Features)
@@ -136,112 +245,388 @@ main {
/* Login.vue */
.login-container {
display: flex; justify-content: center; align-items: center; height: 100%;
background: transparent; position: relative;
display: flex;
justify-content: center;
align-items: center;
height: 100%;
background: transparent;
position: relative;
}
.login-card {
width: 92%; max-width: 400px; border-radius: 16px;
width: 92%;
max-width: 400px;
border-radius: 16px;
box-shadow: var(--glass-card-shadow);
background: var(--glass-card-bg);
backdrop-filter: blur(10px); border: none;
backdrop-filter: blur(10px);
border: none;
}
.logo-container { text-align: center; margin-bottom: 30px; padding-top: 10px; }
.logo-container h2 { margin: 16px 0 8px; color: var(--el-text-color-primary); font-size: 24px; font-weight: 600; }
.subtitle { margin: 0; color: var(--el-text-color-secondary); font-size: 14px; }
.action-container { display: flex; flex-direction: column; gap: 15px; margin-bottom: 30px; }
.logo-container {
text-align: center;
margin-bottom: 30px;
padding-top: 10px;
}
.logo-container h2 {
margin: 16px 0 8px;
color: var(--el-text-color-primary);
font-size: 24px;
font-weight: 600;
}
.subtitle {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.action-container {
display: flex;
flex-direction: column;
gap: 15px;
margin-bottom: 30px;
}
.oauth-btn {
width: 100%; height: 50px; font-size: 16px; border-radius: 8px;
width: 100%;
height: 50px;
font-size: 16px;
border-radius: 8px;
margin-left: 0 !important;
}
.oauth-btn:hover, .oauth-btn:focus {
.oauth-btn:hover,
.oauth-btn:focus {
filter: brightness(1.1);
transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}
.footer-tips {
margin-top: 10px;
}
.footer-tips { margin-top: 10px; }
/* OAuthCallback.vue */
.callback-container { display: flex; justify-content: center; align-items: center; height: 100%; background: transparent; }
.callback-container {
display: flex;
justify-content: center;
align-items: center;
height: 100%;
background: transparent;
}
.loading-card {
width: 92%; max-width: 400px; border-radius: 16px;
width: 92%;
max-width: 400px;
border-radius: 16px;
background: var(--glass-card-bg);
backdrop-filter: blur(10px);
}
/* AccountList.vue */
.account-card {
border-radius: 12px;
/* VaultList.vue */
.vault-card {
border-radius: 12px;
/* 全局规则已覆盖 transition,此处只需定义样式 */
border: 1px solid var(--el-border-color-lighter);
cursor: pointer; background-color: var(--el-bg-color);
cursor: pointer;
background-color: var(--el-bg-color);
height: 100%;
display: flex; flex-direction: column;
display: flex;
flex-direction: column;
}
.vault-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1) !important;
}
.vault-list-wrapper .card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 10px;
}
.account-card:hover { transform: translateY(-5px); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.1) !important; }
.account-list-wrapper .card-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
/* Flex 容器:限制宽度以支持内部文本截断 */
.service-info {
display: flex;
align-items: center;
gap: 10px;
flex: 1; /* 占据剩余空间 */
min-width: 0; /* 关键:允许 Flex 子项收缩 */
flex: 1;
/* 占据剩余空间 */
min-width: 0;
/* 关键:允许 Flex 子项收缩 */
margin-right: 10px;
}
/* 标题截断逻辑 */
.service-name { margin: 0; font-size: 18px; color: var(--el-text-color-primary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.service-name {
margin: 0;
font-size: 18px;
color: var(--el-text-color-primary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* 让卡片内容撑开,底部对齐 */
.account-card .el-card__body {
.vault-card .el-card__body {
flex: 1;
display: flex;
flex-direction: column;
}
.account-name { color: var(--el-text-color-secondary); font-size: 14px; margin: 0 0 10px 24px; word-break: break-all; }
.more-icon { cursor: pointer; color: var(--el-text-color-secondary); padding: 5px; }
.more-icon:hover { color: #409EFF; }
.account-card.is-selected { border: 1px solid #409EFF; background-color: var(--account-selected-bg); }
.batch-text { color: var(--el-text-color-regular); font-size: 14px; }
.code-display-area { display: flex; justify-content: space-between; align-items: center; margin-top: auto; padding-left: 24px; padding-top: 15px; }
.code-left { display: flex; flex-direction: row; align-items: baseline; gap: 10px; }
.current-code { font-size: 24px; font-weight: bold; color: #409EFF; letter-spacing: 2px; line-height: 1; font-family: monospace; }
.next-code { font-size: 16px; color: var(--el-text-color-secondary); font-family: monospace; }
.timer-text { font-size: 12px; color: var(--el-text-color-regular); font-weight: bold; display: block; text-align: center; }
.el-progress__text { top: 0; left: 0; width: 100%; height: 100%; transform: none; display: flex; align-items: center; justify-content: center; min-width:30px; }
/* AccountList - QR Dialog */
.qr-container { display: flex; flex-direction: column; align-items: center; text-align: center; }
.qr-info { margin-bottom: 15px; }
.qr-service { margin: 0 0 5px 0; font-size: 20px; color: var(--el-text-color-primary); }
.qr-account { margin: 0; color: var(--el-text-color-secondary); font-size: 14px; }
.qr-image-wrapper { margin: 10px 0; padding: 10px; background: #fff; border-radius: 8px; border: 1px solid var(--el-border-color-lighter); }
.qr-code-img { display: block; width: 200px; height: 200px; }
.qr-tip { font-size: 12px; color: var(--el-text-color-regular); margin: 10px 0 20px 0; }
.secret-section { width: 100%; margin-bottom: 15px; }
.secret-box { display: flex; align-items: center; justify-content: space-between; background: var(--el-fill-color-light); padding: 10px 15px; border-radius: 6px; border: 1px solid var(--el-border-color-light); }
.secret-text { font-family: monospace; font-size: 14px; color: var(--el-text-color-regular); letter-spacing: 1px; }
.secret-actions { display: flex; gap: 15px; }
.action-icon { cursor: pointer; font-size: 16px; color: var(--el-text-color-secondary); transition: color 0.2s; }
.action-icon:hover { color: #409EFF; }
.uri-link-wrapper { margin-top: 5px; }
.vault-name {
color: var(--el-text-color-secondary);
font-size: 14px;
margin: 0 0 10px 24px;
word-break: break-all;
}
.more-icon {
cursor: pointer;
color: var(--el-text-color-secondary);
padding: 5px;
}
.more-icon:hover {
color: #409EFF;
}
.vault-card.is-selected {
border: 1px solid #409EFF;
background-color: var(--vault-selected-bg);
}
.batch-text {
color: var(--el-text-color-regular);
font-size: 14px;
}
.code-display-area {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: auto;
padding-left: 24px;
padding-top: 15px;
}
.code-left {
display: flex;
flex-direction: row;
align-items: baseline;
gap: 10px;
}
.current-code {
font-size: 24px;
font-weight: bold;
color: #409EFF;
letter-spacing: 2px;
line-height: 1;
font-family: monospace;
}
.next-code {
font-size: 16px;
color: var(--el-text-color-secondary);
font-family: monospace;
}
.timer-text {
font-size: 12px;
color: var(--el-text-color-regular);
font-weight: bold;
display: block;
text-align: center;
}
.el-progress__text {
top: 0;
left: 0;
width: 100%;
height: 100%;
transform: none;
display: flex;
align-items: center;
justify-content: center;
min-width: 30px;
}
/* VaultList - QR Dialog */
.qr-container {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.qr-info {
margin-bottom: 15px;
}
.qr-service {
margin: 0 0 5px 0;
font-size: 20px;
color: var(--el-text-color-primary);
}
.qr-account {
margin: 0;
color: var(--el-text-color-secondary);
font-size: 14px;
}
.qr-image-wrapper {
margin: 10px 0;
padding: 10px;
background: #fff;
border-radius: 8px;
border: 1px solid var(--el-border-color-lighter);
}
.qr-code-img {
display: block;
width: 200px;
height: 200px;
}
.qr-tip {
font-size: 12px;
color: var(--el-text-color-regular);
margin: 10px 0 20px 0;
}
.secret-section {
width: 100%;
margin-bottom: 15px;
}
.secret-box {
display: flex;
align-items: center;
justify-content: space-between;
background: var(--el-fill-color-light);
padding: 10px 15px;
border-radius: 6px;
border: 1px solid var(--el-border-color-light);
}
.secret-text {
font-family: monospace;
font-size: 14px;
color: var(--el-text-color-regular);
letter-spacing: 1px;
}
.secret-actions {
display: flex;
gap: 15px;
}
.action-icon {
cursor: pointer;
font-size: 16px;
color: var(--el-text-color-secondary);
transition: color 0.2s;
}
.action-icon:hover {
color: #409EFF;
}
.uri-link-wrapper {
margin-top: 5px;
}
/* BackupSettings.vue */
.header-actions { display: flex; justify-content: space-between; align-items: center; margin-bottom: 20px; }
.provider-card { height: 100%; }
.backup-container .card-header { display: flex; justify-content: space-between; align-items: center; }
.provider-info { display: flex; align-items: center; }
.provider-title { display: flex; align-items: center; gap: 8px; font-weight: bold; flex-wrap: wrap; }
.status-text { font-size: 13px; color: var(--el-text-color-regular); margin-bottom: 15px; }
.action-buttons { display: flex; gap: 10px; }
.form-tip { font-size: 12px; color: var(--el-text-color-secondary); margin-top: 5px; }
.success-tip { padding: 10px; background-color: var(--success-tip-bg); border-radius: 4px; color: var(--el-color-success); font-size: 13px; display: flex; align-items: center; gap: 8px; width: 100%; }
.header-actions {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.provider-card {
height: 100%;
}
.backup-container .card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.provider-info {
display: flex;
align-items: center;
}
.provider-title {
display: flex;
align-items: center;
gap: 8px;
font-weight: bold;
flex-wrap: wrap;
}
.status-text {
font-size: 13px;
color: var(--el-text-color-regular);
margin-bottom: 15px;
}
.action-buttons {
display: flex;
gap: 10px;
}
.form-tip {
font-size: 12px;
color: var(--el-text-color-secondary);
margin-top: 5px;
}
.success-tip {
padding: 10px;
background-color: var(--success-tip-bg);
border-radius: 4px;
color: var(--el-color-success);
font-size: 13px;
display: flex;
align-items: center;
gap: 8px;
width: 100%;
}
/* DataImport.vue & DataExport.vue */
.import-options, .export-options { display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-top: 20px; }
.import-options .el-button, .export-options .el-button { margin: 0; justify-content: flex-start; padding-left: 20px; height: auto; padding-top: 15px; padding-bottom: 15px; }
@media (max-width: 768px) {
.import-options, .export-options { grid-template-columns: 1fr; }
.import-options,
.export-options {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-top: 20px;
}
.import-options .el-button,
.export-options .el-button {
margin: 0;
justify-content: flex-start;
padding-left: 20px;
height: auto;
padding-top: 15px;
padding-bottom: 15px;
}
@media (max-width: 768px) {
.import-options,
.export-options {
grid-template-columns: 1fr;
}
}
/* =========================================
@@ -349,8 +734,13 @@ main {
box-shadow: var(--el-box-shadow-light);
}
.clock-card.local { border-top: 4px solid #409EFF; }
.clock-card.server { border-top: 4px solid #E6A23C; }
.clock-card.local {
border-top: 4px solid #409EFF;
}
.clock-card.server {
border-top: 4px solid #E6A23C;
}
.clock-label {
font-size: 14px;
@@ -387,20 +777,25 @@ main {
align-items: center;
}
.scan-area, .upload-area {
.scan-area,
.upload-area {
width: 100%;
max-width: 500px;
margin-bottom: 20px;
}
.video-wrapper {
position: relative;
width: 100%;
border-radius: 12px;
overflow: hidden;
background: #000;
background-color: #000;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.15);
line-height: 0;
}
.camera-placeholder, .image-preview {
.camera-placeholder,
.image-preview {
width: 100%;
height: 250px;
border: 2px dashed var(--el-border-color);
@@ -415,11 +810,13 @@ main {
overflow: hidden;
}
.camera-placeholder:hover, .image-preview:hover {
.camera-placeholder:hover,
.image-preview:hover {
border-color: #409EFF;
}
.camera-placeholder p, .upload-placeholder p {
.camera-placeholder p,
.upload-placeholder p {
margin-top: 10px;
color: var(--el-text-color-secondary);
}
@@ -480,6 +877,7 @@ main {
.clocks-wrapper {
flex-direction: column;
}
.control-row.checkboxes {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -646,4 +1044,125 @@ main {
.input-label {
margin-right: 12px;
}
/* =========================================
6. VaultUnlock 组件 (解锁 / 创建密码)
========================================= */
.vault-unlock-container {
display: flex;
justify-content: center;
align-items: center;
min-height: 400px;
padding: 20px;
}
.unlock-card {
width: 100%;
max-width: 400px;
text-align: center;
border-radius: 12px;
}
.icon-wrapper {
margin-bottom: 20px;
background: var(--el-color-primary-light-9);
width: 80px;
height: 80px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
}
.title {
margin: 0 0 10px;
font-size: 24px;
color: var(--el-text-color-primary);
}
.form-container {
display: flex;
flex-direction: column;
gap: 15px;
}
.error-msg {
color: var(--el-color-danger);
font-size: 13px;
text-align: left;
padding-left: 5px;
}
.submit-btn {
width: 100%;
margin-top: 10px;
font-weight: bold;
}
.reset-link {
margin-top: 15px;
}
/* =========================================
7. QrScanner 组件 (摄像头 / 图片上传)
========================================= */
.qr-scanner-container {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
}
/* scan-area 与 upload-area 已并入上方全局规则,下部移除原遗留定义 */
.scan-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 70%;
aspect-ratio: 1;
border: 2px solid rgba(64, 158, 255, 0.8);
box-shadow: 0 0 0 4000px rgba(0, 0, 0, 0.4);
border-radius: 12px;
pointer-events: none;
}
.scan-line {
width: 100%;
height: 2px;
background-color: #409EFF;
box-shadow: 0 0 10px #409EFF;
animation: scan 2s linear infinite;
}
@keyframes scan {
0% {
transform: translateY(0);
}
50% {
transform: translateY(200px);
}
100% {
transform: translateY(0);
}
}
.upload-placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 250px;
cursor: pointer;
transition: all 0.3s ease;
border: none;
border-radius: 12px;
}
.upload-placeholder:hover {
color: var(--el-color-primary);
}
-575
View File
@@ -1,575 +0,0 @@
<template>
<div class="account-list-wrapper" style="min-height: 400px;">
<div v-if="!loading && accounts.length === 0 && !searchQuery" class="empty-state">
<el-empty description="空空如也,快去添加你的第一个 2FA 账号吧!">
<el-button type="primary" @click="$emit('switch-tab', 'add-account')">去添加账号</el-button>
</el-empty>
</div>
<div v-else>
<div class="toolbar" style="margin-bottom: 20px; display: flex; gap: 15px; align-items: center; justify-content: space-between; flex-wrap: wrap;">
<el-input
v-model="searchQuery"
placeholder="🔍 搜索服务名称、账号或分类..."
clearable
style="max-width: 400px; flex: 1;"
/>
<div class="batch-actions" style="display: flex; align-items: center; gap: 10px;">
<template v-if="selectedIds.length > 0">
<span class="batch-text">已选 {{ selectedIds.length }} 项</span>
<el-button type="danger" plain @click="handleBulkDelete" :loading="isBulkDeleting">
<el-icon><Delete /></el-icon> 删除
</el-button>
<el-button @click="selectedIds = []" plain>取消</el-button>
</template>
<el-button v-else @click="selectAllLoaded" plain>全选已加载</el-button>
</div>
</div>
<div
class="list-container"
style="min-height: 200px;"
v-infinite-scroll="loadMore"
:infinite-scroll-disabled="disabled"
:infinite-scroll-distance="100"
>
<el-row :gutter="20" v-if="accounts.length > 0">
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="account in accounts" :key="account.id" style="margin-bottom: 20px;">
<el-card class="account-card" :class="{ 'is-selected': selectedIds.includes(account.id) }" shadow="hover" @click="copyCode(account)">
<div class="card-header">
<div class="service-info">
<el-checkbox :model-value="selectedIds.includes(account.id)" @change="toggleSelection(account.id)" @click.stop />
<h3 class="service-name" :title="account.service">{{ account.service }}</h3>
<el-tag size="small" v-if="account.category" effect="light">{{ account.category }}</el-tag>
</div>
<el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, account)">
<el-icon class="more-icon" @click.stop><MoreFilled /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="qr">
<el-icon><Picture /></el-icon> 账号导出
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon> 编辑账号
</el-dropdown-item>
<el-dropdown-item command="delete" style="color: #F56C6C;">
<el-icon><Delete /></el-icon> 删除账号
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<p class="account-name">{{ account.account }}</p>
<div class="code-display-area">
<div class="code-left">
<div class="current-code">{{ account.currentCode || '------' }}</div>
<div class="next-code" v-if="account.nextCode">
{{ account.nextCode }}
</div>
</div>
<div class="code-right">
<el-progress
type="circle"
:percentage="account.percentage || 0"
:width="30"
:stroke-width="3"
:color="account.color || '#67C23A'"
>
<template #default>
<span class="timer-text">{{ account.remaining || 30 }}</span>
</template>
</el-progress>
</div>
</div>
</el-card>
</el-col>
</el-row>
<div v-if="loadingMore" style="text-align: center; padding: 20px; color: var(--el-text-color-secondary);">
<el-icon class="is-loading"><Loading /></el-icon> 正在加载更多...
</div>
<div v-if="noMore && accounts.length > 0" style="text-align: center; padding: 20px; color: var(--el-text-color-secondary); font-size: 12px;">
- 到底了,没有更多账号了 -
</div>
<el-empty v-if="!loading && accounts.length === 0 && searchQuery" description="没有找到匹配的账号" />
</div>
</div>
<!-- 编辑弹窗 -->
<el-dialog v-model="showEditDialog" title="✏️ 编辑账号" :width="layoutState.isMobile ? '90%' : '400px'" destroy-on-close>
<el-form :model="editAccountData" label-position="top">
<el-form-item label="服务名称 (如 Google, GitHub)">
<el-input v-model="editAccountData.service" />
</el-form-item>
<el-form-item label="账号标识 (如 邮箱地址)">
<el-input v-model="editAccountData.account" />
</el-form-item>
<el-form-item label="分类 (可选)">
<el-input v-model="editAccountData.category" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showEditDialog = false">取消</el-button>
<el-button type="primary" :loading="isEditing" @click="submitEditAccount">保存修改</el-button>
</span>
</template>
</el-dialog>
<!-- 账号导出弹窗 -->
<el-dialog v-model="showQrDialog" title="账号导出" :width="layoutState.isMobile ? '90%' : '350px'" center align-center destroy-on-close @closed="showSecret = false">
<div class="qr-container" v-if="currentQrAccount">
<div class="qr-info">
<h3 class="qr-service">{{ currentQrAccount.service }}</h3>
<p class="qr-account">{{ currentQrAccount.account }}</p>
</div>
<div class="qr-image-wrapper">
<img :src="qrCodeUrl" class="qr-code-img" />
</div>
<p class="qr-tip">使用任意 2FA 应用扫描二维码即可添加此账户</p>
<div class="secret-section">
<div class="secret-box">
<div class="secret-text">{{ showSecret ? formatSecret(currentQrAccount.secret) : '•••• •••• •••• ••••' }}</div>
<div class="secret-actions">
<el-icon class="action-icon" @click="showSecret = !showSecret" :title="showSecret ? '隐藏' : '显示'"><View v-if="!showSecret" /><Hide v-else /></el-icon>
<el-icon class="action-icon" @click="copySecret" title="复制密钥"><CopyDocument /></el-icon>
</div>
</div>
</div>
<div class="uri-link-wrapper">
<el-button link type="info" size="small" @click="copyOtpUrl">复制原始 otpauth 链接</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted, watch, computed } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MoreFilled, Edit, Delete, Picture, View, Hide, CopyDocument, Loading } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import { request } from '../utils/request'
import { layoutState } from '../states/layout'
const emit = defineEmits(['switch-tab'])
// --- 状态定义 ---
const accounts = ref([])
const allFetchedAccounts = ref([]) // 新增:用于存储所有已获取的数据(缓存+网络)
const loading = ref(true)
const loadingMore = ref(false)
const total = ref(0)
const searchQuery = ref('')
const currentPage = ref(1)
const pageSize = ref(12)
const isOfflineMode = ref(false)
// --- 批量操作 ---
const selectedIds = ref([])
const isBulkDeleting = ref(false)
// --- 弹窗状态 ---
const showEditDialog = ref(false)
const isEditing = ref(false)
const editAccountData = ref({ id: '', service: '', account: '', category: '' })
const showQrDialog = ref(false)
const currentQrAccount = ref(null)
const showSecret = ref(false)
const qrCodeUrl = ref('')
let globalTimer = null
let loadingMessageHandle = null
let loadingTimer = null
// --- 工具函数 ---
// TOTP 算法 (Web Crypto API)
const base32ToBytes = (str) => {
const base32chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
let bits = 0
let value = 0
let index = 0
const buffer = new Uint8Array(Math.ceil(str.length * 5 / 8))
for (let i = 0; i < str.length; i++) {
const val = base32chars.indexOf(str[i].toUpperCase())
if (val === -1) continue
value = (value << 5) | val
bits += 5
if (bits >= 8) {
buffer[index++] = (value >>> (bits - 8)) & 0xFF
bits -= 8
}
}
return buffer.slice(0, index)
}
const generateTOTP = async (secret, period, digits, offset = 0) => {
if (!secret) return '------'
try {
const now = Date.now() / 1000
const epoch = Math.floor(now / period) + offset
const timeBuffer = new ArrayBuffer(8)
const view = new DataView(timeBuffer)
view.setBigUint64(0, BigInt(epoch), false)
const keyBytes = base32ToBytes(secret)
const key = await window.crypto.subtle.importKey(
'raw', keyBytes, { name: 'HMAC', hash: 'SHA-1' }, false, ['sign']
)
const signature = await window.crypto.subtle.sign('HMAC', key, timeBuffer)
const sigView = new DataView(signature)
const offsetByte = sigView.getUint8(19) & 0xf
const binary = ((sigView.getUint8(offsetByte) & 0x7f) << 24) |
((sigView.getUint8(offsetByte + 1) & 0xff) << 16) |
((sigView.getUint8(offsetByte + 2) & 0xff) << 8) |
(sigView.getUint8(offsetByte + 3) & 0xff)
const otp = binary % Math.pow(10, digits)
return otp.toString().padStart(digits, '0')
} catch (e) { return 'ERROR' }
}
let searchTimer = null
// 计算属性:是否禁用无限滚动
const noMore = computed(() => accounts.value.length >= total.value && total.value > 0)
const disabled = computed(() => loading.value || loadingMore.value || noMore.value)
// --- Loading 提示控制 ---
const showTopLoading = () => {
// 防抖:如果已经在显示或正在等待显示,则不重复触发
if (loadingMessageHandle || loadingTimer) return
loadingMessageHandle = ElMessage({
message: '数据正在加载...',
icon: Loading,
duration: 0, // 设为 0 则不会自动关闭
type: 'info',
grouping: true
})
}
const hideTopLoading = () => {
if (loadingTimer) clearTimeout(loadingTimer) // 清除等待中的 Loading
loadingTimer = null
if (loadingMessageHandle) {
loadingMessageHandle.close()
loadingMessageHandle = null
}
}
// --- 核心逻辑 ---
// 获取账号列表
const fetchAccounts = async (append = false, useCache = true) => {
// 如果不是追加模式(即刷新或搜索),重置页码
if (!append) {
currentPage.value = 1
}
if (!append) showTopLoading()
const isInitLoad = !append && currentPage.value === 1
const isNoSearch = !searchQuery.value
let loadedFromCache = false
// 本次请求的 limit (默认 pageSize,但如果是恢复缓存,则请求缓存的大小)
let requestLimit = pageSize.value
let requestPage = currentPage.value
// 1. 离线优先策略:仅在初始化且无搜索时,尝试加载本地缓存
if (useCache && isInitLoad && isNoSearch) {
const cachedData = localStorage.getItem('cached_accounts')
if (cachedData) {
try {
const parsed = JSON.parse(cachedData)
allFetchedAccounts.value = parsed.accounts || []
if (parsed.pagination) total.value = parsed.pagination.total
// 【性能优化关键点】
// 即使缓存有 350 条,初始只渲染 pageSize (12条),避免卡死
accounts.value = allFetchedAccounts.value.slice(0, pageSize.value)
// 后台仍然请求所有缓存的数据量,以保持数据新鲜度
if (allFetchedAccounts.value.length > pageSize.value) {
requestLimit = allFetchedAccounts.value.length
// 修正 currentPage,以便后续网络请求能接上
currentPage.value = Math.ceil(allFetchedAccounts.value.length / pageSize.value)
}
updateAccountsStatus() // 立即开始倒计时
loadedFromCache = true
} catch (e) { console.error('Cache load failed', e) }
}
}
if (append) {
loadingMore.value = true
} else {
loading.value = true
}
try {
const query = new URLSearchParams({
page: requestPage,
limit: requestLimit,
search: searchQuery.value
}).toString()
const data = await request(`/api/accounts?${query}`)
if (data.success) {
const newAccounts = data.accounts || []
if (append) {
// 追加模式:去重后添加到列表
const existingIds = new Set(allFetchedAccounts.value.map(a => a.id))
const uniqueNewAccounts = newAccounts.filter(a => !existingIds.has(a.id))
allFetchedAccounts.value.push(...uniqueNewAccounts)
// 同时追加到显示列表(因为用户正在滚动加载)
accounts.value.push(...uniqueNewAccounts)
} else {
// 覆盖模式 (刷新/搜索/初始化)
allFetchedAccounts.value = newAccounts
// 更新显示列表:保持当前已渲染的数量,或者重置为 pageSize
// 如果是初始化加载(tab切换回来),重置为 pageSize 以保证速度
// 如果是搜索,也重置。
const countToRender = isInitLoad ? pageSize.value : Math.max(accounts.value.length, pageSize.value)
accounts.value = allFetchedAccounts.value.slice(0, countToRender)
}
if (data.pagination) total.value = data.pagination.total
isOfflineMode.value = false
updateAccountsStatus() // 立即计算一次
// 2. 实时缓存:保存完整的内存数据
if (isNoSearch) {
const cacheData = {
accounts: allFetchedAccounts.value,
pagination: { total: total.value }
}
localStorage.setItem('cached_accounts', JSON.stringify(cacheData))
}
}
} catch (error) {
console.error('Failed to fetch accounts', error)
// 3. 网络请求失败:如果是离线状态且已有缓存,则不报错
if (!navigator.onLine || error.message.includes('Failed to fetch')) {
isOfflineMode.value = true
if (accounts.value.length > 0) {
if (!loadedFromCache) ElMessage.warning('网络不可用,正在使用离线数据')
return
}
}
} finally {
loading.value = false
loadingMore.value = false
if (!append) hideTopLoading()
}
}
defineExpose({ fetchAccounts })
// 滚动加载更多
const loadMore = () => {
if (disabled.value) return
// 1. 优先从内存加载 (分批渲染)
if (accounts.value.length < allFetchedAccounts.value.length) {
// 为了展示 Loading 效果,人为增加一点延迟 (300ms),提升交互感知
loadingMore.value = true
setTimeout(() => {
const currentLen = accounts.value.length
const nextBatch = allFetchedAccounts.value.slice(currentLen, currentLen + pageSize.value)
accounts.value.push(...nextBatch)
updateAccountsStatus() // 立即为新渲染的卡片计算验证码
loadingMore.value = false
}, 300)
} else {
// 2. 内存数据已全部显示,才请求服务器
currentPage.value++
fetchAccounts(true) // true 表示追加模式
}
}
watch(searchQuery, () => {
showTopLoading()
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => fetchAccounts(), 300)
})
// 批量选择
const toggleSelection = (id) => {
const index = selectedIds.value.indexOf(id)
if (index > -1) selectedIds.value.splice(index, 1)
else selectedIds.value.push(id)
}
const selectAllLoaded = () => {
// 全选当前已加载的所有账号
selectedIds.value = accounts.value.map(acc => acc.id)
}
// 批量删除
const handleBulkDelete = async () => {
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 个账号吗?`, '警告', { type: 'error' })
isBulkDeleting.value = true
const data = await request('/api/accounts/batch-delete', {
method: 'POST',
body: JSON.stringify({ ids: selectedIds.value })
})
if (data.success) {
ElMessage.success(`成功删除了 ${data.count} 个账号`)
selectedIds.value = []
fetchAccounts(false, false) // 删除后不使用缓存,防止已删数据闪现
}
} catch (e) {} finally { isBulkDeleting.value = false }
}
// 单个账号操作 (删除/编辑/导出)
const handleCommand = async (command, account) => {
if (command === 'delete') {
try {
await ElMessageBox.confirm(`确定删除 [${account.service}] 吗?`, '警告', { type: 'error' })
const data = await request(`/api/accounts/${account.id}`, { method: 'DELETE' })
if (data.success) {
ElMessage.success('账号已删除')
fetchAccounts(false, false) // 删除后不使用缓存
}
} catch (e) {}
} else if (command === 'edit') {
editAccountData.value = { ...account, category: account.category || '' }
showEditDialog.value = true
} else if (command === 'qr') {
currentQrAccount.value = account
try {
const uri = getOtpAuthUrl(account)
qrCodeUrl.value = await QRCode.toDataURL(uri, { width: 200, margin: 1, errorCorrectionLevel: 'M' })
showQrDialog.value = true
} catch (e) {
console.error(e)
ElMessage.error('二维码生成失败')
}
}
}
// 提交编辑
const submitEditAccount = async () => {
if (!editAccountData.value.service || !editAccountData.value.account) return ElMessage.warning('必填项不能为空')
isEditing.value = true
try {
const data = await request(`/api/accounts/${editAccountData.value.id}`, {
method: 'PUT',
body: JSON.stringify({
service: editAccountData.value.service,
account: editAccountData.value.account,
category: editAccountData.value.category
})
})
if (data.success) {
ElMessage.success('账号修改成功')
showEditDialog.value = false
fetchAccounts(false, false) // 编辑后也不使用缓存,确保显示最新信息
}
} catch (e) {} finally { isEditing.value = false }
}
// --- 定时更新 ---
// 更新倒计时与验证码
const updateAccountsStatus = async () => {
const now = Date.now() / 1000
for (const acc of accounts.value) {
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)
if (acc.lastEpoch !== currentEpoch || !acc.currentCode) {
acc.currentCode = await generateTOTP(acc.secret, period, acc.digits)
acc.lastEpoch = currentEpoch
}
// 剩余5秒时计算下一个验证码
if (remaining <= 5) {
if (!acc.nextCode || acc.lastNextEpoch !== currentEpoch + 1) {
acc.nextCode = await generateTOTP(acc.secret, period, acc.digits, 1)
acc.lastNextEpoch = currentEpoch + 1
}
} else {
acc.nextCode = null
}
}
}
// --- 辅助功能 ---
const copyCode = async (account) => {
const code = (account.remaining <= 5 && account.nextCode) ? account.nextCode : account.currentCode
if (!code || code === '------') return
try {
await navigator.clipboard.writeText(code)
ElMessage.success('验证码已复制')
} catch (e) {}
}
const getOtpAuthUrl = (acc) => {
if (!acc) return ''
const label = encodeURIComponent(`${acc.service}:${acc.account}`)
const params = new URLSearchParams()
params.set('secret', acc.secret)
params.set('issuer', acc.service)
if (acc.algorithm) params.set('algorithm', acc.algorithm)
if (acc.digits) params.set('digits', acc.digits)
if (acc.period) params.set('period', acc.period)
return `otpauth://totp/${label}?${params.toString()}`
}
const copyOtpUrl = async () => {
const url = getOtpAuthUrl(currentQrAccount.value)
try {
await navigator.clipboard.writeText(url)
ElMessage.success('otpauth已复制')
} catch (e) {}
}
const formatSecret = (secret) => {
return secret ? secret.replace(/(.{4})/g, '$1 ').trim() : ''
}
const copySecret = async () => {
if (!currentQrAccount.value?.secret) return
try {
await navigator.clipboard.writeText(currentQrAccount.value.secret)
ElMessage.success('密钥已复制')
} catch (e) {}
}
onMounted(() => {
fetchAccounts()
globalTimer = setInterval(updateAccountsStatus, 1000)
})
onUnmounted(() => {
if (globalTimer) clearInterval(globalTimer)
if (searchTimer) clearTimeout(searchTimer)
hideTopLoading()
})
</script>
@@ -1,90 +0,0 @@
<template>
<div class="add-account-wrapper">
<div class="tab-card-wrapper">
<h2 style="text-align: center; margin-bottom: 20px;">⌨️ 手动输入</h2>
<div style="max-width: 600px; margin: 0 auto;">
<el-form :model="newAccount" label-position="top" :rules="rules" ref="addFormRef" style="padding: 10px 0;">
<el-form-item label="服务名称 (如 Google, GitHub)" prop="service">
<el-input v-model="newAccount.service" placeholder="请输入服务名称" />
</el-form-item>
<el-form-item label="账号标识 (如 邮箱地址)" prop="account">
<el-input v-model="newAccount.account" placeholder="请输入账号或邮箱" />
</el-form-item>
<el-form-item label="安全密钥 (Base32格式)" prop="secret">
<el-input v-model="newAccount.secret" placeholder="请输入 16 位以上的安全密钥" />
</el-form-item>
<el-row :gutter="20">
<el-col :span="12">
<el-form-item label="代码位数" prop="digits">
<el-select v-model="newAccount.digits" style="width: 100%">
<el-option label="6 位" :value="6" />
<el-option label="8 位" :value="8" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="更新周期" prop="period">
<el-select v-model="newAccount.period" style="width: 100%">
<el-option label="30 秒" :value="30" />
<el-option label="60 秒" :value="60" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="分类 (可选)" prop="category">
<el-input v-model="newAccount.category" placeholder="如 工作, 个人" />
</el-form-item>
<el-form-item style="margin-top: 30px;">
<el-button type="primary" :loading="submitting" @click="submitAddAccount" style="width: 100%;" size="large">确认添加</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</template>
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { request } from '../utils/request'
const emit = defineEmits(['success'])
const submitting = ref(false)
const addFormRef = ref(null)
const newAccount = ref({
service: '', account: '', secret: '', category: '', digits: 6, period: 30
})
const rules = {
service: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
account: [{ required: true, message: '请输入账号标识', trigger: 'blur' }],
secret: [
{ required: true, message: '请输入安全密钥', trigger: 'blur' },
{ min: 16, message: '密钥长度通常不少于 16 位', trigger: 'blur' }
]
}
const submitAddAccount = async () => {
if (!addFormRef.value) return
await addFormRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
const data = await request('/api/accounts', {
method: 'POST',
body: JSON.stringify(newAccount.value)
})
if (data.success) {
ElMessage.success('账号添加成功!')
newAccount.value = { service: '', account: '', secret: '', category: '', digits: 6, period: 30 }
emit('success')
}
} catch (error) {
} finally {
submitting.value = false
}
}
})
}
</script>
@@ -1,60 +0,0 @@
<template>
<div class="add-account-wrapper">
<div class="tab-card-wrapper">
<h2 style="text-align: center; margin-bottom: 20px;">📷 扫码添加</h2>
<div style="max-width: 600px; margin: 0 auto;">
<div style="text-align: center; margin-bottom: 20px; margin-top: 10px;">
<p style="color: var(--el-text-color-secondary);">请允许浏览器使用摄像头,或直接上传微信/系统截图。</p>
</div>
<QrScanner @scan-success="handleScanSuccess" />
</div>
</div>
</div>
</template>
<script setup>
import { h } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { request } from '../utils/request'
import QrScanner from './QrScanner.vue'
const emit = defineEmits(['success'])
const handleScanSuccess = async (uri) => {
try {
const data = await request('/api/accounts/parse-uri', {
method: 'POST',
body: JSON.stringify({ uri })
})
if (data.success && data.account) {
const acc = data.account
await ElMessageBox.confirm(
h('div', { style: 'text-align:left; background:#f5f7fa; padding:15px; border-radius:8px;' }, [
h('p', [h('strong', '服务方:'), acc.issuer || '未知']),
h('p', [h('strong', '账号:'), acc.account || '未知']),
h('p', [h('strong', '算法:'), `${acc.algorithm} (${acc.digits}位 / ${acc.period}秒)`])
]),
'✅ 二维码解析成功,确认添加?',
{
confirmButtonText: '确认添加',
cancelButtonText: '取消',
type: 'success'
}
)
const addData = await request('/api/accounts/add-from-uri', {
method: 'POST',
body: JSON.stringify({ uri, category: '手机扫码' })
})
if (addData.success) {
ElMessage.success('扫码账号添加成功!')
emit('success')
}
}
} catch (err) {
if (err !== 'cancel') console.error(err)
}
}
</script>
-219
View File
@@ -1,219 +0,0 @@
<template>
<div class="qr-scanner-wrapper">
<div class="scan-actions">
<el-button type="primary" size="large" @click="startCamera" :loading="isStarting">
<el-icon><Camera /></el-icon> 启动摄像头扫描
</el-button>
<el-button size="large" @click="triggerFileUpload">
<el-icon><Picture /></el-icon> 上传图片识别
</el-button>
<input
type="file"
ref="fileInputRef"
accept="image/*"
style="display: none"
@change="handleFileUpload"
/>
</div>
<div v-show="isScanning" class="camera-container">
<video ref="videoRef" class="video-preview" autoplay muted playsinline></video>
<div class="scan-overlay">
<div class="scan-line"></div>
</div>
<div class="camera-controls">
<el-button type="danger" round @click="stopCamera">关闭摄像头</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onBeforeUnmount } from 'vue'
import { Camera, Picture } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import jsQR from 'jsqr'
// 定义向父组件 (Home.vue) 传递结果的事件
const emit = defineEmits(['scan-success'])
const videoRef = ref(null)
const fileInputRef = ref(null)
const isScanning = ref(false)
const isStarting = ref(false)
let stream = null
let scanInterval = null
// ==========================================
// 1. 摄像头实时扫描逻辑
// ==========================================
const startCamera = async () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
ElMessage.error('您的浏览器不支持摄像头功能')
return
}
isStarting.value = true
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' } // 优先调用后置摄像头
})
videoRef.value.srcObject = stream
// 等待视频流加载完成后开始扫描
videoRef.value.onloadedmetadata = () => {
videoRef.value.play()
isScanning.value = true
isStarting.value = false
scanInterval = setInterval(scanVideoFrame, 500) // 每半秒截取一帧分析
ElMessage.success('摄像头已启动,请将二维码对准取景框')
}
} catch (err) {
isStarting.value = false
ElMessage.error('无法访问摄像头,请检查权限设置')
console.error('Camera error:', err)
}
}
const stopCamera = () => {
if (stream) {
stream.getTracks().forEach(track => track.stop())
stream = null
}
if (scanInterval) {
clearInterval(scanInterval)
scanInterval = null
}
isScanning.value = false
}
// 抽取视频帧进行二维码解析
const scanVideoFrame = () => {
if (!videoRef.value || videoRef.value.readyState !== videoRef.value.HAVE_ENOUGH_DATA) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d', { willReadFrequently: true })
canvas.width = videoRef.value.videoWidth
canvas.height = videoRef.value.videoHeight
ctx.drawImage(videoRef.value, 0, 0, canvas.width, canvas.height)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const code = jsQR(imageData.data, imageData.width, imageData.height)
if (code && code.data.startsWith('otpauth://')) {
stopCamera() // 扫到了就马上关掉摄像头
emit('scan-success', code.data) // 把扫到的 URI 传给 Home.vue
}
}
// ==========================================
// 2. 静态图片上传识别逻辑
// ==========================================
const triggerFileUpload = () => {
fileInputRef.value.click()
}
const handleFileUpload = (e) => {
const file = e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const code = jsQR(imageData.data, imageData.width, imageData.height)
if (code && code.data.startsWith('otpauth://')) {
emit('scan-success', code.data)
} else {
ElMessage.error('未能从图片中识别出有效的 2FA 二维码')
}
}
img.src = event.target.result
}
reader.readAsDataURL(file)
e.target.value = '' // 清空 input,允许重复上传同一张图片
}
// 组件销毁前务必关掉摄像头,防止偷偷耗电
onBeforeUnmount(() => {
stopCamera()
})
</script>
<style scoped>
.qr-scanner-wrapper {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px 0;
}
.scan-actions {
display: flex;
gap: 20px;
margin-bottom: 30px;
}
.camera-container {
position: relative;
width: 100%;
max-width: 500px;
border-radius: 16px;
overflow: hidden;
box-shadow: 0 12px 32px rgba(0, 0, 0, 0.2);
background-color: #000;
}
.video-preview {
width: 100%;
display: block;
}
.scan-overlay {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
width: 250px;
height: 250px;
border: 2px solid rgba(64, 158, 255, 0.8);
box-shadow: 0 0 0 4000px rgba(0, 0, 0, 0.4);
border-radius: 12px;
pointer-events: none;
}
/* 炫酷的扫描线动画 */
.scan-line {
width: 100%;
height: 2px;
background-color: #409EFF;
box-shadow: 0 0 10px #409EFF;
animation: scan 2s linear infinite;
}
@keyframes scan {
0% { transform: translateY(0); }
50% { transform: translateY(250px); }
100% { transform: translateY(0); }
}
.camera-controls {
position: absolute;
bottom: 20px;
left: 0;
width: 100%;
display: flex;
justify-content: center;
}
</style>
-129
View File
@@ -1,129 +0,0 @@
<template>
<div class="tool-pane">
<div class="qr-parser-container">
<el-radio-group v-model="parserMode" style="margin-bottom: 20px; width: 100%; display: flex; justify-content: center;">
<el-radio-button label="camera">摄像头扫描</el-radio-button>
<el-radio-button label="image">图片识别</el-radio-button>
</el-radio-group>
<!-- Camera Mode -->
<div v-if="parserMode === 'camera'" class="scan-area">
<div class="video-wrapper" v-show="isScanning">
<video ref="videoEl" playsinline muted style="width: 100%; border-radius: 8px; background: #000;"></video>
</div>
<div v-if="!isScanning" class="camera-placeholder" @click="toggleCamera">
<el-icon :size="48" color="#909399"><Camera /></el-icon>
<p>点击开启摄像头</p>
</div>
<el-button :type="isScanning ? 'danger' : 'primary'" @click="toggleCamera" style="margin-top: 15px; width: 100%;">
{{ isScanning ? '停止扫描' : '开启摄像头' }}
</el-button>
</div>
<!-- Image Mode -->
<div v-else class="upload-area">
<div class="image-preview" @click="triggerFileUpload">
<img v-if="previewImg" :src="previewImg" class="preview-img" />
<div v-else class="upload-placeholder">
<el-icon :size="48"><Upload /></el-icon>
<p>点击选择或拖拽二维码图片</p>
</div>
</div>
<input type="file" ref="fileInput" accept="image/*" style="display: none" @change="handleFileChange" />
</div>
<!-- Result -->
<div v-if="scanResult" class="result-section" style="margin-top: 20px;">
<el-divider content-position="left">解析结果</el-divider>
<el-input v-model="scanResult" type="textarea" :rows="3" readonly resize="none" />
<el-button type="success" plain style="width: 100%; margin-top: 10px;" @click="copyResult">
<el-icon><CopyDocument /></el-icon> 复制内容
</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, onUnmounted, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { Camera, Upload, CopyDocument } from '@element-plus/icons-vue'
import jsQR from 'jsqr'
const parserMode = ref('camera')
const scanResult = ref('')
const isScanning = ref(false)
const videoEl = ref(null)
const fileInput = ref(null)
const previewImg = ref('')
let stream = null
let rafId = null
const toggleCamera = async () => {
if (isScanning.value) stopCamera()
else await startCamera()
}
const startCamera = async () => {
try {
stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } })
isScanning.value = true
setTimeout(() => {
if (videoEl.value) {
videoEl.value.srcObject = stream
videoEl.value.play()
requestAnimationFrame(tick)
}
}, 100)
} catch (err) { ElMessage.error('无法访问摄像头,请检查权限') }
}
const stopCamera = () => {
isScanning.value = false
if (stream) { stream.getTracks().forEach(track => track.stop()); stream = null }
if (rafId) { cancelAnimationFrame(rafId); rafId = null }
}
const tick = () => {
if (!isScanning.value || !videoEl.value) return
if (videoEl.value.readyState === videoEl.value.HAVE_ENOUGH_DATA) {
const canvas = document.createElement('canvas')
canvas.width = videoEl.value.videoWidth
canvas.height = videoEl.value.videoHeight
const ctx = canvas.getContext('2d')
ctx.drawImage(videoEl.value, 0, 0, canvas.width, canvas.height)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const code = jsQR(imageData.data, imageData.width, imageData.height, { inversionAttempts: 'dontInvert' })
if (code) {
scanResult.value = code.data
ElMessage.success('二维码识别成功')
stopCamera()
return
}
}
rafId = requestAnimationFrame(tick)
}
const triggerFileUpload = () => fileInput.value.click()
const handleFileChange = (e) => {
const file = e.target.files[0]; if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
previewImg.value = ev.target.result
const img = new Image(); img.onload = () => {
const canvas = document.createElement('canvas'); canvas.width = img.width; canvas.height = img.height
const ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const code = jsQR(imageData.data, imageData.width, imageData.height)
if (code) { scanResult.value = code.data; ElMessage.success('图片识别成功') } else { ElMessage.warning('未识别到二维码') }
}; img.src = ev.target.result
}; reader.readAsDataURL(file); e.target.value = ''
}
const copyResult = async () => {
if (!scanResult.value) return
try { await navigator.clipboard.writeText(scanResult.value); ElMessage.success('内容已复制') } catch (e) {}
}
onUnmounted(stopCamera)
</script>
@@ -0,0 +1,64 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { request } from '@/shared/utils/request'
import { useVaultStore } from '@/features/vault/store/vaultStore'
export const useUserStore = defineStore('user', () => {
const getStoredUser = () => {
try {
return JSON.parse(localStorage.getItem('userInfo') || '{}')
} catch (e) {
return {}
}
}
const userInfo = ref(getStoredUser())
const setUserInfo = (info) => {
userInfo.value = info
localStorage.setItem('userInfo', JSON.stringify(info))
}
const clearUserInfo = () => {
userInfo.value = {}
localStorage.removeItem('userInfo')
localStorage.removeItem('secure_vault')
localStorage.removeItem('backup_providers_cache')
// 锁定保险箱
const vaultStore = useVaultStore()
vaultStore.lock()
}
const logout = async () => {
try {
await request('/api/oauth/logout', { method: 'POST' })
} catch (e) {
console.error('Logout request failed', e)
} finally {
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) {
clearUserInfo()
return false
}
return false
}
return {
userInfo,
setUserInfo,
clearUserInfo,
logout,
fetchUserInfo
}
})
@@ -45,7 +45,7 @@ import { ref, onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Lock, Platform } from '@element-plus/icons-vue'
import { userState } from '../states/user'
import { useUserStore } from '@/features/auth/store/userStore'
const router = useRouter()
const loadingProvider = ref(null)
@@ -30,7 +30,7 @@
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Loading } from '@element-plus/icons-vue'
import { userState } from '../states/user'
import { useUserStore } from '@/features/auth/store/userStore'
const route = useRoute()
const router = useRouter()
@@ -126,7 +126,8 @@ onMounted(async () => {
if (data.success) {
// 登录成功!Token 已写入 httpOnly Cookie
// 立即调用接口确认会话有效性,并更新用户信息
await userState.fetchUserInfo()
const userStore = useUserStore()
await userStore.fetchUserInfo()
// 一把推开大门,进入主界面
router.push('/')
@@ -33,10 +33,10 @@
<div class="card-content">
<p class="status-text">
上次备份:
<span v-if="provider.last_backup_at">{{ new Date(provider.last_backup_at).toLocaleString() }}</span>
<span v-if="provider.lastBackupAt">{{ new Date(provider.lastBackupAt).toLocaleString() }}</span>
<span v-else>从未</span>
<el-tag v-if="provider.last_backup_status" size="small" :type="provider.last_backup_status === 'success' ? 'success' : 'danger'" style="margin-left: 5px;">
{{ provider.last_backup_status }}
<el-tag v-if="provider.lastBackupStatus" size="small" :type="provider.lastBackupStatus === 'success' ? 'success' : 'danger'" style="margin-left: 5px;">
{{ provider.lastBackupStatus }}
</el-tag>
</p>
@@ -54,7 +54,7 @@
</el-row>
<!-- 编辑弹窗 -->
<el-dialog v-model="showConfigDialog" :title="isEditing ? '编辑备份源' : '添加备份源'" :width="layoutState.isMobile ? '90%' : '500px'" destroy-on-close>
<el-dialog v-model="showConfigDialog" :title="isEditing ? '编辑备份源' : '添加备份源'" :width="layoutStore.isMobile ? '90%' : '500px'" destroy-on-close>
<el-form :model="form" label-position="top" ref="formRef">
<el-form-item label="类型">
<el-select v-model="form.type" :disabled="isEditing">
@@ -123,6 +123,10 @@
<el-icon><CircleCheck /></el-icon><span>系统将继续使用原有的自动备份密码。</span>
</div>
</el-form-item>
<el-form-item label="保留最近份数" v-if="form.autoBackup">
<el-input-number v-model="form.autoBackupRetain" :min="0" :max="999" label="保留分数"></el-input-number>
<div class="form-tip" style="width: 100%">填 0 表示不对历史备份做任何限制与清理</div>
</el-form-item>
</el-form>
<template #footer>
@@ -132,23 +136,17 @@
</el-dialog>
<!-- 备份弹窗 -->
<el-dialog v-model="showBackupDialog" title="加密备份" :width="layoutState.isMobile ? '90%' : '400px'">
<el-dialog v-model="showBackupDialog" title="加密备份" :width="layoutStore.isMobile ? '90%' : '400px'">
<el-alert title="数据安全" type="info" description="请输入加密密码用于保护备份文件。" show-icon :closable="false" style="margin-bottom: 20px;" />
<div v-if="currentActionProvider?.auto_backup" style="margin-bottom: 20px;">
<div v-if="currentActionProvider?.auto_backup" style="margin-bottom: 15px;">
<el-radio-group v-model="useAutoPassword">
<el-radio :label="true">使用自动备份密码</el-radio>
<el-radio :label="false">使用临时密码</el-radio>
<el-radio :label="false">使用新密码</el-radio>
</el-radio-group>
</div>
<div v-if="!useAutoPassword">
<el-input v-model="backupPassword" type="password" show-password placeholder="输入加密密码 (至少12位)" />
</div>
<div v-else class="success-tip">
<el-icon><CircleCheck /></el-icon>
<span>系统将使用您预设的自动备份密码进行加密。</span>
</div>
<el-input v-if="!currentActionProvider?.auto_backup || !useAutoPassword" v-model="backupPassword" type="password" show-password placeholder="输入自定义加密密码 (至少12位)" />
<template #footer>
<el-button @click="showBackupDialog = false">取消</el-button>
@@ -157,7 +155,7 @@
</el-dialog>
<!-- 恢复列表弹窗 -->
<el-dialog v-model="showRestoreListDialog" title="选择备份文件恢复" :width="layoutState.isMobile ? '95%' : '600px'">
<el-dialog v-model="showRestoreListDialog" title="选择备份文件恢复" :width="layoutStore.isMobile ? '95%' : '600px'">
<el-table :data="backupFiles" v-loading="isLoadingFiles" height="300px" style="width: 100%">
<el-table-column prop="filename" label="文件名" show-overflow-tooltip />
<el-table-column label="大小" width="100">
@@ -175,7 +173,7 @@
</el-dialog>
<!-- 恢复确认弹窗 -->
<el-dialog v-model="showRestoreConfirmDialog" title="解密恢复" :width="layoutState.isMobile ? '90%' : '400px'">
<el-dialog v-model="showRestoreConfirmDialog" title="解密恢复" :width="layoutStore.isMobile ? '90%' : '400px'">
<el-alert title="警告" type="warning" description="恢复操作将覆盖当前所有数据!" show-icon :closable="false" style="margin-bottom: 15px;" />
<el-input v-model="restorePassword" type="password" show-password placeholder="输入备份时的加密密码" />
<template #footer>
@@ -190,10 +188,14 @@
import { ref, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Edit, Delete, CircleCheck, Timer } from '@element-plus/icons-vue'
import { request } from '../utils/request'
import { layoutState } from '../states/layout'
import { request } from '@/shared/utils/request'
import { useLayoutStore } from '@/shared/stores/layoutStore'
import { useVaultStore } from '@/features/vault/store/vaultStore'
import { migrationService } from '@/features/backup/service/migrationService'
const emit = defineEmits(['restore-success'])
const layoutStore = useLayoutStore()
const vaultStore = useVaultStore()
// --- 状态定义 ---
const providers = ref([])
@@ -204,7 +206,7 @@ const showConfigDialog = ref(false)
const isEditing = ref(false)
const isTesting = ref(false)
const isSaving = ref(false)
const form = ref({ type: 'webdav', name: '', config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' }, autoBackup: false, autoBackupPassword: '' })
const form = ref({ type: 'webdav', name: '', config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' }, autoBackup: false, autoBackupPassword: '', autoBackupRetain: 30 })
const currentProviderId = ref(null)
const hasExistingAutoPwd = ref(false)
const configUseExistingAutoPwd = ref(false)
@@ -213,8 +215,8 @@ const configUseExistingAutoPwd = ref(false)
const showBackupDialog = ref(false)
const backupPassword = ref('')
const isBackingUp = ref(false)
const currentActionProvider = ref(null)
const useAutoPassword = ref(false)
const currentActionProvider = ref(null)
const showRestoreListDialog = ref(false)
const isLoadingFiles = ref(false)
@@ -226,11 +228,15 @@ const isRestoring = ref(false)
// --- API 交互 ---
const fetchProviders = async () => {
// 1. 离线优先:尝试加载缓存
const cached = localStorage.getItem('cached_backup_providers')
if (cached) {
providers.value = JSON.parse(cached)
// 有缓存时,isLoading 保持 false (或设为 false),实现静默更新
// 1. 离线优先:尝试加载加密缓存 (仅在保险箱已解锁时有效)
if (vaultStore.isUnlocked) {
const cachedEncrypted = await vaultStore.getEncryptedBackupProviders()
if (cachedEncrypted && Array.isArray(cachedEncrypted)) {
providers.value = cachedEncrypted
// 有缓存时静默更新,不转圈圈
} else {
isLoading.value = true
}
} else {
isLoading.value = true
}
@@ -239,8 +245,10 @@ const fetchProviders = async () => {
const res = await request('/api/backups/providers')
if (res.success) {
providers.value = res.providers
// 2. 更新缓存
localStorage.setItem('cached_backup_providers', JSON.stringify(res.providers))
// 2. 更新加密缓存 (如果保险箱已解锁)
if (vaultStore.isUnlocked) {
await vaultStore.saveEncryptedBackupProviders(res.providers)
}
}
} finally { isLoading.value = false }
}
@@ -257,7 +265,7 @@ const formatSize = (bytes) => {
const openAddDialog = () => {
isEditing.value = false
form.value = { type: 'webdav', name: '', config: { url: '', username: '', password: '', saveDir: '/', endpoint: '', bucket: '', region: 'auto', accessKeyId: '', secretAccessKey: '' }, autoBackup: false, autoBackupPassword: '' }
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
@@ -271,7 +279,8 @@ const editProvider = (provider) => {
name: provider.name,
config: provider.config,
autoBackup: !!provider.auto_backup,
autoBackupPassword: '' // 编辑时不回显密码,留空表示不修改
autoBackupPassword: '', // 编辑时不回显密码,留空表示不修改
autoBackupRetain: provider.auto_backup_retain ?? 30
}))
hasExistingAutoPwd.value = !!provider.auto_backup_password
configUseExistingAutoPwd.value = true // 默认保持原密码
@@ -394,19 +403,37 @@ const selectRestoreFile = (file) => {
const handleRestore = async () => {
isRestoring.value = true
try {
const res = await request(`/api/backups/providers/${currentActionProvider.value.id}/restore`, {
// 1. 下载加密文件
const downloadRes = await request(`/api/backups/providers/${currentActionProvider.value.id}/download`, {
method: 'POST', body: JSON.stringify({
filename: selectedFile.value.filename,
password: restorePassword.value
filename: selectedFile.value.filename
})
})
if (res.success) {
ElMessage.success('恢复成功')
// 2. 前端解密
let contentToDecrypt = downloadRes.content
try {
// 备份文件是 JSON 包装格式,需要提取内部的 data 字段 (Base64)
// 修复:如果 contentToDecrypt 已经是对象,直接使用,避免 JSON.parse([object Object]) 报错
const json = typeof contentToDecrypt === 'string' ? JSON.parse(contentToDecrypt) : contentToDecrypt
if (json && json.encrypted && json.data) {
contentToDecrypt = json.data
}
} catch (e) {}
const vault = await migrationService.parseImportData(contentToDecrypt, 'encrypted', restorePassword.value)
// 3. 保存数据
const saveRes = await migrationService.saveImportedVault(vault)
if (saveRes.success) {
ElMessage.success(`恢复成功,已导入 ${saveRes.count} 个账号`)
showRestoreConfirmDialog.value = false
showRestoreListDialog.value = false
emit('restore-success')
}
} catch (e) {} finally { isRestoring.value = false }
} catch (e) {
ElMessage.error(e.message || '恢复失败')
} finally { isRestoring.value = false }
}
onMounted(fetchProviders)
@@ -0,0 +1,171 @@
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
}
}
@@ -11,7 +11,7 @@
</template>
<script setup>
import BackupSettings from './BackupSettings.vue'
import BackupSettings from '@/features/backup/components/backupSettings.vue'
const emit = defineEmits(['success'])
</script>
@@ -33,7 +33,7 @@
</el-dialog>
<!-- 明文导出风险提示弹窗 -->
<el-dialog v-model="showWarningDialog" title="⚠️ 安全警告" width="400px">
<el-dialog v-model="showWarningDialog" title="⚠️ 安全警告" width="400px" destroy-on-close>
<el-alert title="风险提示" type="error" :closable="false" description="您正在导出未加密的明文数据。任何获取该文件的人都可以直接访问您的账号验证码!" show-icon />
<p style="margin-top: 15px;">确定要继续吗?</p>
<template #footer>
@@ -47,7 +47,8 @@
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { getCookie } from '../utils/request'
import { migrationService } from '@/features/backup/service/migrationService'
import { downloadBlob } from '@/shared/utils/common'
const showPasswordDialog = ref(false)
const showWarningDialog = ref(false)
@@ -67,59 +68,45 @@ const openExportDialog = (type) => {
}
const executeExport = async () => {
const payload = { type: currentType.value }
const type = currentType.value
let password = ''
if (currentType.value === 'encrypted') {
if (type === 'encrypted') {
if (exportForm.value.password !== exportForm.value.confirm) {
return ElMessage.error('两次输入的密码不一致!')
}
if (exportForm.value.password.length < 12) {
return ElMessage.error('密码太弱!至少需要 12 个字符。')
}
payload.password = exportForm.value.password
loadingText.value = '正在进行高强度 AES-GCM 加密...'
} else {
loadingText.value = '正在生成导出文件...'
password = exportForm.value.password
}
isExporting.value = true
try {
const response = await fetch('/api/accounts/export', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCookie('csrf_token') || ''
},
credentials: 'include', // 关键:发送 httpOnly Cookie
body: JSON.stringify(payload)
})
if (!response.ok) {
const err = await response.json()
throw new Error(err.error || 'Export failed')
// 1. 获取所有账号
loadingText.value = '正在获取所有账号数据...'
const vault = await migrationService.fetchAllVault()
if (!vault || vault.length === 0) {
throw new Error('没有可导出的账号')
}
let blob, filename
// 2. 在前端处理数据
loadingText.value = type === 'encrypted' ? '正在进行高强度 AES-GCM 加密...' : '正在生成导出文件...'
const fileContent = await migrationService.exportData(vault, type, password)
// 3. 创建 Blob 并下载
let filename, mimeType
const date = new Date().toISOString().split('T')[0]
if (currentType.value === 'text') {
const text = await response.text()
blob = new Blob([text], { type: 'text/plain' })
if (type === 'text') {
mimeType = 'text/plain'
filename = `2fa-export-${date}.txt`
} else {
const data = await response.json()
blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
filename = `2fa-export-${currentType.value}-${date}.${currentType.value === '2fas' ? '2fas' : 'json'}`
mimeType = 'application/json'
filename = `2fa-export-${type}-${date}.${type === '2fas' ? '2fas' : 'json'}`
}
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
downloadBlob(fileContent, filename, mimeType)
ElMessage.success('导出成功!')
showPasswordDialog.value = false
@@ -11,7 +11,7 @@
<el-button size="large" plain @click="triggerImport('encrypted', '.json')">🔒 本系统加密备份 (.json)</el-button>
<el-button size="large" plain @click="triggerImport('json', '.json')">📄 标准 JSON / 2FAuth (.json)</el-button>
<el-button size="large" plain @click="triggerImport('2fas', '.2fas,.json')">📱 2FAS 备份 (.2fas)</el-button>
<el-button size="large" plain @click="triggerImport('text', '.txt')">📝 纯文本 URI (.txt)</el-button>
<el-button size="large" plain @click="triggerImport('text', '.txt')">📝 OTPAuth 纯文本格式 (.txt)</el-button>
</div>
<input type="file" ref="fileInputRef" :accept="acceptType" style="display: none" @change="handleFileUpload" />
</div>
@@ -35,7 +35,7 @@
<script setup>
import { ref } from 'vue'
import { ElMessage } from 'element-plus'
import { request } from '../utils/request'
import { migrationService } from '@/features/backup/service/migrationService'
const emit = defineEmits(['success'])
@@ -81,24 +81,37 @@ const submitImportData = async () => {
if (currentImportType.value === 'encrypted' && !importPassword.value) {
return ElMessage.warning('请输入解密密码')
}
loadingText.value = currentImportType.value === 'encrypted' ? '正在解密并导入数据...' : '正在导入数据...'
isImporting.value = true
try {
const data = await request('/api/accounts/import', {
method: 'POST',
body: JSON.stringify({
type: currentImportType.value,
content: currentFileContent.value,
password: importPassword.value
})
})
// 1. 前端解析/解密
loadingText.value = currentImportType.value === 'encrypted' ? '正在使用 AES-GCM 解密数据...' : '正在解析数据...'
const vault = await migrationService.parseImportData(
currentFileContent.value,
currentImportType.value,
importPassword.value
)
// 2. 发送给后端保存
loadingText.value = `正在保存 ${vault.length} 个账号...`
const data = await migrationService.saveImportedVault(vault)
if (data.success) {
let msg = `✅ 成功导入 ${data.count} 个账户!`
if (data.duplicates > 0) msg += ` (自动跳过了 ${data.duplicates} 个重复账户)`
ElMessage.success({ message: msg, duration: 5000 })
showDecryptDialog.value = false
emit('success')
if (data.count > 0) {
let msg = `✅ 成功导入 ${data.count} 个账户!`
if (data.duplicates > 0) msg += ` (自动跳过了 ${data.duplicates} 个重复账户)`
ElMessage.success({ message: msg, duration: 5000 })
emit('success') // 有实际导入才切换到账号列表
} else {
// 0 条:留在导入页,给出明确提示
const reason = data.total > 0 ? `文件中 ${data.total} 个账户均已存在,无需重复导入` : '未能从文件中解析到有效账户,请检查文件格式'
ElMessage.warning({ message: `⚠️ 导入完成,${reason}`, duration: 6000 })
}
}
} catch (error) {} finally { isImporting.value = false }
} catch (error) {
console.error(error)
ElMessage.error(error.message || '导入失败')
} finally { isImporting.value = false }
}
</script>
@@ -46,7 +46,11 @@ const generate = () => {
if (useNumber.value) chars += '0123456789'
if (useSymbol.value) chars += '!@#$%^&*()_+-=[]{}|;:,.<>?'
if (!chars) return (password.value = '')
if (!chars) {
password.value = ''
ElMessage.warning('请至少选择一种字符类型')
return
}
let res = ''
const array = new Uint32Array(length.value)
@@ -0,0 +1,39 @@
<template>
<div class="tool-pane">
<div class="qr-parser-container">
<!-- Use QrScanner Component -->
<QrScanner @scan-success="handleScanSuccess" />
<!-- Result Display -->
<div v-if="scanResult" class="result-section" style="margin-top: 20px;">
<el-divider content-position="left">解析结果</el-divider>
<el-input v-model="scanResult" type="textarea" :rows="3" readonly resize="none" />
<el-button type="success" plain style="width: 100%; margin-top: 10px;" @click="copyResult">
<el-icon><CopyDocument /></el-icon> 复制内容
</el-button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, defineAsyncComponent } from 'vue'
import { ElMessage } from 'element-plus'
import { CopyDocument } from '@element-plus/icons-vue'
// Import QrScanner component
const QrScanner = defineAsyncComponent(() => import('@/shared/components/qrScanner.vue'))
const scanResult = ref('')
const handleScanSuccess = (result) => {
scanResult.value = result
ElMessage.success('二维码解析成功')
}
const copyResult = async () => {
if (!scanResult.value) return
try { await navigator.clipboard.writeText(scanResult.value); ElMessage.success('内容已复制') } catch (e) {}
}
</script>
@@ -41,7 +41,7 @@
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ElMessage } from 'element-plus'
import { Refresh } from '@element-plus/icons-vue'
import { request } from '../utils/request'
import { request } from '@/shared/utils/request'
const localTime = ref(Date.now())
const offset = ref(null)
@@ -64,7 +64,7 @@ const syncTime = async () => {
isSyncing.value = true
const start = Date.now()
try {
const res = await request('/api/accounts/server-time')
const res = await request('/api/tools/server-time')
const end = Date.now()
if (res.success) {
const serverTs = res.time
@@ -73,9 +73,19 @@ const syncTime = async () => {
offset.value = Math.round(estimatedServerTime - end)
ElMessage.success(`校准完成,偏差 ${offset.value}ms`)
}
} catch (e) { ElMessage.error('无法连接服务器') } finally { isSyncing.value = false }
} catch (e) {
ElMessage.error(e.message || '无法连接服务器')
} finally {
isSyncing.value = false
}
}
onMounted(() => { clockTimer = setInterval(() => { localTime.value = Date.now() }, 1000); syncTime() })
onUnmounted(() => { if (clockTimer) clearInterval(clockTimer) })
onMounted(() => {
clockTimer = setInterval(() => { localTime.value = Date.now() }, 1000)
syncTime()
})
onUnmounted(() => {
if (clockTimer) clearInterval(clockTimer)
})
</script>
@@ -7,7 +7,7 @@
<div class="config-section">
<div class="section-header">
<h3 class="section-title">密钥配置</h3>
<el-button link type="primary" @click="triggerImportQr">
<el-button link type="primary" @click="showScanner = true">
<el-icon><Camera /></el-icon> 识别二维码导入
</el-button>
</div>
@@ -24,7 +24,7 @@
/>
<div class="tab-actions">
<el-button size="small" @click="refreshBase32"><el-icon><Refresh /></el-icon> 随机生成</el-button>
<el-button size="small" @click="copy(secretBase32)"><el-icon><CopyDocument /></el-icon> 复制</el-button>
<el-button size="small" @click="copyToClipboard(secretBase32)"><el-icon><CopyDocument /></el-icon> 复制</el-button>
</div>
</el-tab-pane>
@@ -39,7 +39,7 @@
/>
<div class="tab-actions">
<el-button size="small" @click="refreshHex"><el-icon><Refresh /></el-icon> 随机生成</el-button>
<el-button size="small" @click="copy(secretHex)"><el-icon><CopyDocument /></el-icon> 复制</el-button>
<el-button size="small" @click="copyToClipboard(secretHex)"><el-icon><CopyDocument /></el-icon> 复制</el-button>
</div>
</el-tab-pane>
@@ -54,7 +54,7 @@
/>
<div class="tab-actions">
<el-button size="small" @click="refreshAscii"><el-icon><Refresh /></el-icon> 随机生成</el-button>
<el-button size="small" @click="copy(secretAscii)"><el-icon><CopyDocument /></el-icon> 复制</el-button>
<el-button size="small" @click="copyToClipboard(secretAscii)"><el-icon><CopyDocument /></el-icon> 复制</el-button>
</div>
</el-tab-pane>
</el-tabs>
@@ -91,14 +91,14 @@
<div class="totp-timer" :class="{ 'urgent': remaining < 5 }">
<el-icon><Timer /></el-icon> {{ remaining }}s 后刷新
</div>
<el-button type="primary" plain size="small" @click="copy(currentCode)" :disabled="!currentCode" style="margin-top: 10px;">
<el-button type="primary" plain size="small" @click="copyToClipboard(currentCode, '验证码已复制')" :disabled="!currentCode" style="margin-top: 10px;">
<el-icon><CopyDocument /></el-icon> 复制验证码
</el-button>
</div>
</div>
<div class="uri-box" v-if="qrCodeUrl">
<div class="uri-text">{{ currentUri }}</div>
<el-button link type="primary" @click="copy(currentUri)"><el-icon><CopyDocument /></el-icon></el-button>
<el-button link type="primary" @click="copyToClipboard(currentUri)"><el-icon><CopyDocument /></el-icon></el-button>
</div>
</div>
@@ -139,25 +139,34 @@
<!-- Save Button -->
<div class="config-section" style="margin-top: 20px;">
<el-button type="success" size="large" @click="saveToAccount" style="width: 100%;" :loading="isSaving">
<el-button type="success" size="large" @click="saveToVault" style="width: 100%;" :loading="isSaving">
<el-icon><CircleCheck /></el-icon> 保存到我的账户
</el-button>
</div>
</div>
</div>
<input type="file" ref="qrFileInput" accept="image/*" style="display: none" @change="handleQrFileChange" />
<!-- 二维码扫描弹窗 -->
<el-dialog v-model="showScanner" title="扫描二维码" width="500px" destroy-on-close append-to-body>
<QrScanner @scan-success="handleScanSuccess" />
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { ref, onMounted, onUnmounted, defineAsyncComponent } from 'vue'
import { ElMessage } from 'element-plus'
import { CopyDocument, Refresh, Timer, Camera, CircleCheck, Download } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import jsQR from 'jsqr'
import { request } from '../utils/request'
import { request } from '@/shared/utils/request'
import { base32ToBytes, bytesToBase32, hexToBytes, bytesToHex, asciiToBytes, bytesToAscii, generateTOTP, parseOtpUri } from '@/shared/utils/totp'
import { useQueryClient } from '@tanstack/vue-query'
import { copyToClipboard, triggerDownload } from '@/shared/utils/common'
const QrScanner = defineAsyncComponent(() => import('@/shared/components/qrScanner.vue'))
// --- State ---
const queryClient = useQueryClient()
const activeTab = ref('base32')
const secretBase32 = ref('')
const secretHex = ref('')
@@ -176,84 +185,25 @@ const currentUri = ref('')
const currentCode = ref('')
const remaining = ref(30)
const isSaving = ref(false)
const qrFileInput = ref(null)
const showScanner = ref(false)
let timer = null
// --- Base32 Logic ---
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
const base32Encode = (buffer) => {
let bits = 0, value = 0, output = ''
for (let i = 0; i < buffer.length; i++) {
value = (value << 8) | buffer[i]
bits += 8
while (bits >= 5) {
output += alphabet[(value >>> (bits - 5)) & 31]
bits -= 5
}
}
if (bits > 0) output += alphabet[(value << (5 - bits)) & 31]
return output
}
const base32Decode = (input) => {
let bits = 0, value = 0
const buffer = []
const cleaned = input.toUpperCase().replace(/=+$/, '').replace(/[^A-Z2-7]/g, '')
for (let i = 0; i < cleaned.length; i++) {
const val = alphabet.indexOf(cleaned[i])
value = (value << 5) | val
bits += 5
while (bits >= 8) {
buffer.push((value >>> (bits - 8)) & 255)
bits -= 8
}
}
return new Uint8Array(buffer)
}
// --- Hex Logic ---
const toHex = (buffer) => {
return Array.from(buffer).map(b => b.toString(16).padStart(2, '0')).join('')
}
const fromHex = (hexString) => {
const hex = hexString.replace(/[^0-9a-fA-F]/g, '')
if (hex.length % 2 !== 0) return new Uint8Array(0)
const bytes = new Uint8Array(hex.length / 2)
for (let i = 0; i < hex.length; i += 2) bytes[i / 2] = parseInt(hex.substr(i, 2), 16)
return bytes
}
// --- ASCII Logic ---
const toAscii = (buffer) => {
return String.fromCharCode.apply(null, buffer)
}
const fromAscii = (str) => {
const buffer = new Uint8Array(str.length)
for (let i = 0; i < str.length; i++) {
buffer[i] = str.charCodeAt(i)
}
return buffer
}
// --- Handlers ---
const updateAll = async (sourceType) => {
try {
// 1. Sync Inputs
if (sourceType === 'base32') {
const bytes = base32Decode(secretBase32.value)
secretHex.value = toHex(bytes)
secretAscii.value = toAscii(bytes)
const bytes = base32ToBytes(secretBase32.value)
secretHex.value = bytesToHex(bytes)
secretAscii.value = bytesToAscii(bytes)
} else if (sourceType === 'hex') {
const bytes = fromHex(secretHex.value)
if (bytes.length > 0) secretBase32.value = base32Encode(bytes)
secretAscii.value = toAscii(bytes)
const bytes = hexToBytes(secretHex.value)
if (bytes.length > 0) secretBase32.value = bytesToBase32(bytes)
secretAscii.value = bytesToAscii(bytes)
} else if (sourceType === 'ascii') {
const bytes = fromAscii(secretAscii.value)
secretBase32.value = base32Encode(bytes)
secretHex.value = toHex(bytes)
const bytes = asciiToBytes(secretAscii.value)
secretBase32.value = bytesToBase32(bytes)
secretHex.value = bytesToHex(bytes)
}
// 2. Generate QR
@@ -286,7 +236,7 @@ const refreshBase32 = () => {
// Generate 20 random bytes (160 bits) - Standard high security
const array = new Uint8Array(20)
window.crypto.getRandomValues(array)
secretBase32.value = base32Encode(array)
secretBase32.value = bytesToBase32(array)
updateAll('base32')
ElMessage.success('已生成新的随机密钥')
}
@@ -295,7 +245,7 @@ const refreshHex = () => {
// Generate 20 random bytes
const array = new Uint8Array(20)
window.crypto.getRandomValues(array)
secretHex.value = toHex(array)
secretHex.value = bytesToHex(array)
updateAll('hex')
ElMessage.success('已生成新的 Hex 密钥')
}
@@ -321,24 +271,9 @@ const calcTotp = async () => {
remaining.value = Math.ceil(p - (now % p))
try {
const keyBytes = base32Decode(secretBase32.value)
if (keyBytes.length === 0) return
const timeBuffer = new ArrayBuffer(8)
new DataView(timeBuffer).setBigUint64(0, BigInt(epoch), false)
const key = await window.crypto.subtle.importKey('raw', keyBytes, { name: 'HMAC', hash: algorithm.value }, false, ['sign'])
const signature = await window.crypto.subtle.sign('HMAC', key, timeBuffer)
const view = new DataView(signature)
const offset = view.getUint8(19) & 0xf
const binary = ((view.getUint8(offset) & 0x7f) << 24) |
((view.getUint8(offset + 1) & 0xff) << 16) |
((view.getUint8(offset + 2) & 0xff) << 8) |
(view.getUint8(offset + 3) & 0xff)
const mod = Math.pow(10, digits.value)
currentCode.value = (binary % mod).toString().padStart(digits.value, '0')
// 计算周期偏移量 (Time Travel)
const periodOffset = Math.floor(timeOffset.value / p)
currentCode.value = await generateTOTP(secretBase32.value, p, digits.value, algorithm.value, periodOffset)
} catch (e) {
currentCode.value = 'ERROR'
}
@@ -352,82 +287,39 @@ const adjustTime = (delta, reset = false) => {
}
// --- Import QR ---
const triggerImportQr = () => qrFileInput.value.click()
const handleQrFileChange = (e) => {
const file = e.target.files[0]
if (!file) return
const reader = new FileReader()
reader.onload = (ev) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
canvas.width = img.width
canvas.height = img.height
const ctx = canvas.getContext('2d')
ctx.drawImage(img, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const code = jsQR(imageData.data, imageData.width, imageData.height)
if (code) {
parseOtpUri(code.data)
ElMessage.success('二维码解析成功')
} else {
ElMessage.warning('未识别到二维码')
}
}
img.src = ev.target.result
}
reader.readAsDataURL(file)
e.target.value = ''
const handleScanSuccess = (uri) => {
showScanner.value = false
handleParsedUri(uri)
}
const parseOtpUri = (uri) => {
try {
if (!uri.startsWith('otpauth://')) return
const url = new URL(uri)
const params = url.searchParams
if (params.has('secret')) {
secretBase32.value = params.get('secret')
const handleParsedUri = (uri) => {
const parsed = parseOtpUri(uri)
if (parsed) {
if (parsed.secret) {
secretBase32.value = parsed.secret
updateAll('base32')
}
if (params.has('issuer')) issuer.value = params.get('issuer')
if (params.has('digits')) digits.value = parseInt(params.get('digits'))
if (params.has('period')) period.value = parseInt(params.get('period'))
if (params.has('algorithm')) {
const algo = params.get('algorithm').toUpperCase()
if (algo === 'SHA1') algorithm.value = 'SHA-1'
else if (algo === 'SHA256') algorithm.value = 'SHA-256'
else if (algo === 'SHA512') algorithm.value = 'SHA-512'
}
// Parse Label
const label = decodeURIComponent(url.pathname.replace(/^\//, ''))
if (label.includes(':')) {
const parts = label.split(':')
if (!params.has('issuer')) issuer.value = parts[0]
account.value = parts[1]
} else {
account.value = label
}
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')
} catch (e) {
console.error('URI Parse Error', e)
ElMessage.success('二维码解析成功')
} else {
ElMessage.warning('无效的 OTP URI')
}
}
// --- Save to Account ---
const saveToAccount = async () => {
// --- Save to Vault ---
const saveToVault = async () => {
if (!secretBase32.value) return ElMessage.warning('密钥不能为空')
if (!issuer.value || !account.value) return ElMessage.warning('请填写服务商和账号')
isSaving.value = true
try {
const res = await request('/api/accounts', {
const res = await request('/api/vault', {
method: 'POST',
body: JSON.stringify({
service: issuer.value,
@@ -440,6 +332,8 @@ const saveToAccount = async () => {
})
if (res.success) {
ElMessage.success('已保存到我的账户')
// 刷新账号列表缓存
queryClient.invalidateQueries(['vault'])
}
} catch (e) {
// Error handled by request
@@ -450,17 +344,7 @@ const saveToAccount = async () => {
const downloadQrCode = () => {
if (!qrCodeUrl.value) return
const a = document.createElement('a')
a.href = qrCodeUrl.value
a.download = `2fa-qr-${account.value || 'code'}.png`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
const copy = async (text) => {
if (!text) return
try { await navigator.clipboard.writeText(text); ElMessage.success('复制成功') } catch (e) {}
triggerDownload(qrCodeUrl.value, `2fa-qr-${account.value || 'code'}.png`)
}
onMounted(() => {
@@ -41,12 +41,12 @@
</template>
<script setup>
import { ref, computed } from 'vue'
import { ref, computed, defineAsyncComponent } from 'vue'
import { Key, ArrowLeft, Timer, Lock, Camera } from '@element-plus/icons-vue'
import ToolPassword from './ToolPassword.vue'
import ToolTimeSync from './ToolTimeSync.vue'
import ToolQrParser from './ToolQrParser.vue'
import ToolTotpSecret from './ToolTotpSecret.vue'
const ToolPassword = defineAsyncComponent(() => import('@/features/tools/components/passwordGenerator.vue'))
const ToolTimeSync = defineAsyncComponent(() => import('@/features/tools/components/timeSync.vue'))
const ToolQrParser = defineAsyncComponent(() => import('@/features/tools/components/qrParser.vue'))
const ToolTotpSecret = defineAsyncComponent(() => import('@/features/tools/components/totpSecret.vue'))
const currentTool = ref(null)
@@ -92,10 +92,10 @@ const getToolTitle = (id) => {
const activeComponent = computed(() => {
switch (currentTool.value) {
case 'password': return ToolPassword
case 'time-sync': return ToolTimeSync
case 'qr-parser': return ToolQrParser
case 'totp-secret': return ToolTotpSecret
case 'password': return ToolPassword;
case 'time-sync': return ToolTimeSync;
case 'qr-parser': return ToolQrParser;
case 'totp-secret': return ToolTotpSecret;
default: return null
}
})
@@ -0,0 +1,126 @@
<template>
<div class="add-vault-wrapper">
<div class="tab-card-wrapper">
<h2 style="text-align: center; margin-bottom: 20px;">⌨️ 手动输入</h2>
<div style="max-width: 600px; margin: 0 auto;">
<el-form :model="newVault" label-position="top" :rules="rules" ref="addFormRef" style="padding: 10px 0;">
<el-form-item label="服务名称 (如 Google, GitHub)" prop="service">
<el-input v-model="newVault.service" placeholder="请输入服务名称" />
</el-form-item>
<el-form-item label="账号标识 (如 邮箱地址)" prop="account">
<el-input v-model="newVault.account" placeholder="请输入账号或邮箱" />
</el-form-item>
<el-form-item label="安全密钥 (Base32格式)" prop="secret">
<el-input v-model="newVault.secret" placeholder="请输入 16 位以上的安全密钥" clearable>
<template #append>
<el-button @click="generateRandomSecret" title="随机生成密钥"><el-icon><Refresh /></el-icon></el-button>
</template>
</el-input>
</el-form-item>
<el-row :gutter="20">
<el-col :span="8">
<el-form-item label="代码位数" prop="digits">
<el-select v-model="newVault.digits" style="width: 100%">
<el-option label="6 位" :value="6" />
<el-option label="8 位" :value="8" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="更新周期" prop="period">
<el-select v-model="newVault.period" style="width: 100%">
<el-option label="30 秒" :value="30" />
<el-option label="60 秒" :value="60" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="算法" prop="algorithm">
<el-select v-model="newVault.algorithm" style="width: 100%">
<el-option label="SHA1 (常用)" value="SHA1" />
<el-option label="SHA256" value="SHA256" />
<el-option label="SHA512" value="SHA512" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-form-item label="分类 (可选)" prop="category">
<el-input v-model="newVault.category" placeholder="如 工作, 个人" />
</el-form-item>
<el-form-item style="margin-top: 30px;">
<el-button type="primary" :loading="submitting" @click="submitAddVault" style="width: 100%;" size="large">确认添加</el-button>
</el-form-item>
</el-form>
</div>
</div>
</div>
</template>
<script setup>
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'
const emit = defineEmits(['success'])
const vaultStore = useVaultStore()
const submitting = ref(false)
const addFormRef = ref(null)
const newVault = ref({
service: '', account: '', secret: '', category: '', digits: 6, period: 30, algorithm: 'SHA1'
})
const validateSecret = (rule, value, callback) => {
if (!value) {
return callback(new Error('请输入安全密钥'))
}
// 移除空格后检查
const clean = value.replace(/\s/g, '')
if (clean.length < 16) {
return callback(new Error('密钥长度通常不少于 16 位'))
}
if (!/^[A-Z2-7]+$/i.test(clean)) {
return callback(new Error('包含非法字符 (仅限 A-Z, 2-7)'))
}
callback()
}
const rules = {
service: [{ required: true, message: '请输入服务名称', trigger: 'blur' }],
account: [{ required: true, message: '请输入账号标识', trigger: 'blur' }],
secret: [{ required: true, validator: validateSecret, trigger: 'blur' }]
}
const generateRandomSecret = () => {
const array = new Uint8Array(20)
window.crypto.getRandomValues(array)
newVault.value.secret = bytesToBase32(array)
}
const submitAddVault = async () => {
if (!addFormRef.value) return
await addFormRef.value.validate(async (valid) => {
if (valid) {
submitting.value = true
try {
const data = await request('/api/vault', {
method: 'POST',
body: JSON.stringify(newVault.value)
})
if (data.success) {
ElMessage.success('账号添加成功!')
newVault.value = { service: '', account: '', secret: '', category: '', digits: 6, period: 30, algorithm: 'SHA1' }
vaultStore.markDirty() // 实际写入数据,标记缓存过期
emit('success')
}
} catch (error) {
} finally {
submitting.value = false
}
}
})
}
</script>
@@ -0,0 +1,77 @@
<template>
<div class="add-vault-wrapper">
<div class="tab-card-wrapper">
<h2 style="text-align: center; margin-bottom: 20px;">📷 扫码添加</h2>
<div style="max-width: 600px; margin: 0 auto;">
<div style="text-align: center; margin-bottom: 20px; margin-top: 10px;">
<p style="color: var(--el-text-color-secondary);">请允许浏览器使用摄像头,或直接上传微信/系统截图。</p>
</div>
<QrScanner @scan-success="handleScanSuccess" />
</div>
</div>
</div>
</template>
<script setup>
import { h, defineAsyncComponent } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { request } from '@/shared/utils/request'
import { parseOtpUri } from '@/shared/utils/totp'
import { useVaultStore } from '@/features/vault/store/vaultStore'
const QrScanner = defineAsyncComponent(() => import('@/shared/components/qrScanner.vue'))
const emit = defineEmits(['success'])
const vaultStore = useVaultStore()
const handleScanSuccess = async (uri) => {
try {
const acc = parseOtpUri(uri)
if (!acc) {
ElMessage.error('二维码格式无效,请扫描有效的 OTP 二维码')
return
}
await ElMessageBox.confirm(
h('div', { style: 'text-align: left; background: var(--el-fill-color-light); padding: 16px; border-radius: 8px; border: 1px solid var(--el-border-color-lighter); margin-top: 10px;' }, [
h('div', { style: 'margin-bottom: 12px; display: flex; align-items: center;' }, [
h('span', { style: 'color: var(--el-text-color-secondary); width: 70px; flex-shrink: 0;' }, '服务方'),
h('span', { style: 'font-weight: 600; font-size: 15px; color: var(--el-text-color-primary); word-break: break-all;' }, acc.service || '未指定服务')
]),
h('div', { style: 'margin-bottom: 12px; display: flex; align-items: center;' }, [
h('span', { style: 'color: var(--el-text-color-secondary); width: 70px; flex-shrink: 0;' }, '账 号'),
h('span', { style: 'font-family: monospace; font-size: 14px; background: var(--el-fill-color-darker); padding: 4px 8px; border-radius: 6px; color: var(--el-color-primary); word-break: break-all;' }, acc.account || '未命名账号')
]),
h('div', { style: 'display: flex; align-items: center;' }, [
h('span', { style: 'color: var(--el-text-color-secondary); width: 70px; flex-shrink: 0;' }, '参 数'),
h('div', { style: 'display: flex; gap: 8px; flex-wrap: wrap;' }, [
h('span', { style: 'background: var(--el-color-info-light-9); color: var(--el-color-info); border: 1px solid var(--el-color-info-light-7); padding: 2px 8px; border-radius: 4px; font-size: 12px;' }, acc.algorithm || 'SHA1'),
h('span', { style: 'background: var(--el-color-success-light-9); color: var(--el-color-success); border: 1px solid var(--el-color-success-light-7); padding: 2px 8px; border-radius: 4px; font-size: 12px;' }, `${acc.digits || 6}位`),
h('span', { style: 'background: var(--el-color-warning-light-9); color: var(--el-color-warning); border: 1px solid var(--el-color-warning-light-7); padding: 2px 8px; border-radius: 4px; font-size: 12px;' }, `${acc.period || 30}秒`)
])
])
]),
'二维码解析成功,确认添加?',
{
confirmButtonText: '确认添加',
cancelButtonText: '取消',
type: 'success',
center: true
}
)
const addData = await request('/api/vault/add-from-uri', {
method: 'POST',
body: JSON.stringify({ uri, category: '手机扫码' })
})
if (addData.success) {
ElMessage.success('扫码账号添加成功!')
vaultStore.markDirty() // 实际写入数据,标记缓存过期
emit('success')
}
} catch (err) {
if (err !== 'cancel') console.error(err)
}
}
</script>
@@ -0,0 +1,155 @@
<template>
<!-- 侧边栏 (Desktop) -->
<el-aside :width="isCollapse ? '64px' : '240px'" class="left-aside" v-if="!layoutStore.isMobile">
<el-menu
:default-active="activeTab"
class="side-menu"
@select="$emit('select', $event)"
:collapse="isCollapse"
>
<el-menu-item index="vault">
<el-icon><Iphone /></el-icon>
<template #title><span>我的账户</span></template>
</el-menu-item>
<el-sub-menu index="add-vault">
<template #title>
<el-icon><Plus /></el-icon><span>添加账号</span>
</template>
<el-menu-item index="add-vault-scan">
<el-icon><Camera /></el-icon><span>扫码添加</span>
</el-menu-item>
<el-menu-item index="add-vault-manual">
<el-icon><Edit /></el-icon><span>手动输入</span>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="migration">
<template #title>
<el-icon><Sort /></el-icon><span>数据迁移</span>
</template>
<el-menu-item index="migration-export"><el-icon><Upload /></el-icon><span>数据导出</span></el-menu-item>
<el-menu-item index="migration-import"><el-icon><Download /></el-icon><span>数据导入</span></el-menu-item>
</el-sub-menu>
<el-menu-item index="backups">
<el-icon><Cloudy /></el-icon><template #title><span>云端备份</span></template>
</el-menu-item>
<el-menu-item index="tools">
<el-icon><Tools /></el-icon><template #title><span>实用工具</span></template>
</el-menu-item>
</el-menu>
<!-- 底部操作按钮 -->
<div class="sidebar-footer" :class="{ 'is-collapsed': isCollapse }">
<el-button circle :icon="isCollapse ? Expand : Fold" @click="toggleCollapse" :title="isCollapse ? '展开' : '折叠'" />
<el-button circle :icon="themeStore.isDark ? Sunny : Moon" @click="themeStore.toggleTheme" />
<el-button circle @click="handleLogout" title="退出登录">
<el-icon><SwitchButton /></el-icon>
</el-button>
</div>
</el-aside>
<!-- 抽屉菜单 (Mobile) -->
<el-drawer
v-model="layoutStore.showMobileMenu"
direction="ltr"
size="240px"
:with-header="false"
>
<div class="drawer-content">
<el-menu
:default-active="activeTab"
class="side-menu"
@select="handleMobileSelect"
style="border-right: none;"
>
<el-menu-item index="vault">
<el-icon><Iphone /></el-icon>
<span>我的账户</span>
</el-menu-item>
<el-sub-menu index="add-vault">
<template #title>
<el-icon><Plus /></el-icon><span>添加账号</span>
</template>
<el-menu-item index="add-vault-scan">
<el-icon><Camera /></el-icon><span>扫码添加</span>
</el-menu-item>
<el-menu-item index="add-vault-manual">
<el-icon><Edit /></el-icon><span>手动输入</span>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="migration">
<template #title>
<el-icon><Sort /></el-icon><span>数据迁移</span>
</template>
<el-menu-item index="migration-export"><el-icon><Upload /></el-icon><span>数据导出</span></el-menu-item>
<el-menu-item index="migration-import"><el-icon><Download /></el-icon><span>数据导入</span></el-menu-item>
</el-sub-menu>
<el-menu-item index="backups">
<el-icon><Cloudy /></el-icon><span>云端备份</span>
</el-menu-item>
<el-menu-item index="tools">
<el-icon><Tools /></el-icon><span>实用工具</span>
</el-menu-item>
</el-menu>
<div class="sidebar-footer">
<el-button circle :icon="themeStore.isDark ? Sunny : Moon" @click="themeStore.toggleTheme" />
<el-button circle @click="handleLogout" title="退出登录">
<el-icon><SwitchButton /></el-icon>
</el-button>
</div>
</div>
</el-drawer>
</template>
<script setup>
import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import {
Iphone, Plus, Camera, Edit, Sort, Upload, Download,
Cloudy, Sunny, Moon, SwitchButton, Fold, Expand, Tools
} from '@element-plus/icons-vue'
import { useLayoutStore } from '@/shared/stores/layoutStore'
import { useThemeStore } from '@/shared/stores/themeStore'
import { useUserStore } from '@/features/auth/store/userStore'
const props = defineProps({
activeTab: {
type: String,
required: true
}
})
const emit = defineEmits(['select'])
const layoutStore = useLayoutStore()
const themeStore = useThemeStore()
const userStore = useUserStore()
const router = useRouter()
const isCollapse = ref(localStorage.getItem('sidebar_collapse') === 'true')
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value
localStorage.setItem('sidebar_collapse', isCollapse.value)
}
// 移动端:关闭抽屉后再通知父组件切换 Tab
const handleMobileSelect = (index) => {
layoutStore.showMobileMenu = false
emit('select', index)
}
const handleLogout = async () => {
if (layoutStore.showMobileMenu) {
layoutStore.showMobileMenu = false
}
await userStore.logout()
router.replace('/login')
ElMessage.success('已安全退出')
}
</script>
@@ -0,0 +1,543 @@
<template>
<div class="vault-list-wrapper" style="min-height: 400px;">
<!-- 0. 保险箱锁定状态 -->
<div v-if="!vaultStore.isUnlocked" class="vault-lock-wrapper">
<VaultUnlock @unlocked="handleUnlocked" />
</div>
<!-- 1. 加载状态 (已解锁且本地缓存尚未挂载,或者正在首次/后台请求时但无数据) -->
<div v-else-if="(isLoading || isFetching) && vault.length === 0" class="loading-state" style="display: flex; flex-direction: column; justify-content: center; align-items: center; min-height: 400px; color: var(--el-text-color-secondary);">
<el-icon class="is-loading" :size="48" style="margin-bottom: 20px; color: var(--el-color-primary);"><Loading /></el-icon>
<p style="font-size: 16px; letter-spacing: 1px;">数据获取中, 请稍候...</p>
</div>
<!-- 2. 空状态 (明确加载完毕 + 数据真正为空 + 无搜索) -->
<div v-else-if="!isLoading && !isFetching && vault.length === 0 && !searchQuery" class="empty-state">
<el-empty description="空空如也,快去添加你的第一个 2FA 账号吧!">
<el-button type="primary" @click="$emit('switch-tab', 'add-vault-scan')">去添加账号</el-button>
</el-empty>
</div>
<!-- 3. 数据列表 (已解锁) -->
<div v-else class="vault-content">
<div class="toolbar" style="margin-bottom: 20px; display: flex; gap: 15px; align-items: center; justify-content: space-between; flex-wrap: wrap;">
<el-input
v-model="searchQuery"
placeholder="🔍 搜索服务名称、账号或分类..."
clearable
style="max-width: 400px; flex: 1;"
/>
<div class="batch-actions" style="display: flex; align-items: center; gap: 10px;">
<template v-if="selectedIds.length > 0">
<span class="batch-text">已选 {{ selectedIds.length }} 项</span>
<el-button type="danger" plain @click="handleBulkDelete" :loading="isBulkDeleting">
<el-icon><Delete /></el-icon> 删除
</el-button>
<el-button @click="selectedIds = []" plain>取消</el-button>
</template>
<el-button v-else @click="selectAllLoaded" plain>全选已加载</el-button>
</div>
</div>
<div
class="list-container"
style="min-height: 200px;"
v-infinite-scroll="handleLoadMore"
:infinite-scroll-disabled="isLoadMoreDisabled"
:infinite-scroll-distance="100"
>
<el-row :gutter="20" v-if="vault.length > 0">
<el-col :xs="24" :sm="12" :md="8" :lg="6" v-for="vaultItem in vault" :key="vaultItem.id" style="margin-bottom: 20px;">
<el-card class="vault-card" :class="{ 'is-selected': selectedIds.includes(vaultItem.id) }" shadow="hover" @click="copyCode(vaultItem)">
<div class="card-header">
<div class="service-info">
<el-checkbox :model-value="selectedIds.includes(vaultItem.id)" @change="toggleSelection(vaultItem.id)" @click.stop />
<h3 class="service-name" :title="vaultItem.service">{{ vaultItem.service }}</h3>
<el-tag size="small" v-if="vaultItem.category" effect="light">{{ vaultItem.category }}</el-tag>
</div>
<el-dropdown trigger="click" @command="(cmd) => handleCommand(cmd, vaultItem)">
<el-icon class="more-icon" @click.stop><MoreFilled /></el-icon>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="qr">
<el-icon><Picture /></el-icon> 账号导出
</el-dropdown-item>
<el-dropdown-item command="edit">
<el-icon><Edit /></el-icon> 编辑账号
</el-dropdown-item>
<el-dropdown-item command="delete" style="color: #F56C6C;">
<el-icon><Delete /></el-icon> 删除账号
</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
<p class="vault-name">{{ vaultItem.account }}</p>
<div class="code-display-area">
<div class="code-left">
<div class="current-code">{{ vaultItem.currentCode || '------' }}</div>
<div class="next-code" v-if="vaultItem.nextCode">
{{ vaultItem.nextCode }}
</div>
</div>
<div class="code-right">
<el-progress
type="circle"
:percentage="vaultItem.percentage || 0"
:width="30"
:stroke-width="3"
:color="vaultItem.color || '#67C23A'"
>
<template #default>
<span class="timer-text">{{ vaultItem.remaining || 30 }}</span>
</template>
</el-progress>
</div>
</div>
</el-card>
</el-col>
</el-row>
<div v-if="isFetchingNextPage" style="text-align: center; padding: 20px; color: var(--el-text-color-secondary);">
<el-icon class="is-loading"><Loading /></el-icon> 正在加载更多...
</div>
<div v-if="!hasNextPage && vault.length > 0" style="text-align: center; padding: 20px; color: var(--el-text-color-secondary); font-size: 12px;">
- 到底了,没有更多账号了 -
</div>
<el-empty v-if="!isLoading && vault.length === 0 && searchQuery" description="没有找到匹配的账号" />
</div>
</div>
<!-- 编辑弹窗 -->
<el-dialog v-model="showEditDialog" title="✏️ 编辑账号" :width="layoutStore.isMobile ? '90%' : '400px'" destroy-on-close>
<el-form :model="editVaultData" label-position="top">
<el-form-item label="服务名称 (如 Google, GitHub)">
<el-input v-model="editVaultData.service" />
</el-form-item>
<el-form-item label="账号标识 (如 邮箱地址)">
<el-input v-model="editVaultData.account" />
</el-form-item>
<el-form-item label="分类 (可选)">
<el-input v-model="editVaultData.category" />
</el-form-item>
</el-form>
<template #footer>
<span class="dialog-footer">
<el-button @click="showEditDialog = false">取消</el-button>
<el-button type="primary" :loading="isEditing" @click="submitEditVault">保存修改</el-button>
</span>
</template>
</el-dialog>
<!-- 账号导出弹窗 -->
<el-dialog v-model="showQrDialog" title="账号导出" :width="layoutStore.isMobile ? '90%' : '350px'" center align-center destroy-on-close @closed="showSecret = false">
<div class="qr-container" v-if="currentQrItem">
<div class="qr-info">
<h3 class="qr-service">{{ currentQrItem.service }}</h3>
<p class="qr-account">{{ currentQrItem.account }}</p>
</div>
<div class="qr-image-wrapper">
<img :src="qrCodeUrl" class="qr-code-img" />
</div>
<p class="qr-tip">使用任意 2FA 应用扫描二维码即可添加此账户</p>
<div class="secret-section">
<div class="secret-box">
<div class="secret-text">{{ showSecret ? formatSecret(currentQrItem.secret) : '•••• •••• •••• ••••' }}</div>
<div class="secret-actions">
<el-icon class="action-icon" @click="showSecret = !showSecret" :title="showSecret ? '隐藏' : '显示'"><View v-if="!showSecret" /><Hide v-else /></el-icon>
<el-icon class="action-icon" @click="copySecret" title="复制密钥"><CopyDocument /></el-icon>
</div>
</div>
</div>
<div class="uri-link-wrapper">
<el-button link type="info" size="small" @click="copyOtpUrl">复制原始 otpauth 链接</el-button>
</div>
</div>
</el-dialog>
</div>
</template>
<script setup>
import { ref, shallowRef, onMounted, onUnmounted, watch, computed, defineAsyncComponent, nextTick, triggerRef } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { MoreFilled, Edit, Delete, Picture, View, Hide, CopyDocument, Loading } from '@element-plus/icons-vue'
import QRCode from 'qrcode'
import { request } from '@/shared/utils/request'
import { useLayoutStore } from '@/shared/stores/layoutStore'
import { useVaultStore } from '@/features/vault/store/vaultStore'
import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/vue-query'
import { generateTOTP } from '@/shared/utils/totp'
import { copyToClipboard } from '@/shared/utils/common'
// 异步引入解锁组件,避免循环依赖
const VaultUnlock = defineAsyncComponent(() => import('@/features/vault/components/vaultUnlock.vue'))
const emit = defineEmits(['switch-tab'])
const queryClient = useQueryClient()
const vaultStore = useVaultStore()
const layoutStore = useLayoutStore()
// --- 状态定义 ---
const vault = shallowRef([])
const searchQuery = ref('')
const pageSize = ref(12)
// --- 批量操作 ---
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('')
let globalTimer = null
let loadingMessageHandle = null
// --- 定时更新 (提升定义顺序) ---
// 更新倒计时与验证码
const updateVaultStatus = async (targetList) => {
// 支持传入特定列表进行预计算(用于渲染前),如果不传则默认更新当前显示的 vault
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
}
}))
// 关键优化:因为 vault 是 shallowRef,必须在每轮更新完毕后执行一次通知
// 从而使得 1000 次深层属性变更合并成 1 次 UI 组件挂载重绘
if (list === vault.value) {
triggerRef(vault)
}
}
// --- 工具函数 ---
let searchTimer = null
// --- Loading 提示控制 ---
const showTopLoading = () => {
// 仅在已解锁状态下显示 Loading
if (!vaultStore.isUnlocked) return
// 防抖:如果已经在显示或正在等待显示,则不重复触发
if (loadingMessageHandle || loadingTimer) return
loadingMessageHandle = ElMessage({
message: '数据正在加载...',
icon: Loading,
duration: 0, // 设为 0 则不会自动关闭
type: 'info',
grouping: true
})
}
let loadingTimer = null
const hideTopLoading = () => {
if (loadingTimer) clearTimeout(loadingTimer) // 清除等待中的 Loading
loadingTimer = null
if (loadingMessageHandle) {
loadingMessageHandle.close()
loadingMessageHandle = null
}
}
// --- Vue Query 集成 ---
// 1. 定义数据获取函数
const fetchVaultPage = async ({ pageParam = 1 }) => {
// 如果未解锁,直接返回空(理论上 enabled 会控制不执行,但做个兜底)
if (!vaultStore.isUnlocked) return { vault: [], pagination: { totalPages: 0 } }
// 如果是第一页且没有搜索,显示顶部 Loading
if (pageParam === 1 && !searchQuery.value) showTopLoading()
const query = new URLSearchParams({
page: pageParam,
limit: pageSize.value,
search: searchQuery.value
}).toString()
const data = await request(`/api/vault?${query}`)
// 隐藏 Loading (仅第一页)
if (pageParam === 1) hideTopLoading()
return data
}
// 2. 使用 useInfiniteQuery
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), // 仅在解锁后启用查询
staleTime: 0, // 始终视为过期,确保 invalidateQueries 能立即触发重新请求
})
// 3. 监听数据变化,同步到本地 vault 引用和保险箱
watch(data, async (newData) => {
if (!newData) return
// 展平分页数据
const flatVault = newData.pages.flatMap(page => page.vault || [])
// 智能合并:保留现有的 TOTP 计算状态 (currentCode, remaining 等),仅更新静态字段
// 这样可以避免每次 fetch 导致倒计时重置
const merged = flatVault.map(newAcc => {
const existing = vault.value.find(a => a.id === newAcc.id)
if (existing) {
return { ...existing, ...newAcc }
}
// Initialize new vault with all reactive properties to ensure Vue tracks them
return {
...newAcc,
currentCode: '------',
nextCode: null,
remaining: 30,
percentage: 0,
color: ''
}
})
// 1. Pre-calculate codes before assigning to the reactive ref
await updateVaultStatus(merged)
// 2. Update the view
vault.value = merged
// 3. Force trigger reactivity as a safeguard against missed updates
triggerRef(vault)
// 4. 加密保存到保险箱 (仅在无搜索词时保存,作为全量缓存)
if (!searchQuery.value && vaultStore.isUnlocked) {
try {
await vaultStore.saveData({ vault: merged })
vaultStore.clearDirty() // 此次获取的是最新数据,清除 dirty 标记
} catch (e) {
// Silently fail, or add more robust error handling if needed
}
}
})
// 5. 解锁回调
const handleUnlocked = async () => {
// 若有未保存的变更,跳过旧缓存,直接等待服务器数据
if (vaultStore.isDirty) return
// 1. 优先加载离线数据,实现秒开 (移除耗时的 await updateVaultStatus 控制权)
const vaultData = await vaultStore.getData()
if (vaultData && vaultData.vault) {
vault.value = vaultData.vault
triggerRef(vault) // 瞬间触发 UI 大列表首屏渲染 (携带缓存旧状态)
// 把耗时的 200次+ HMACS Hash 计算扔进异步宏任务/微任务中,让出关键渲染主线程
setTimeout(() => updateVaultStatus(), 0)
}
// 2. Vue Query 的 enabled 变为 true,会自动触发网络请求更新数据
}
// 6. 滚动加载处理
const isLoadMoreDisabled = computed(() => {
return isLoading.value || isFetchingNextPage.value || !hasNextPage.value || !vaultStore.isUnlocked || isError.value
})
const handleLoadMore = () => {
if (!isLoadMoreDisabled.value) {
fetchNextPage()
}
}
// 暴露给父组件的方法 (保持兼容性)
defineExpose({
fetchVault: () => queryClient.invalidateQueries({ queryKey: ['vault'] })
})
watch(searchQuery, () => {
// Vue Query 会自动处理,这里只需要处理 Loading UI
if (searchQuery.value) showTopLoading()
})
// 批量选择
const toggleSelection = (id) => {
const index = selectedIds.value.indexOf(id)
if (index > -1) selectedIds.value.splice(index, 1)
else selectedIds.value.push(id)
}
const selectAllLoaded = () => {
// 全选当前已加载的所有账号
selectedIds.value = vault.value.map(acc => acc.id)
}
// 批量删除
const handleBulkDelete = async () => {
try {
await ElMessageBox.confirm(`确定要删除选中的 ${selectedIds.value.length} 个账号吗?`, '警告', { type: 'error' })
isBulkDeleting.value = true
const res = await request('/api/vault/batch-delete', {
method: 'POST',
body: JSON.stringify({ ids: selectedIds.value })
})
if (res.success) {
ElMessage.success(`成功删除了 ${res.count} 个账号`)
selectedIds.value = []
vaultStore.markDirty()
queryClient.invalidateQueries({ queryKey: ['vault'] }) // 强制过期,绕过 staleTime
}
} catch (e) {} finally { isBulkDeleting.value = false }
}
// 单个账号操作 (删除/编辑/导出)
const handleCommand = async (command, item) => {
if (command === 'delete') {
try {
await ElMessageBox.confirm(`确定删除 [${item.service}] 吗?`, '警告', { type: 'error' })
const data = await request(`/api/vault/${item.id}`, { method: 'DELETE' })
if (data.success) {
ElMessage.success('账号已删除')
vaultStore.markDirty()
queryClient.invalidateQueries({ queryKey: ['vault'] }) // 强制过期,绕过 staleTime
}
} catch (e) {}
} else if (command === 'edit') {
editVaultData.value = { ...item, category: item.category || '' }
showEditDialog.value = true
} else if (command === 'qr') {
currentQrItem.value = item
try {
const uri = getOtpAuthUrl(item)
qrCodeUrl.value = await QRCode.toDataURL(uri, { width: 200, margin: 1, errorCorrectionLevel: 'M' })
showQrDialog.value = true
} catch (e) {
ElMessage.error('二维码生成失败')
}
}
}
// 提交编辑
const submitEditVault = async () => {
if (!editVaultData.value.service || !editVaultData.value.account) return ElMessage.warning('必填项不能为空')
isEditing.value = true
try {
const data = await request(`/api/vault/${editVaultData.value.id}`, {
method: 'PUT',
body: JSON.stringify({
service: editVaultData.value.service,
account: editVaultData.value.account,
category: editVaultData.value.category
})
})
if (data.success) {
ElMessage.success('账号修改成功')
showEditDialog.value = false
vaultStore.markDirty()
queryClient.invalidateQueries({ queryKey: ['vault'] }) // 强制过期,绕过 staleTime
}
} catch (e) {} finally { isEditing.value = false }
}
// --- 辅助功能 ---
const copyCode = async (item) => {
const code = (item.remaining <= 5 && item.nextCode) ? item.nextCode : item.currentCode
if (!code || code === '------') return
copyToClipboard(code, '验证码已复制')
}
const getOtpAuthUrl = (acc) => {
if (!acc) return ''
const label = encodeURIComponent(`${acc.service}:${acc.account}`)
const params = new URLSearchParams()
params.set('secret', acc.secret)
params.set('issuer', acc.service)
if (acc.algorithm) params.set('algorithm', acc.algorithm)
if (acc.digits) params.set('digits', acc.digits)
if (acc.period) params.set('period', acc.period)
return `otpauth://totp/${label}?${params.toString()}`
}
const copyOtpUrl = async () => {
const url = getOtpAuthUrl(currentQrItem.value)
copyToClipboard(url, 'otpauth已复制')
}
const formatSecret = (secret) => {
return secret ? secret.replace(/(.{4})/g, '$1 ').trim() : ''
}
const copySecret = async () => {
if (!currentQrItem.value?.secret) return
copyToClipboard(currentQrItem.value.secret, '密钥已复制')
}
onMounted(() => {
// 如果已经解锁 (例如从其他 Tab 切回来),尝试加载缓存
if (vaultStore.isUnlocked) {
handleUnlocked()
}
globalTimer = setInterval(updateVaultStatus, 1000)
})
onUnmounted(() => {
if (globalTimer) clearInterval(globalTimer)
if (searchTimer) clearTimeout(searchTimer)
hideTopLoading()
})
</script>
@@ -0,0 +1,159 @@
<template>
<div class="vault-unlock-container">
<el-card class="unlock-card" shadow="hover">
<div class="icon-wrapper">
<el-icon :size="48" color="#409EFF"><Lock /></el-icon>
</div>
<h2 class="title">{{ vaultStore.hasVault ? '解锁本地缓存' : '设置缓存密码' }}</h2>
<p class="subtitle" style="margin-bottom: 10px;">
{{ vaultStore.hasVault
? '请输入您的缓存密码以加载本地离线数据'
: '设置一个密码用于将 2FA 数据安全地缓存在本机'
}}
</p>
<div class="form-container">
<el-input
v-model="password"
type="password"
show-password
placeholder="请输入密码"
@keyup.enter="handleSubmit"
:prefix-icon="Key"
size="large"
/>
<div v-if="!vaultStore.hasVault" style="margin-top: 15px;">
<el-input
v-model="confirmPassword"
type="password"
show-password
placeholder="再次确认密码"
@keyup.enter="handleSubmit"
:prefix-icon="Key"
size="large"
/>
</div>
<div v-if="!vaultStore.hasVault" style="margin-top: 15px; text-align: left;">
<el-alert
title="缓存密码用途说明"
type="info"
:closable="false"
show-icon
>
<p style="margin: 5px 0 0 0; line-height: 1.5; font-size: 13px;">
此密码仅用于将 2FA 数据强加密保存到您的浏览器本地缓存中,防止<span style="color: var(--el-color-primary); font-weight: bold;">离线使用</span>时的明文泄露。<br/>
即使您意外遗忘了它,也可以随时<span style="color: var(--el-color-success); font-weight: bold;">清除并重置</span>,重新登录即可从云端恢复您的全部 2FA 数据。
</p>
</el-alert>
</div>
<div v-if="errorMsg" class="error-msg">
{{ errorMsg }}
</div>
<el-button
type="primary"
size="large"
class="submit-btn"
:loading="loading"
@click="handleSubmit"
>
{{ vaultStore.hasVault ? '验证并进入' : '开启缓存保护' }}
</el-button>
<div v-if="vaultStore.hasVault" class="reset-link">
<el-button link type="info" size="small" @click="handleResetPrompt">忘记密码?清除本地离线缓存</el-button>
</div>
</div>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, h } from 'vue'
import { Lock, Key } from '@element-plus/icons-vue'
import { useVaultStore } from '@/features/vault/store/vaultStore'
import { ElMessage, ElMessageBox } from 'element-plus'
const vaultStore = useVaultStore()
const emit = defineEmits(['unlocked'])
const password = ref('')
const confirmPassword = ref('')
const loading = ref(false)
const errorMsg = ref('')
onMounted(() => {
vaultStore.init()
})
const handleSubmit = async () => {
if (!password.value) {
errorMsg.value = '密码不能为空'
return
}
loading.value = true
errorMsg.value = ''
try {
if (vaultStore.hasVault) {
// 解锁模式
await vaultStore.unlock(password.value)
ElMessage.success('解锁成功')
emit('unlocked')
} else {
// 创建模式
if (password.value.length < 6) {
throw new Error('密码长度至少需要 6 位')
}
if (password.value !== confirmPassword.value) {
throw new Error('两次输入的密码不一致')
}
await vaultStore.setup(password.value)
ElMessage.success('保险箱创建成功')
emit('unlocked')
}
} catch (e) {
errorMsg.value = e.message
// 震动反馈 (如果支持)
if (navigator.vibrate) navigator.vibrate(200)
} finally {
loading.value = false
}
}
const handleResetPrompt = async () => {
try {
await ElMessageBox.confirm(
h('div', null, [
h('p', { style: 'margin-bottom: 10px; font-weight: 500;' }, '密码找不到了?没关系!'),
h('p', { style: 'font-size: 13px; color: var(--el-text-color-secondary); line-height: 1.6;' },
'点击下方按钮,系统将为您强制清理当前浏览器的本地加密缓存。清理完成后,您只需像初次访问一样,重新设置一个新的缓存密码即可。您的云端原始数据不会受到任何影响。'
)
]),
'清除缓存锁',
{
confirmButtonText: '确定清除',
cancelButtonText: '点错了,返回',
type: 'warning',
center: true
}
)
// User confirmed
localStorage.removeItem('secure_vault')
vaultStore.init()
password.value = ''
confirmPassword.value = ''
errorMsg.value = ''
ElMessage.success('本地缓存已完全清除,请重新设置密码')
} catch (err) {
if (err !== 'cancel') console.error(err)
}
}
</script>
@@ -0,0 +1,116 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { encryptDataWithPassword, decryptDataWithPassword } from '@/shared/utils/crypto'
export const useVaultStore = defineStore('vault', () => {
const isUnlocked = ref(false)
const hasVault = ref(false)
const isDirty = ref(false) // 标记缓存是否已因变更操作而过期
const _password = ref(null) // 仅保存在内存中,刷新页面即丢失
// 初始化检查:判断本地是否有加密数据,并尝试恢复会话
const init = async () => {
const encrypted = localStorage.getItem('secure_vault')
hasVault.value = !!encrypted
if (!hasVault.value) return
// 模式 A: 尝试从 sessionStorage 恢复会话级解锁状态
const sessionPassword = sessionStorage.getItem('vault_session_key')
if (sessionPassword) {
try {
// 验证密码是否仍然有效
await decryptDataWithPassword(encrypted, sessionPassword)
// 验证通过,自动解锁
_password.value = sessionPassword
isUnlocked.value = true
} catch (e) {
// 密码无效,清除会话并锁定
lock()
}
}
}
// 解锁保险箱
const unlock = async (password) => {
const encrypted = localStorage.getItem('secure_vault')
if (!encrypted) throw new Error('尚未创建保险箱')
try {
await decryptDataWithPassword(encrypted, password)
_password.value = password
isUnlocked.value = true
sessionStorage.setItem('vault_session_key', password)
return true
} catch (e) {
throw new Error('密码错误')
}
}
// 创建/重置保险箱
const setup = async (password, initialData = { vault: [] }) => {
const encrypted = await encryptDataWithPassword(initialData, password)
localStorage.setItem('secure_vault', encrypted)
_password.value = password
hasVault.value = true
isUnlocked.value = true
sessionStorage.setItem('vault_session_key', password)
}
// 锁定保险箱
const lock = () => {
_password.value = null
isUnlocked.value = false
sessionStorage.removeItem('vault_session_key')
}
// 获取数据 (使用内存密码解密)
const getData = async () => {
if (!isUnlocked.value || !_password.value) return null
const encrypted = localStorage.getItem('secure_vault')
if (!encrypted) return { vault: [] }
return await decryptDataWithPassword(encrypted, _password.value)
}
// 保存数据 (使用内存密码加密)
const saveData = async (data) => {
if (!isUnlocked.value || !_password.value) throw new Error('保险箱已锁定')
const encrypted = await encryptDataWithPassword(data, _password.value)
localStorage.setItem('secure_vault', encrypted)
}
// --- 备份云配置的专属加密缓存 ---
const getEncryptedBackupProviders = async () => {
if (!isUnlocked.value || !_password.value) return null
const encrypted = localStorage.getItem('backup_providers_cache')
if (!encrypted) return null
try {
const decrypted = await decryptDataWithPassword(encrypted, _password.value)
return decrypted.providers || null
} catch (e) {
return null
}
}
const saveEncryptedBackupProviders = async (providers) => {
if (!isUnlocked.value || !_password.value) return
const encrypted = await encryptDataWithPassword({ providers }, _password.value)
localStorage.setItem('backup_providers_cache', encrypted)
}
return {
isUnlocked,
hasVault,
isDirty,
init,
unlock,
setup,
lock,
getData,
markDirty: () => { isDirty.value = true },
clearDirty: () => { isDirty.value = false },
saveData,
getEncryptedBackupProviders,
saveEncryptedBackupProviders
}
})
@@ -0,0 +1,81 @@
<template>
<el-container class="home-container">
<el-container class="main-body">
<!-- 侧边栏(桌面 + 移动端抽屉,统一由 AppSidebar 管理) -->
<AppSidebar :active-tab="activeTab" @select="activeTab = $event" />
<el-main class="main-content">
<!-- 视图:我的账户 -->
<div v-if="activeTab === 'vault'" class="view-container">
<VaultList ref="vaultListRef" @switch-tab="activeTab = $event" />
</div>
<!-- 视图:添加账号 -->
<div v-if="activeTab === 'add-vault-scan'" class="view-container">
<AddVaultScan @success="handleSuccess" />
</div>
<div v-if="activeTab === 'add-vault-manual'" class="view-container">
<AddVaultManual @success="handleSuccess" />
</div>
<!-- 视图:数据导出 -->
<div v-if="activeTab === 'migration-export'" class="view-container">
<DataExport />
</div>
<!-- 视图:数据导入 -->
<div v-if="activeTab === 'migration-import'" class="view-container">
<DataImport @success="handleSuccess" />
</div>
<!-- 视图:云端备份 -->
<div v-if="activeTab === 'backups'" class="view-container">
<DataBackup @success="handleSuccess" />
</div>
<!-- 视图:实用工具 -->
<div v-if="activeTab === 'tools'" class="view-container">
<UtilityTools />
</div>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref, nextTick, defineAsyncComponent, watch } from 'vue'
import AppSidebar from '@/features/vault/components/appSidebar.vue'
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 DataBackup = defineAsyncComponent(() => import('@/features/backup/views/dataBackup.vue'))
const UtilityTools = defineAsyncComponent(() => import('@/features/tools/views/utilityTools.vue'))
const activeTab = ref('vault')
const vaultListRef = ref(null)
// 操作成功后:跳回账户列表并刷新数据
let pendingRefetch = false
const handleSuccess = () => {
activeTab.value = 'vault'
if (vaultListRef.value) {
nextTick(() => vaultListRef.value?.fetchVault())
} else {
// VaultList 是懒加载组件,标记待刷新,等 ref 绑定后执行
pendingRefetch = true
}
}
watch(vaultListRef, (ref) => {
if (ref && pendingRefetch) {
pendingRefetch = false
nextTick(() => ref.fetchVault())
}
})
</script>
+30 -9
View File
@@ -1,23 +1,29 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import { ElMessage } from 'element-plus'
import 'element-plus/dist/index.css'
import { registerSW } from 'virtual:pwa-register'
import 'element-plus/theme-chalk/dark/css-vars.css'
import './main.css'
import './dark.css'
import App from './App.vue'
import router from './router'
import { initTheme } from './states/theme'
import '@/app/styles/main.css'
import '@/app/styles/dark.css'
import App from '@/app/app.vue'
import router from '@/app/router'
import { useThemeStore } from '@/shared/stores/themeStore'
import { VueQueryPlugin } from '@tanstack/vue-query'
import { useVaultStore } from '@/features/vault/store/vaultStore'
const app = createApp(App)
const pinia = createPinia()
// 初始化主题状态 (从 localStorage 恢复)
initTheme()
app.use(pinia)
// 初始化主题状态
const themeStore = useThemeStore()
themeStore.initTheme()
// PWA Service Worker 注册
registerSW({
onOfflineReady() {
// 当 App 所有的资源都被缓存完毕,可以离线访问时触发
ElMessage.success({
message: '应用已缓存,支持离线访问',
duration: 3000
@@ -26,4 +32,19 @@ registerSW({
})
app.use(router)
app.mount('#app')
app.use(VueQueryPlugin, {
queryClientConfig: {
defaultOptions: {
queries: {
retry: 1,
refetchOnWindowFocus: false, // 避免切换窗口时频繁请求列表
staleTime: 1000 * 60 * 5 // 5分钟内数据视为新鲜,不自动重发请求
}
}
}
})
const vaultStore = useVaultStore()
vaultStore.init().then(() => {
app.mount('#app')
})
@@ -0,0 +1,179 @@
<template>
<div class="qr-scanner-container">
<el-radio-group v-model="scannerMode" style="margin-bottom: 20px; width: 100%; display: flex; justify-content: center;" @change="handleModeChange">
<el-radio-button label="camera">摄像头扫描</el-radio-button>
<el-radio-button label="image">图片识别</el-radio-button>
</el-radio-group>
<!-- Camera Mode -->
<div v-if="scannerMode === 'camera'" class="scan-area">
<div class="video-wrapper" v-show="isScanning">
<video ref="videoRef" class="video-preview" autoplay muted playsinline></video>
<div class="scan-overlay">
<div class="scan-line"></div>
</div>
</div>
<div v-if="!isScanning" class="camera-placeholder" @click="toggleCamera">
<el-icon :size="48" color="#909399"><Camera /></el-icon>
<p>点击开启摄像头</p>
</div>
<el-button :type="isScanning ? 'danger' : 'primary'" @click="toggleCamera" style="margin-top: 15px; width: 100%;" :loading="isStarting">
{{ isScanning ? '停止扫描' : '开启摄像头' }}
</el-button>
</div>
<!-- Image Mode -->
<div v-else class="upload-area">
<div class="image-preview" @click="triggerFileUpload">
<img v-if="previewImg" :src="previewImg" class="preview-img" />
<div v-else class="upload-placeholder">
<el-icon :size="48" color="#909399"><Picture /></el-icon>
<p>点击选择或拖拽二维码图片</p>
</div>
</div>
<input type="file" ref="fileInputRef" accept="image/*" style="display: none" @change="handleFileUpload" />
</div>
</div>
</template>
<script setup>
import { ref, onBeforeUnmount, nextTick } from 'vue'
import { Camera, Picture, Upload } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import jsQR from 'jsqr'
const emit = defineEmits(['scan-success'])
const scannerMode = ref('camera')
const videoRef = ref(null)
const fileInputRef = ref(null)
const isScanning = ref(false)
const isStarting = ref(false)
const previewImg = ref('')
let stream = null
let scanInterval = null
const handleModeChange = () => {
if (scannerMode.value !== 'camera') {
stopCamera()
}
}
const toggleCamera = () => {
if (isScanning.value) {
stopCamera()
} else {
startCamera()
}
}
const startCamera = async () => {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
ElMessage.error('您的浏览器不支持摄像头功能')
return
}
isStarting.value = true
try {
stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'environment' } // 优先调用后置摄像头
})
await nextTick()
if (videoRef.value) {
videoRef.value.srcObject = stream
videoRef.value.onloadedmetadata = () => {
videoRef.value.play()
isScanning.value = true
isStarting.value = false
scanInterval = setInterval(scanVideoFrame, 500) // 每半秒截取一帧分析
}
}
} catch (err) {
isStarting.value = false
ElMessage.error('无法访问摄像头,请检查权限设置')
console.error('Camera error:', err)
}
}
const stopCamera = () => {
if (stream) {
stream.getTracks().forEach(track => track.stop())
stream = null
}
if (scanInterval) {
clearInterval(scanInterval)
scanInterval = null
}
isScanning.value = false
isStarting.value = false
}
// 抽取视频帧进行二维码解析
const scanVideoFrame = () => {
if (!videoRef.value || videoRef.value.readyState !== videoRef.value.HAVE_ENOUGH_DATA) return
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d', { willReadFrequently: true })
canvas.width = videoRef.value.videoWidth
canvas.height = videoRef.value.videoHeight
ctx.drawImage(videoRef.value, 0, 0, canvas.width, canvas.height)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const code = jsQR(imageData.data, imageData.width, imageData.height)
// 以前限制了 code.data.startsWith('otpauth://'),既然变成了 shared 基础组件,就不应该做这个判定
if (code && code.data) {
stopCamera() // 扫到了就马上关掉摄像头
emit('scan-success', code.data)
}
}
const triggerFileUpload = () => {
if (fileInputRef.value) {
fileInputRef.value.click()
}
}
const handleFileUpload = (e) => {
const file = e.target.files[0]
if (!file) return
// 生成预览
previewImg.value = URL.createObjectURL(file)
const reader = new FileReader()
reader.onload = (event) => {
const img = new Image()
img.onload = () => {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width
canvas.height = img.height
ctx.drawImage(img, 0, 0)
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height)
const code = jsQR(imageData.data, imageData.width, imageData.height)
if (code && code.data) {
emit('scan-success', code.data)
} else {
ElMessage.error('未能从图片中识别出有效的二维码')
}
}
img.src = event.target.result
}
reader.readAsDataURL(file)
e.target.value = '' // 清空 input,允许重复上传同一张图片
}
onBeforeUnmount(() => {
stopCamera()
if (previewImg.value) {
URL.revokeObjectURL(previewImg.value)
}
})
</script>
@@ -1,18 +1,18 @@
<template>
<header class="header">
<div class="logo">
<el-button v-if="layoutState.isMobile && route.path !== '/login'" link @click="layoutState.showMobileMenu = true" style="margin-right: 5px; padding: 0;">
<el-button v-if="layoutStore.isMobile && route.path !== '/login'" link @click="layoutStore.showMobileMenu = true" style="margin-right: 5px; padding: 0;">
<el-icon :size="24" class="menu-icon"><Menu /></el-icon>
</el-button>
<el-icon :size="24" color="#409EFC" style="margin-right: 10px;"><Lock /></el-icon>
<h2>2FAuth</h2>
</div>
<div class="user-actions" v-if="route.path !== '/login'">
<div class="user-profile" v-if="!layoutState.isMobile">
<el-avatar :size="32" :src="userState.userInfo?.avatar">
{{ userState.userInfo?.username?.charAt(0)?.toUpperCase() }}
<div class="user-profile" v-if="!layoutStore.isMobile">
<el-avatar :size="32" :src="userStore.userInfo?.avatar">
{{ userStore.userInfo?.username?.charAt(0)?.toUpperCase() }}
</el-avatar>
<span class="username">{{ userState.userInfo?.username }}</span>
<span class="username">{{ userStore.userInfo?.username }}</span>
</div>
</div>
</header>
@@ -22,8 +22,10 @@
import { ref } from 'vue'
import { useRoute } from 'vue-router'
import { Lock, Menu } from '@element-plus/icons-vue'
import { layoutState } from '../states/layout'
import { userState } from '../states/user'
import { useLayoutStore } from '@/shared/stores/layoutStore'
import { useUserStore } from '@/features/auth/store/userStore'
const route = useRoute()
const layoutStore = useLayoutStore()
const userStore = useUserStore()
</script>
+12
View File
@@ -0,0 +1,12 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useLayoutStore = defineStore('layout', () => {
const isMobile = ref(false)
const showMobileMenu = ref(false)
return {
isMobile,
showMobileMenu
}
})
+42
View File
@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useThemeStore = defineStore('theme', () => {
const isDark = ref(false)
const initTheme = () => {
// 1. 检查本地存储
const saved = localStorage.getItem('theme')
if (saved) {
isDark.value = saved === 'dark'
} else {
// 2. 跟随系统
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
}
applyTheme()
}
const toggleTheme = () => {
isDark.value = !isDark.value
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
applyTheme()
}
const applyTheme = () => {
if (isDark.value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
// 监听系统变化 (仅在用户未手动设置过时生效)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
isDark.value = e.matches
applyTheme()
}
})
return { isDark, initTheme, toggleTheme }
})
+28
View File
@@ -0,0 +1,28 @@
import { ElMessage } from 'element-plus'
export async function copyToClipboard(text, successMsg = '复制成功') {
if (!text) return
try {
await navigator.clipboard.writeText(text)
ElMessage.success(successMsg)
} catch (e) {
ElMessage.error('复制失败')
console.error('Clipboard error:', e)
}
}
export function triggerDownload(href, filename) {
const a = document.createElement('a')
a.href = href
a.download = filename
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
}
export function downloadBlob(content, filename, mimeType) {
const blob = new Blob([content], { type: mimeType })
const url = URL.createObjectURL(blob)
triggerDownload(url, filename)
URL.revokeObjectURL(url)
}
+120
View File
@@ -0,0 +1,120 @@
// frontend/src/utils/crypto-vault.js
// 配置常量
const PBKDF2_ITERATIONS = 100000;
const SALT_LENGTH = 16;
const IV_LENGTH = 12; // AES-GCM 推荐 12 字节
const ALGORITHM = 'AES-GCM';
const HASH = 'SHA-256';
/**
* 将文本密码转换为加密密钥 (PBKDF2)
* @param {string} password - 用户输入的 PIN 码或密码
* @param {Uint8Array} salt - 盐值
* @returns {Promise<CryptoKey>}
*/
async function importKeyFromPassword(password, salt) {
const enc = new TextEncoder();
const keyMaterial = await window.crypto.subtle.importKey(
'raw',
enc.encode(password),
{ name: 'PBKDF2' },
false,
['deriveKey']
);
return window.crypto.subtle.deriveKey(
{
name: 'PBKDF2',
salt: salt,
iterations: PBKDF2_ITERATIONS,
hash: HASH
},
keyMaterial,
{ name: ALGORITHM, length: 256 },
false,
['encrypt', 'decrypt']
);
}
/**
* 加密数据
* @param {Object} data - 要加密的 JSON 对象
* @param {string} password - 密码
* @returns {Promise<string>} - 返回 base64 编码的加密字符串 (包含 salt + iv + ciphertext)
*/
export async function encryptDataWithPassword(data, password) {
const salt = window.crypto.getRandomValues(new Uint8Array(SALT_LENGTH));
const iv = window.crypto.getRandomValues(new Uint8Array(IV_LENGTH));
const key = await importKeyFromPassword(password, salt);
const enc = new TextEncoder();
const encodedData = enc.encode(JSON.stringify(data));
const ciphertext = await window.crypto.subtle.encrypt(
{ name: ALGORITHM, iv: iv },
key,
encodedData
);
// 打包格式: salt + iv + ciphertext
const combined = new Uint8Array(salt.length + iv.length + ciphertext.byteLength);
combined.set(salt, 0);
combined.set(iv, salt.length);
combined.set(new Uint8Array(ciphertext), salt.length + iv.length);
return arrayBufferToBase64(combined);
}
/**
* 解密数据
* @param {string} encryptedBase64 - base64 编码的加密字符串
* @param {string} password - 密码
* @returns {Promise<Object>} - 解密后的 JSON 对象
*/
export async function decryptDataWithPassword(encryptedBase64, password) {
try {
const combined = base64ToArrayBuffer(encryptedBase64);
// 提取 salt, iv, ciphertext
const salt = combined.slice(0, SALT_LENGTH);
const iv = combined.slice(SALT_LENGTH, SALT_LENGTH + IV_LENGTH);
const ciphertext = combined.slice(SALT_LENGTH + IV_LENGTH);
const key = await importKeyFromPassword(password, salt);
const decrypted = await window.crypto.subtle.decrypt(
{ name: ALGORITHM, iv: iv },
key,
ciphertext
);
const dec = new TextDecoder();
return JSON.parse(dec.decode(decrypted));
} catch (e) {
console.error('Decryption failed:', e);
throw new Error('密码错误或数据损坏');
}
}
// --- 辅助函数 ---
function arrayBufferToBase64(buffer) {
let binary = '';
const bytes = new Uint8Array(buffer);
const len = bytes.byteLength;
for (let i = 0; i < len; i++) {
binary += String.fromCharCode(bytes[i]);
}
return window.btoa(binary);
}
function base64ToArrayBuffer(base64) {
const binary_string = window.atob(base64);
const len = binary_string.length;
const bytes = new Uint8Array(len);
for (let i = 0; i < len; i++) {
bytes[i] = binary_string.charCodeAt(i);
}
return bytes;
}
+94
View File
@@ -0,0 +1,94 @@
import { ElMessage } from 'element-plus'
import router from '@/app/router'
import { useUserStore } from '@/features/auth/store/userStore'
// 辅助函数:从 document.cookie 中安全地读取指定的 cookie 值
export function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
let isNavigatingToLogin = false; // 增加防重入锁,避免并发 401 触发无限弹窗和跳转
export async function request(url, options = {}) {
const headers = {
'Content-Type': 'application/json',
...options.headers,
}
const csrfToken = getCookie('csrf_token');
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
try {
const response = await fetch(url, { ...options, headers, credentials: 'include' })
// 针对 204 No Content,没有 body,直接返回成功
if (response.status === 204) {
return { success: true };
}
const data = await response.json()
// 处理 401 Unauthorized (后端统一抛出的 AppError 会被 index.ts 捕获并返回 { code: 401, success: false, message: ...})
if (response.status === 401 || data.code === 401) {
if (options.silent) {
throw new Error(data.message || data.error || 'Unauthorized/Forbidden')
}
if (!isNavigatingToLogin) {
isNavigatingToLogin = true;
ElMessage.error(data.message || data.error || '会话已过期,请重新登录')
// 调用 Pinia Action 清空用户信息、缓存、以及重置内存状态,防止路由守卫死循环打回
try {
const userStore = useUserStore()
userStore.clearUserInfo()
} catch (e) {
// Fallback to manual localstorage clear if store is unavailable
localStorage.removeItem('userInfo')
localStorage.removeItem('secure_vault')
localStorage.removeItem('backup_providers_cache')
sessionStorage.removeItem('vault_session_key')
}
if (router && router.currentRoute.value.path !== '/login') {
router.replace('/login').then(() => {
// 给个延迟再释放锁,防止快速回溯
setTimeout(() => { isNavigatingToLogin = false }, 1000)
}).catch(() => {
setTimeout(() => { isNavigatingToLogin = false }, 1000)
})
} else {
setTimeout(() => { isNavigatingToLogin = false }, 1000)
}
}
throw new Error(data.message || data.error || 'Unauthorized/Forbidden')
}
// 处理其他业务报错或 HTTP 错误
// 注意:兼容旧的 data.error 和新的 data.message
if (!response.ok || data.success === false) {
if (!options.silent) {
ElMessage.error(data.message || data.error || '请求失败')
}
throw new Error(data.message || data.error || '请求失败')
}
// 解构新的标准响应格式。如果后端返回 { success: true, data: [...] },这里直接将 data 铺平返回,保持对原有旧代码的兼容性。
// 或如果未被包装,直接 fallback 回 data 本身
if (data.data !== undefined && Object.keys(data).includes('code')) {
return { success: true, ...data.data }
}
return data
} catch (error) {
// 屏蔽被 silent 处理过的 Auth Error
if (error.message !== 'Unauthorized/Forbidden' && !options.silent) {
console.error('API Request Error:', error)
}
throw error
}
}
+145
View File
@@ -0,0 +1,145 @@
// Base32 字母表
const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567'
// --- 编码 / 解码工具 ---
export function base32ToBytes(str) {
let bits = 0
let value = 0
let index = 0
const cleaned = str.toUpperCase().replace(/=+$/, '').replace(/[^A-Z2-7]/g, '')
const buffer = new Uint8Array(Math.ceil(cleaned.length * 5 / 8))
for (let i = 0; i < cleaned.length; i++) {
const val = ALPHABET.indexOf(cleaned[i])
if (val === -1) continue
value = (value << 5) | val
bits += 5
if (bits >= 8) {
buffer[index++] = (value >>> (bits - 8)) & 0xFF
bits -= 8
}
}
return buffer.slice(0, index)
}
export function bytesToBase32(buffer) {
let bits = 0
let value = 0
let output = ''
const bytes = new Uint8Array(buffer)
for (let i = 0; i < bytes.length; i++) {
value = (value << 8) | bytes[i]
bits += 8
while (bits >= 5) {
output += ALPHABET[(value >>> (bits - 5)) & 31]
bits -= 5
}
}
if (bits > 0) {
output += ALPHABET[(value << (5 - bits)) & 31]
}
return output
}
export function hexToBytes(hex) {
const cleaned = hex.replace(/[^0-9a-fA-F]/g, '')
if (cleaned.length % 2 !== 0) return new Uint8Array(0)
const bytes = new Uint8Array(cleaned.length / 2)
for (let i = 0; i < cleaned.length; i += 2) {
bytes[i / 2] = parseInt(cleaned.substr(i, 2), 16)
}
return bytes
}
export function bytesToHex(buffer) {
return Array.from(new Uint8Array(buffer))
.map(b => b.toString(16).padStart(2, '0'))
.join('')
}
export function asciiToBytes(str) {
const buffer = new Uint8Array(str.length)
for (let i = 0; i < str.length; i++) {
buffer[i] = str.charCodeAt(i)
}
return buffer
}
export function bytesToAscii(buffer) {
return String.fromCharCode.apply(null, new Uint8Array(buffer))
}
// --- TOTP 计算核心 ---
export async function generateTOTP(secret, period = 30, digits = 6, algorithm = 'SHA-1', offset = 0) {
if (!secret) return '------'
try {
const keyBytes = base32ToBytes(secret)
if (keyBytes.length === 0) return '------'
const now = Date.now() / 1000
const epoch = Math.floor(now / period) + offset
const timeBuffer = new ArrayBuffer(8)
const view = new DataView(timeBuffer)
view.setBigUint64(0, BigInt(epoch), false)
// Web Crypto API 算法映射
const cryptoAlgo = { name: 'HMAC', hash: algorithm }
const key = await window.crypto.subtle.importKey(
'raw', keyBytes, cryptoAlgo, false, ['sign']
)
const signature = await window.crypto.subtle.sign('HMAC', key, timeBuffer)
const sigView = new DataView(signature)
// 动态获取 offset (最后一个字节的低4位)
const offsetByte = sigView.getUint8(signature.byteLength - 1) & 0xf
const binary = ((sigView.getUint8(offsetByte) & 0x7f) << 24) |
((sigView.getUint8(offsetByte + 1) & 0xff) << 16) |
((sigView.getUint8(offsetByte + 2) & 0xff) << 8) |
(sigView.getUint8(offsetByte + 3) & 0xff)
const otp = binary % Math.pow(10, digits)
return otp.toString().padStart(digits, '0')
} catch (e) {
console.error('TOTP Error', e)
return 'ERROR'
}
}
// --- URI 解析 ---
export function parseOtpUri(uri) {
try {
const url = new URL(uri)
if (url.protocol !== 'otpauth:') return null
const params = url.searchParams
const secret = params.get('secret')
if (!secret) return null
const label = decodeURIComponent(url.pathname.replace(/^\//, '').replace(/^totp\//, ''))
let service = params.get('issuer') || ''
let account = label
if (label.includes(':')) {
const parts = label.split(':')
if (!service) service = parts[0].trim()
account = parts[1].trim()
}
return {
service: service || 'Unknown',
account: account || 'Unknown',
secret: secret.replace(/\s/g, '').toUpperCase(),
digits: parseInt(params.get('digits') || '6', 10),
period: parseInt(params.get('period') || '30', 10),
algorithm: (params.get('algorithm') || 'SHA1').toUpperCase().replace('SHA1', 'SHA-1').replace('SHA256', 'SHA-256').replace('SHA512', 'SHA-512'),
category: ''
}
} catch (e) {
return null
}
}
-6
View File
@@ -1,6 +0,0 @@
import { reactive } from 'vue'
export const layoutState = reactive({
isMobile: false,
showMobileMenu: false
})
-37
View File
@@ -1,37 +0,0 @@
import { ref } from 'vue'
export const isDark = ref(false)
export const initTheme = () => {
// 1. 检查本地存储
const saved = localStorage.getItem('theme')
if (saved) {
isDark.value = saved === 'dark'
} else {
// 2. 跟随系统
isDark.value = window.matchMedia('(prefers-color-scheme: dark)').matches
}
applyTheme()
}
export const toggleTheme = () => {
isDark.value = !isDark.value
localStorage.setItem('theme', isDark.value ? 'dark' : 'light')
applyTheme()
}
const applyTheme = () => {
if (isDark.value) {
document.documentElement.classList.add('dark')
} else {
document.documentElement.classList.remove('dark')
}
}
// 监听系统变化 (仅在用户未手动设置过时生效)
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (e) => {
if (!localStorage.getItem('theme')) {
isDark.value = e.matches
applyTheme()
}
})
-53
View File
@@ -1,53 +0,0 @@
import { reactive } from 'vue'
import { request } from '../utils/request'
const getStoredUser = () => {
try {
return JSON.parse(localStorage.getItem('userInfo') || '{}')
} catch (e) {
return {}
}
}
export const userState = reactive({
userInfo: getStoredUser(),
setUserInfo(info) {
this.userInfo = info
localStorage.setItem('userInfo', JSON.stringify(info))
},
clearUserInfo() {
this.userInfo = {}
localStorage.removeItem('userInfo')
// 关键修复:退出登录时必须清除账号列表缓存,防止敏感数据泄露给下一个用户
localStorage.removeItem('cached_accounts')
},
async logout() {
try {
// 请求后端清除 HttpOnly Cookie
await request('/api/oauth/logout', { method: 'POST' })
} catch (e) {
console.error('Logout request failed', e)
} finally {
// 无论后端是否成功,前端都必须清理状态并跳转
this.clearUserInfo()
}
},
async fetchUserInfo() {
try {
// 使用 silent: true 避免未登录时弹出 "Unauthorized" 错误提示
// 增加时间戳参数 ?_t=... 防止 PWA Service Worker 缓存了 "已登录" 的状态,导致退出后路由守卫误判
const data = await request(`/api/oauth/me?_t=${Date.now()}`, { silent: true })
if (data.success) {
this.setUserInfo(data.userInfo)
return true
}
} catch (e) {
this.clearUserInfo()
return false
}
}
})
-63
View File
@@ -1,63 +0,0 @@
import { ElMessage } from 'element-plus'
import router from '../router'
// 辅助函数:从 document.cookie 中安全地读取指定的 cookie 值
export function getCookie(name) {
const value = `; ${document.cookie}`;
const parts = value.split(`; ${name}=`);
if (parts.length === 2) return parts.pop().split(';').shift();
}
export async function request(url, options = {}) {
// 1. 移除旧的 localStorage Token 逻辑
const headers = {
'Content-Type': 'application/json',
...options.headers,
}
// 2. 新增:读取 CSRF Token 并添加到请求头
const csrfToken = getCookie('csrf_token');
if (csrfToken) {
headers['X-CSRF-Token'] = csrfToken;
}
try {
// 3. 新增:配置 credentials: 'include' 以自动发送 cookie
const response = await fetch(url, { ...options, headers, credentials: 'include' })
// 针对 204 No Content 等情况,没有 body,直接返回成功
if (response.status === 204) {
return { success: true };
}
const data = await response.json()
// 4. 更新:处理 401/403 未登录或 CSRF 失败
if (response.status === 401 || response.status === 403) {
// 如果开启了静默模式 (silent),则不弹窗、不自动跳转,直接抛出错误由调用方处理
if (options.silent) {
throw new Error(data.error || 'Unauthorized/Forbidden')
}
ElMessage.error(data.error || '会话已过期或权限不足,请重新登录')
localStorage.removeItem('userInfo')
// 增加判断:确保 router 已就绪且当前不在登录页
if (router && router.currentRoute.value.path !== '/login') {
router.push('/login').catch(() => {}) // 捕获可能的 NavigationDuplicated 错误
}
throw new Error(data.error || 'Unauthorized/Forbidden')
}
// 处理其他报错
if (!response.ok) {
ElMessage.error(data.error || data.message || '请求失败')
throw new Error(data.error || '请求失败')
}
return data
} catch (error) {
if (error.message !== 'Unauthorized/Forbidden') console.error('API Request Error:', error)
throw error
}
}
-272
View File
@@ -1,272 +0,0 @@
<template>
<el-container class="home-container">
<el-container class="main-body">
<!-- 侧边栏 (Desktop) -->
<el-aside :width="isCollapse ? '64px' : '240px'" class="left-aside" v-if="!layoutState.isMobile">
<el-menu
:default-active="activeTab"
class="side-menu"
@select="handleMenuSelect"
:collapse="isCollapse"
>
<el-menu-item index="accounts">
<el-icon><Iphone /></el-icon>
<template #title><span>我的账户</span></template>
</el-menu-item>
<el-sub-menu index="add-account">
<template #title>
<el-icon><Plus /></el-icon><span>添加账号</span>
</template>
<el-menu-item index="add-account-scan">
<el-icon><Camera /></el-icon><span>扫码添加</span>
</el-menu-item>
<el-menu-item index="add-account-manual">
<el-icon><Edit /></el-icon><span>手动输入</span>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="migration">
<template #title>
<el-icon><Sort /></el-icon><span>数据迁移</span>
</template>
<el-menu-item index="migration-export"><el-icon><Upload /></el-icon><span>数据导出</span></el-menu-item>
<el-menu-item index="migration-import"><el-icon><Download /></el-icon><span>数据导入</span></el-menu-item>
</el-sub-menu>
<el-menu-item index="backups">
<el-icon><Cloudy /></el-icon><template #title><span>云端备份</span></template>
</el-menu-item>
<el-menu-item index="tools">
<el-icon><Tools /></el-icon><template #title><span>实用工具</span></template>
</el-menu-item>
</el-menu>
<!-- 底部切换按钮 -->
<div class="sidebar-footer" :class="{ 'is-collapsed': isCollapse }">
<el-button circle :icon="isCollapse ? Expand : Fold" @click="toggleCollapse" :title="isCollapse ? '展开' : '折叠'" />
<el-button circle :icon="isDark ? Sunny : Moon" @click="toggleTheme" />
<el-button circle @click="handleLogout" title="退出登录">
<el-icon><SwitchButton /></el-icon>
</el-button>
</div>
</el-aside>
<!-- 抽屉菜单 (Mobile) -->
<el-drawer
v-model="layoutState.showMobileMenu"
direction="ltr"
size="240px"
:with-header="false"
>
<div class="drawer-content">
<el-menu
:default-active="activeTab"
class="side-menu"
@select="handleMenuSelect"
style="border-right: none;"
>
<el-menu-item index="accounts">
<el-icon><Iphone /></el-icon>
<span>我的账户</span>
</el-menu-item>
<el-sub-menu index="add-account">
<template #title>
<el-icon><Plus /></el-icon><span>添加账号</span>
</template>
<el-menu-item index="add-account-scan">
<el-icon><Camera /></el-icon><span>扫码添加</span>
</el-menu-item>
<el-menu-item index="add-account-manual">
<el-icon><Edit /></el-icon><span>手动输入</span>
</el-menu-item>
</el-sub-menu>
<el-sub-menu index="migration">
<template #title>
<el-icon><Sort /></el-icon><span>数据迁移</span>
</template>
<el-menu-item index="migration-export"><el-icon><Upload /></el-icon><span>数据导出</span></el-menu-item>
<el-menu-item index="migration-import"><el-icon><Download /></el-icon><span>数据导入</span></el-menu-item>
</el-sub-menu>
<el-menu-item index="backups">
<el-icon><Cloudy /></el-icon><span>云端备份</span>
</el-menu-item>
<el-menu-item index="tools">
<el-icon><Tools /></el-icon><span>实用工具</span>
</el-menu-item>
</el-menu>
<div class="sidebar-footer">
<el-button circle :icon="isDark ? Sunny : Moon" @click="toggleTheme" />
<el-button circle @click="handleLogout" title="退出登录">
<el-icon><SwitchButton /></el-icon>
</el-button>
</div>
</div>
</el-drawer>
<el-main class="main-content">
<!-- 视图:我的账户 -->
<div v-if="activeTab === 'accounts'" class="view-container">
<AccountList ref="accountListRef" @switch-tab="activeTab = $event" />
</div>
<!-- 视图:添加账号 -->
<div v-if="activeTab === 'add-account-scan'" class="view-container">
<AddAccountScan @success="handleSuccess" />
</div>
<div v-if="activeTab === 'add-account-manual'" class="view-container">
<AddAccountManual @success="handleSuccess" />
</div>
<!-- 视图:数据导出 -->
<div v-if="activeTab === 'migration-export'" class="view-container">
<DataExport />
</div>
<!-- 视图:数据导入 -->
<div v-if="activeTab === 'migration-import'" class="view-container">
<DataImport @success="handleSuccess" />
</div>
<!-- 视图:云端备份 -->
<div v-if="activeTab === 'backups'" class="view-container">
<DataBackup @success="handleSuccess" />
</div>
<!-- 视图:实用工具 -->
<div v-if="activeTab === 'tools'" class="view-container">
<UtilityTools />
</div>
</el-main>
</el-container>
</el-container>
</template>
<script setup>
import { ref, nextTick, defineAsyncComponent } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage } from 'element-plus'
import { Iphone, Plus, Camera, Edit, Sort, Upload, Download, Cloudy, Sunny, Moon, SwitchButton, Fold, Expand, Tools } from '@element-plus/icons-vue'
import { layoutState } from '../states/layout'
import { isDark, toggleTheme } from '../states/theme'
import { userState } from '../states/user'
const AccountList = defineAsyncComponent(() => import('../components/AccountList.vue'))
const AddAccountScan = defineAsyncComponent(() => import('../components/AddAccountScan.vue'))
const AddAccountManual = defineAsyncComponent(() => import('../components/AddAccountManual.vue'))
const DataExport = defineAsyncComponent(() => import('../components/DataExport.vue'))
const DataImport = defineAsyncComponent(() => import('../components/DataImport.vue'))
const DataBackup = defineAsyncComponent(() => import('../components/DataBackup.vue'))
const UtilityTools = defineAsyncComponent(() => import('../components/UtilityTools.vue'))
const activeTab = ref('accounts')
const accountListRef = ref(null)
const router = useRouter()
const isCollapse = ref(localStorage.getItem('sidebar_collapse') === 'true')
// 移动端菜单处理
const handleMenuSelect = (index) => {
activeTab.value = index
if (layoutState.isMobile) {
layoutState.showMobileMenu = false
}
}
// 操作成功回调 (刷新列表)
const handleSuccess = () => {
activeTab.value = 'accounts'
nextTick(() => {
if (accountListRef.value) {
accountListRef.value.fetchAccounts()
}
})
}
const handleLogout = async () => {
// 1. 移动端优先关闭抽屉,防止遮挡或状态残留
if (layoutState.showMobileMenu) {
layoutState.showMobileMenu = false
}
await userState.logout()
// 2. 使用 replace 替换当前历史,避免用户点“后退”键又回到已退出的页面
router.replace('/login')
ElMessage.success('已安全退出')
}
const toggleCollapse = () => {
isCollapse.value = !isCollapse.value
localStorage.setItem('sidebar_collapse', isCollapse.value)
}
</script>
<style scoped>
.home-container {
height: 100%; /* 占满父容器 (App.vue 的 main) */
background-color: var(--el-bg-color-page);
}
.main-body {
height: 100%; /* 自动填充 */
display: flex;
overflow: hidden; /* 内部滚动 */
}
.left-aside {
background: var(--el-bg-color);
border-right: 1px solid var(--el-border-color-light);
display: flex;
flex-direction: column;
overflow-x: hidden;
overflow-y: auto;
white-space: nowrap;
}
/* 视觉优化:隐藏侧边栏滚动条,保持界面清爽 */
.left-aside::-webkit-scrollbar {
width: 0;
height: 0;
}
.side-menu {
border-right: none;
padding-top: 10px;
flex: 1;
}
.main-content {
padding: 30px 20px;
width: 100%;
background-color: var(--el-bg-color-page);
overflow-y: auto; /* 内容区独立滚动 */
}
.sidebar-footer {
padding: 20px;
text-align: center;
display: flex;
justify-content: center;
gap: 10px;
}
.sidebar-footer .el-button {
margin-left: 0;
}
.sidebar-footer.is-collapsed {
flex-direction: column;
align-items: center;
padding: 10px 0;
}
.drawer-content {
height: 100%;
display: flex;
flex-direction: column;
overflow-y: auto; /* 确保移动端菜单过长时可以滚动 */
}
</style>
+22 -20
View File
@@ -1,3 +1,5 @@
// frontend/vite.config.js
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import { VitePWA } from 'vite-plugin-pwa'
@@ -9,8 +11,8 @@ export default defineConfig({
plugins: [
vue(),
AutoImport({
imports: ['vue', 'vue-router'], // 自动导入 ref, computed, useRouter 等
resolvers: [ElementPlusResolver({ importStyle: false })], // 关掉样式自动引入,交给 main.js
imports: ['vue', 'vue-router'],
resolvers: [ElementPlusResolver({ importStyle: false })],
}),
Components({
resolvers: [
@@ -20,18 +22,18 @@ export default defineConfig({
],
}),
VitePWA({
registerType: 'autoUpdate', // 自动更新 Service Worker
includeAssets: ['favicon.ico', 'logo.svg', 'apple-touch-icon.png'], // 静态资源缓存
registerType: 'autoUpdate',
includeAssets: ['favicon.ico', 'logo.svg', 'apple-touch-icon.png'],
devOptions: {
enabled: true // 开启开发环境支持
enabled: true
},
manifest: {
name: '2FAuth - Secure 2FA Manager',
name: '2FAuth Worker - Serverless 2FA Manager',
short_name: '2FAuth',
description: 'A secure, serverless 2FA management tool.',
theme_color: '#ffffff',
background_color: '#ffffff',
display: 'standalone', // 独立应用模式 (无浏览器地址栏)
display: 'standalone',
icons: [
{
src: 'pwa-192x192.png',
@@ -42,21 +44,16 @@ export default defineConfig({
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png'
},
{
src: 'pwa-512x512.png',
sizes: '512x512',
type: 'image/png',
purpose: 'any maskable' // 适应不同形状的图标 (圆角/圆形)
}
]
},
workbox: {
globPatterns: ['**/*.{js,css,html,ico,png,svg}'], // 缓存所有静态资源
navigateFallback: '/index.html', // 关键:SPA 单页应用离线必须回退到 index.html
// 关键修复:禁止 PWA 拦截 API 请求。防止 API 404/500 时返回 index.html 导致前端 JSON 解析报错
globPatterns: ['**/*.{js,css,html,ico,png,svg}'],
cleanupOutdatedCaches: true,
clientsClaim: true,
skipWaiting: true,
navigateFallback: '/index.html',
navigateFallbackDenylist: [/^\/api/],
// 明确 API 走网络优先策略 (NetworkOnly),不进行缓存
runtimeCaching: [{
urlPattern: ({ url }) => url.pathname.startsWith('/api'),
handler: 'NetworkOnly'
@@ -64,14 +61,19 @@ export default defineConfig({
}
})
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
host: '0.0.0.0', // 兼容 Docker DevContainer
host: '0.0.0.0',
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8787', // 你的 Hono 后端地址
target: 'http://localhost:8787',
changeOrigin: true
}
}
}
})
})