refactor: 重构项目2.0
This commit is contained in:
1 parent
f112031f4a
commit
694714a11d
92 files changed
+5194
-3421
No files matched your search
@@ -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) 管理器**
|
||||
|
||||
## ✨ 功能特性
|
||||
[](https://github.com/nap0o/2fauth-worker/actions/workflows/deploy.yml)
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://vuejs.org/)
|
||||
[](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) 协议完全开源。你可以自由地二次开发、分发或用于商业场景,只要包含原作者和版权声明即可。
|
||||
@@ -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
|
||||
@@ -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
@@ -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. 导出默认处理函数,实现前后端路由分发
|
||||
@@ -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;
|
||||
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
import { EnvBindings } from '../../config';
|
||||
import { EnvBindings } from '@/app/config';
|
||||
|
||||
export interface OAuthUserInfo {
|
||||
id: string;
|
||||
+2
-2
@@ -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 辅助函数
|
||||
+2
-2
@@ -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';
|
||||
+2
-2
@@ -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';
|
||||
+2
-2
@@ -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()) {
|
||||
+2
-2
@@ -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';
|
||||
+2
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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';
|
||||
+18
-9
@@ -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 = {};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
export * from './BackupProvider';
|
||||
export * from './WebDavProvider';
|
||||
export * from './S3Provider';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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 * * *"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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)
|
||||
+3
-2
@@ -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('/')
|
||||
+62
-35
@@ -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
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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>
|
||||
+23
-36
@@ -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
|
||||
+29
-16
@@ -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>
|
||||
+5
-1
@@ -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>
|
||||
+15
-5
@@ -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>
|
||||
+57
-173
@@ -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(() => {
|
||||
+9
-9
@@ -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
@@ -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>
|
||||
File renamed without changes.
+9
-7
@@ -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>
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
@@ -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 }
|
||||
})
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
import { reactive } from 'vue'
|
||||
|
||||
export const layoutState = reactive({
|
||||
isMobile: false,
|
||||
showMobileMenu: false
|
||||
})
|
||||
@@ -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()
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user