diff --git a/README.md b/README.md index 8dd6ad8..2de79a2 100644 --- a/README.md +++ b/README.md @@ -1,160 +1,172 @@ -# 2FAuth Worker - Serverless 2FA Manager -**2FAuth Worker** 是一个基于 Cloudflare Workers + D1 (SQLite) + Vue 3 构建的轻量级、安全、无服务器的二步验证(2FA/TOTP)管理工具。 +# 2FAuth Worker -它允许你完全掌控自己的 2FA 数据,支持多端同步、WebDAV 备份、S3存储桶、加密导入导出,并且完全免费部署在 Cloudflare 的边缘网络上。 +**基于 Cloudflare Workers + D1 的新型 Serverless 二步验证 (2FA/TOTP) 管理器** -## ✨ 功能特性 +[![Deploy to Cloudflare Workers](https://github.com/nap0o/2fauth-worker/actions/workflows/deploy.yml/badge.svg)](https://github.com/nap0o/2fauth-worker/actions/workflows/deploy.yml) +[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT) +[![Vue3](https://img.shields.io/badge/Vue.js-3.x-4FC08D?style=flat&logo=vue.js)](https://vuejs.org/) +[![Cloudflare Workers](https://img.shields.io/badge/Cloudflare-Workers-F38020?style=flat&logo=cloudflare)](https://workers.cloudflare.com/) -- ☁️ **Serverless 架构**:后端运行在 Cloudflare Workers,数据库使用 D1,无需购买服务器。 -- ⚡️ **极速部署**: 利用 Github Actions 自动化部署,数分钟即可完成。 -- 🔐 **OAuth2登录**:集成 GitHub 和 Cloudflare Access 安全登录。 -- 🔒 **安全隐私**:数据存储在你的私有 D1 数据库中,支持 AES-GCM 高强度加密导出。 -- 🖥️ **多端适配**:响应式设计,完美支持 PC 和移动端访问。 -- 📷 **扫码添加**:支持调用摄像头扫描二维码或上传图片识别添加账号。 -- 🔄 **数据同步**:支持 WebDAV,S3对象存储自动备份,数据永不丢失。 -- 📂 **导入导出**:支持导出导入 加密JSON 格式、2FAS、标准 JSON 格式、otpauth格式数据。 -- ⚙ **实用工具**:原生内置TOTP 密钥工具、时间校准器、二维码解析器等多款实用小工具。 -## 🛠️ 技术栈 - -- **Frontend**: Vue 3, Vite, Element Plus, Pinia (State Management via Reactive) -- **Backend**: Cloudflare Workers, Hono (Web Framework) -- **Database**: Cloudflare D1 (Serverless SQLite) -- **Deployment**: GitHub Actions - -## 🚀 部署指南 (Deployment) - -本项目设计为通过 GitHub Actions 自动部署到 Cloudflare。 - -### 1. 准备工作 - -1. 注册一个 [Cloudflare](https://dash.cloudflare.com/) 账号。 -2. Fork 本仓库到你的 GitHub 账户。 - -### 2. 配置 Cloudflare D1 数据库 - -1. 登录 Cloudflare Dashboard。 -2. 进入左侧菜单的 **存储和数据库** -> **D1 SQL 数据库**。 -3. 点击 **+ 创建数据库** 创建新数据库,命名为 **2fauth-db**,点击 **创建** 确认。 -4. 创建完成后,在数据库详情页找到 **Database ID** 并复制。 -5. 请妥善保存这个 ID,稍后我们需要将其配置到 GitHub Secrets (`CLOUDFLARE_D1_DATABASE_ID`) 中。 - - **以下GitHub OAuth 和 Cloudflare Access 至少配置一项用于登录** - -### 3. 配置 GitHub OAuth - -1. 访问 GitHub Developer Settings -> **OAuth Apps** -> **New OAuth App**。 -2. 填写信息: - - **Application Name**: 2FAuth (或任意名称) - - **Homepage URL**: `https://你的Worker子域名.workers.dev` (部署后可修改为Worker绑定的自定义域名) - - **Authorization callback URL**: `https://你的Worker子域名.workers.dev/oauth/callback` (部署后可修改为Worker绑定的自定义域名) -3. 创建后,保存 `Client ID` 并生成一个新的 `Client Secret`。 - -### 4. 配置 Cloudflare Access - -要启用 Cloudflare Access 登录,你需要: - -1. 进入 **Cloudflare Zero Trust Dashboard** -> **Access** -> **Applications**。 -2. 创建一个 **SaaS** 应用 (Select "SaaS")。 -3. 配置 **Application**: - - **应用程序**: `输入任意名称都行,如 2fauth` - - **选择身份验证协议**: `OIDC` - - **重定向 URL**: ``https://你的Worker子域名.workers.dev/oauth/callback` (部署后可修改为Worker绑定的自定义域名)` -4. 复制保存关键信息备用, `客户端 ID` 和 `客户端密码`。 -5. 配置 Access 策略 -6. 其余均不需要额外配置,一直下一步到完成 - -### 5. 配置 GitHub Secrets - -在你的 GitHub 仓库中,进入 **Settings** -> **Secrets and variables** -> **Actions** -> **New repository secret**,添加以下变量: - -| Secret Name | 说明 | 示例值 | -| `JWT_SECRET` | 用于签发登录 Token 的密钥 (随机字符串) | `random_string_here` | -| :--- | :--- | :--- | -| `ENCRYPTION_KEY` | 用于加密敏感数据的密钥 (32位随机字符串) | `openssl rand -hex 16` 生成 | -| `CLOUDFLARE_ACCOUNT_ID` | Cloudflare 账户 ID (在 Dash 面板右下角) | `a1b2...` | -| `CLOUDFLARE_API_TOKEN` | Cloudflare API 令牌 (需有 Workers 和 D1 读写权限) | `r4_...` | -| `CLOUDFLARE_D1_DATABASE_ID` | 你的 D1 数据库 ID (步骤 2 中生成的) | `xxxxxxxx-xxxx...` | -| `CLOUDFLARE_D1_DATABASE_NAME` | 你的 D1 数据库 名称 (步骤 2 中生成的) | 默认`2fauth-db` | -| `OAUTH_ALLOWED_USERS` | 允许登录的邮箱或用户名白名单 (逗号分隔) | `me@example.com,my_github_id` | -| `OAUTH_GITHUB_CLIENT_ID` | GitHub OAuth Client ID | `Ov23...` | -| `OAUTH_GITHUB_CLIENT_SECRET` | GitHub OAuth Client Secret | `a1b2...` | -| `OAUTH_GITHUB_REDIRECT_URI` | 你的 Worker 回调地址 | `https://xxx.workers.dev/oauth/callback` | -| `OAUTH_CLOUDFLARE_CLIENT_ID` | Cloudflare Access 客户端 ID | `...` | -| `OAUTH_CLOUDFLARE_CLIENT_SECRET` | Cloudflare Access 客户端密码 | `.. -| `OAUTH_CLOUDFLARE_ORG_DOMAIN` | Access 组织域名 | `https://.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)
点击“创建令牌” -> “创建自定义令牌”
权限 1: **账户** -> **D1** -> **编辑**
权限 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`
`Ov23...` | `OAUTH_GITHUB_CLIENT_SECRET`
`a1b2...` | `OAUTH_GITHUB_REDIRECT_URI`
`https://xxx.workers.dev/oauth/callback` | +| **Telegram** | `OAUTH_TELEGRAM_BOT_NAME`
`my_2fa_bot` | `OAUTH_TELEGRAM_BOT_TOKEN`
`123:ABC...` | *Telegram 无需 URI 变量* | +| **Google** | `OAUTH_GOOGLE_CLIENT_ID`
`123...apps.googleusercontent.com` | `OAUTH_GOOGLE_CLIENT_SECRET`
`GOCSPX-...` | `OAUTH_GOOGLE_REDIRECT_URI`
`https://xxx.workers.dev/oauth/callback` | +| **Cloudflare Access** | `OAUTH_CLOUDFLARE_CLIENT_ID`
`abc.access` | `OAUTH_CLOUDFLARE_CLIENT_SECRET`
`def...` | `OAUTH_CLOUDFLARE_REDIRECT_URI`
`https://xxx.workers.dev/oauth/callback`
*(该平台还需配置 `OAUTH_CLOUDFLARE_ORG_DOMAIN`,如: `https://.cloudflareaccess.com`)* | +| **Gitee** | `OAUTH_GITEE_CLIENT_ID`
`bcd...` | `OAUTH_GITEE_CLIENT_SECRET`
`efg...` | `OAUTH_GITEE_REDIRECT_URI`
`https://xxx.workers.dev/oauth/callback` | +| **NodeLoc** | `OAUTH_NODELOC_CLIENT_ID`
`cde...` | `OAUTH_NODELOC_CLIENT_SECRET`
`fgh...` | `OAUTH_NODELOC_REDIRECT_URI`
`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 -``` \ No newline at end of file +## 📄 License +本项目基于 [MIT License](LICENSE) 协议完全开源。你可以自由地二次开发、分发或用于商业场景,只要包含原作者和版权声明即可。 \ No newline at end of file diff --git a/backend/example.dev.vars b/backend/example.dev.vars new file mode 100644 index 0000000..a211c88 --- /dev/null +++ b/backend/example.dev.vars @@ -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 diff --git a/backend/package.json b/backend/package.json index a6d5389..7eb5516 100644 --- a/backend/package.json +++ b/backend/package.json @@ -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" } -} +} \ No newline at end of file diff --git a/backend/schema.sql b/backend/schema.sql index d94ef67..34ea6a7 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -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); \ No newline at end of file +CREATE INDEX IF NOT EXISTS idx_vault_service ON vault(service); +CREATE INDEX IF NOT EXISTS idx_vault_created_at ON vault(created_at DESC); \ No newline at end of file diff --git a/backend/src/config.ts b/backend/src/app/config.ts similarity index 96% rename from backend/src/config.ts rename to backend/src/app/config.ts index 12e8ae9..7d23f6b 100644 --- a/backend/src/config.ts +++ b/backend/src/app/config.ts @@ -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; diff --git a/backend/src/index.ts b/backend/src/app/index.ts similarity index 83% rename from backend/src/index.ts rename to backend/src/app/index.ts index fa6ba6b..6ec18c4 100644 --- a/backend/src/index.ts +++ b/backend/src/app/index.ts @@ -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 } }; @@ -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. 导出默认处理函数,实现前后端路由分发 diff --git a/backend/src/features/auth/authRoutes.ts b/backend/src/features/auth/authRoutes.ts new file mode 100644 index 0000000..4e8a573 --- /dev/null +++ b/backend/src/features/auth/authRoutes.ts @@ -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; \ No newline at end of file diff --git a/backend/src/features/auth/authService.ts b/backend/src/features/auth/authService.ts new file mode 100644 index 0000000..51f0047 --- /dev/null +++ b/backend/src/features/auth/authService.ts @@ -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 { + 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); + } +} diff --git a/backend/src/providers/oauth/BaseOAuthProvider.ts b/backend/src/features/auth/providers/baseOAuthProvider.ts similarity index 96% rename from backend/src/providers/oauth/BaseOAuthProvider.ts rename to backend/src/features/auth/providers/baseOAuthProvider.ts index 72b8ae0..4619508 100644 --- a/backend/src/providers/oauth/BaseOAuthProvider.ts +++ b/backend/src/features/auth/providers/baseOAuthProvider.ts @@ -1,4 +1,4 @@ -import { EnvBindings } from '../../config'; +import { EnvBindings } from '@/app/config'; export interface OAuthUserInfo { id: string; diff --git a/backend/src/providers/oauth/CloudflareAccessProvider.ts b/backend/src/features/auth/providers/cloudflareAccessProvider.ts similarity index 97% rename from backend/src/providers/oauth/CloudflareAccessProvider.ts rename to backend/src/features/auth/providers/cloudflareAccessProvider.ts index 14c9f13..a79306f 100644 --- a/backend/src/providers/oauth/CloudflareAccessProvider.ts +++ b/backend/src/features/auth/providers/cloudflareAccessProvider.ts @@ -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 辅助函数 diff --git a/backend/src/providers/oauth/GiteeProvider.ts b/backend/src/features/auth/providers/giteeProvider.ts similarity index 97% rename from backend/src/providers/oauth/GiteeProvider.ts rename to backend/src/features/auth/providers/giteeProvider.ts index df13adb..c645fcd 100644 --- a/backend/src/providers/oauth/GiteeProvider.ts +++ b/backend/src/features/auth/providers/giteeProvider.ts @@ -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'; diff --git a/backend/src/providers/oauth/GitHubProvider.ts b/backend/src/features/auth/providers/githubProvider.ts similarity index 96% rename from backend/src/providers/oauth/GitHubProvider.ts rename to backend/src/features/auth/providers/githubProvider.ts index 16454d3..ac05541 100644 --- a/backend/src/providers/oauth/GitHubProvider.ts +++ b/backend/src/features/auth/providers/githubProvider.ts @@ -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'; diff --git a/backend/src/providers/oauth/GoogleProvider.ts b/backend/src/features/auth/providers/googleProvider.ts similarity index 95% rename from backend/src/providers/oauth/GoogleProvider.ts rename to backend/src/features/auth/providers/googleProvider.ts index 4eaa6c1..1f45d4b 100644 --- a/backend/src/providers/oauth/GoogleProvider.ts +++ b/backend/src/features/auth/providers/googleProvider.ts @@ -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'; diff --git a/backend/src/providers/oauth/index.ts b/backend/src/features/auth/providers/index.ts similarity index 81% rename from backend/src/providers/oauth/index.ts rename to backend/src/features/auth/providers/index.ts index 7ceb7f4..e04a346 100644 --- a/backend/src/providers/oauth/index.ts +++ b/backend/src/features/auth/providers/index.ts @@ -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()) { diff --git a/backend/src/providers/oauth/NodeLocProvider.ts b/backend/src/features/auth/providers/nodeLocProvider.ts similarity index 99% rename from backend/src/providers/oauth/NodeLocProvider.ts rename to backend/src/features/auth/providers/nodeLocProvider.ts index cd22fb2..a38a3bf 100644 --- a/backend/src/providers/oauth/NodeLocProvider.ts +++ b/backend/src/features/auth/providers/nodeLocProvider.ts @@ -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'; diff --git a/backend/src/providers/oauth/TelegramProvider.ts b/backend/src/features/auth/providers/telegramProvider.ts similarity index 96% rename from backend/src/providers/oauth/TelegramProvider.ts rename to backend/src/features/auth/providers/telegramProvider.ts index a330fbf..dde89a3 100644 --- a/backend/src/providers/oauth/TelegramProvider.ts +++ b/backend/src/features/auth/providers/telegramProvider.ts @@ -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'; diff --git a/backend/src/features/backup/backupRoutes.ts b/backend/src/features/backup/backupRoutes.ts new file mode 100644 index 0000000..cd5e847 --- /dev/null +++ b/backend/src/features/backup/backupRoutes.ts @@ -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; diff --git a/backend/src/features/backup/backupService.ts b/backend/src/features/backup/backupService.ts new file mode 100644 index 0000000..0556813 --- /dev/null +++ b/backend/src/features/backup/backupService.ts @@ -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 { + 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 { + 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(); + } + } + } +} diff --git a/backend/src/providers/backup/BackupProvider.ts b/backend/src/features/backup/providers/backupProvider.ts similarity index 86% rename from backend/src/providers/backup/BackupProvider.ts rename to backend/src/features/backup/providers/backupProvider.ts index 715e9ed..d586467 100644 --- a/backend/src/providers/backup/BackupProvider.ts +++ b/backend/src/features/backup/providers/backupProvider.ts @@ -9,4 +9,5 @@ export interface BackupProvider { listBackups(): Promise; uploadBackup(filename: string, data: string): Promise; downloadBackup(filename: string): Promise; + deleteBackup(filename: string): Promise; } \ No newline at end of file diff --git a/backend/src/features/backup/providers/index.ts b/backend/src/features/backup/providers/index.ts new file mode 100644 index 0000000..a9a98f1 --- /dev/null +++ b/backend/src/features/backup/providers/index.ts @@ -0,0 +1,3 @@ +export * from '@/features/backup/providers/backupProvider'; +export * from '@/features/backup/providers/webDavProvider'; +export * from '@/features/backup/providers/s3Provider'; \ No newline at end of file diff --git a/backend/src/providers/backup/S3Provider.ts b/backend/src/features/backup/providers/s3Provider.ts similarity index 90% rename from backend/src/providers/backup/S3Provider.ts rename to backend/src/features/backup/providers/s3Provider.ts index fc0f7b0..370b996 100644 --- a/backend/src/providers/backup/S3Provider.ts +++ b/backend/src/features/backup/providers/s3Provider.ts @@ -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 解析库) // 提取 块中的 Key, LastModified, Size const backups: BackupFile[] = []; const regex = /[\s\S]*?(.*?)<\/Key>[\s\S]*?(.*?)<\/LastModified>[\s\S]*?(.*?)<\/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 { 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 { + 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}`); + } } \ No newline at end of file diff --git a/backend/src/features/backup/providers/webDavProvider.ts b/backend/src/features/backup/providers/webDavProvider.ts new file mode 100644 index 0000000..946e5fd --- /dev/null +++ b/backend/src/features/backup/providers/webDavProvider.ts @@ -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 { + await this.client.getDirectoryContents('/'); + return true; + } + + async listBackups(): Promise { + 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 { + 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 { + 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 { + 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}`); + } + } +} \ No newline at end of file diff --git a/backend/src/routes/telegram.ts b/backend/src/features/telegram/telegramRoutes.ts similarity index 98% rename from backend/src/routes/telegram.ts rename to backend/src/features/telegram/telegramRoutes.ts index 9959697..b7d0c4c 100644 --- a/backend/src/routes/telegram.ts +++ b/backend/src/features/telegram/telegramRoutes.ts @@ -1,5 +1,5 @@ import { Hono } from 'hono'; -import { EnvBindings } from '../config'; +import { EnvBindings } from '@/app/config'; const telegram = new Hono<{ Bindings: EnvBindings }>(); diff --git a/backend/src/features/telegram/telegramService.ts b/backend/src/features/telegram/telegramService.ts new file mode 100644 index 0000000..bc71020 --- /dev/null +++ b/backend/src/features/telegram/telegramService.ts @@ -0,0 +1,2 @@ +// This file is a placeholder for Telegram-related business logic. +export const telegramService = {}; diff --git a/backend/src/features/tools/toolsRoutes.ts b/backend/src/features/tools/toolsRoutes.ts new file mode 100644 index 0000000..d981b88 --- /dev/null +++ b/backend/src/features/tools/toolsRoutes.ts @@ -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; diff --git a/backend/src/features/vault/vaultRoutes.ts b/backend/src/features/vault/vaultRoutes.ts new file mode 100644 index 0000000..ff9bdf9 --- /dev/null +++ b/backend/src/features/vault/vaultRoutes.ts @@ -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; \ No newline at end of file diff --git a/backend/src/features/vault/vaultService.ts b/backend/src/features/vault/vaultService.ts new file mode 100644 index 0000000..6fa6bf6 --- /dev/null +++ b/backend/src/features/vault/vaultService.ts @@ -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 }; + } + +} diff --git a/backend/src/providers/backup/WebDavProvider.ts b/backend/src/providers/backup/WebDavProvider.ts deleted file mode 100644 index ed0eddd..0000000 --- a/backend/src/providers/backup/WebDavProvider.ts +++ /dev/null @@ -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 { - await this.client.getDirectoryContents('/'); - return true; - } - - async listBackups(): Promise { - 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 { - 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 { - 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; - } -} \ No newline at end of file diff --git a/backend/src/providers/backup/index.ts b/backend/src/providers/backup/index.ts deleted file mode 100644 index bd93d83..0000000 --- a/backend/src/providers/backup/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export * from './BackupProvider'; -export * from './WebDavProvider'; -export * from './S3Provider'; \ No newline at end of file diff --git a/backend/src/routes/accounts.ts b/backend/src/routes/accounts.ts deleted file mode 100644 index 7affc13..0000000 --- a/backend/src/routes/accounts.ts +++ /dev/null @@ -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; \ No newline at end of file diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts deleted file mode 100644 index adcfc80..0000000 --- a/backend/src/routes/auth.ts +++ /dev/null @@ -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; \ No newline at end of file diff --git a/backend/src/routes/backups.ts b/backend/src/routes/backups.ts deleted file mode 100644 index 99adcee..0000000 --- a/backend/src/routes/backups.ts +++ /dev/null @@ -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 { - 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; diff --git a/backend/src/shared/db/db.ts b/backend/src/shared/db/db.ts new file mode 100644 index 0000000..03f845e --- /dev/null +++ b/backend/src/shared/db/db.ts @@ -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 { + 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 { + 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,这里近似返回 +} \ No newline at end of file diff --git a/backend/src/shared/db/repositories/backupRepository.ts b/backend/src/shared/db/repositories/backupRepository.ts new file mode 100644 index 0000000..b523eb6 --- /dev/null +++ b/backend/src/shared/db/repositories/backupRepository.ts @@ -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 { + return await this.db + .select() + .from(backupProviders) + .orderBy(desc(backupProviders.updatedAt)); + } + + /** + * 根据提供商类型获取配置 + */ + async findByType(type: string): Promise { + const result = await this.db + .select() + .from(backupProviders) + .where(eq(backupProviders.type, type)) + .limit(1); + + return result[0]; + } + + /** + * 保存或更新某个提供商的配置 + */ + async upsert(type: string, data: Partial): Promise { + 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]; + } + } +} diff --git a/backend/src/shared/db/repositories/vaultRepository.ts b/backend/src/shared/db/repositories/vaultRepository.ts new file mode 100644 index 0000000..9c753dd --- /dev/null +++ b/backend/src/shared/db/repositories/vaultRepository.ts @@ -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 { + return await this.db + .select() + .from(vault) + .orderBy(desc(vault.createdAt)); + } + + /** + * 获取分页和搜索过滤的 vault items + */ + async findPaginated(page: number, limit: number, search: string): Promise { + 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 { + 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`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 { + const result = await this.db + .select() + .from(vault) + .where(eq(vault.id, id)) + .limit(1); + + return result[0]; + } + + /** + * 创建一个新 item + */ + async create(item: NewVaultItem): Promise { + const result = await this.db.insert(vault).values(item).returning(); + return result[0]; + } + + /** + * 批量创建 + */ + async batchCreate(items: NewVaultItem[]): Promise { + 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): Promise { + 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 { + 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 { + 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; + } +} diff --git a/backend/src/shared/db/schema.ts b/backend/src/shared/db/schema.ts new file mode 100644 index 0000000..a16bf28 --- /dev/null +++ b/backend/src/shared/db/schema.ts @@ -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; \ No newline at end of file diff --git a/backend/src/shared/middleware/auth.ts b/backend/src/shared/middleware/auth.ts new file mode 100644 index 0000000..5cec53b --- /dev/null +++ b/backend/src/shared/middleware/auth.ts @@ -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(); +} diff --git a/backend/src/shared/utils/common.ts b/backend/src/shared/utils/common.ts new file mode 100644 index 0000000..7fd9b6f --- /dev/null +++ b/backend/src/shared/utils/common.ts @@ -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; +} diff --git a/backend/src/utils/crypto.ts b/backend/src/shared/utils/crypto.ts similarity index 50% rename from backend/src/utils/crypto.ts rename to backend/src/shared/utils/crypto.ts index 5e50c9f..16dce61 100644 --- a/backend/src/utils/crypto.ts +++ b/backend/src/shared/utils/crypto.ts @@ -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 { + 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 { - 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 { - 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, secret: string): Promise { +export async function generateSecureJWT(payload: Record, secret: string, expiry: number = 86400 * 7): Promise { 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 ({'-':'+','_':'/'}[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 { 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 { - 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 { + 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 { 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)); } \ No newline at end of file diff --git a/backend/src/shared/utils/totp.ts b/backend/src/shared/utils/totp.ts new file mode 100644 index 0000000..28f211a --- /dev/null +++ b/backend/src/shared/utils/totp.ts @@ -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 { + 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 { + 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; + } +} diff --git a/backend/src/utils/db.ts b/backend/src/utils/db.ts deleted file mode 100644 index 52abb1e..0000000 --- a/backend/src/utils/db.ts +++ /dev/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 { - // 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 { - 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; -} \ No newline at end of file diff --git a/backend/src/utils/helper.ts b/backend/src/utils/helper.ts deleted file mode 100644 index 877eab5..0000000 --- a/backend/src/utils/helper.ts +++ /dev/null @@ -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(); -} \ No newline at end of file diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 2334537..7a66dcb 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -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" diff --git a/backend/wrangler.demo.jsonc b/backend/wrangler.demo.jsonc new file mode 100644 index 0000000..10a9e66 --- /dev/null +++ b/backend/wrangler.demo.jsonc @@ -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 * * *" + ] + } +} \ No newline at end of file diff --git a/backend/wrangler.dev.jsonc b/backend/wrangler.dev.jsonc index 60988b4..8a77f52 100644 --- a/backend/wrangler.dev.jsonc +++ b/backend/wrangler.dev.jsonc @@ -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 * * *" + ] } } \ No newline at end of file diff --git a/backend/wrangler.jsonc b/backend/wrangler.jsonc index 7bf9683..87ffdf1 100644 --- a/backend/wrangler.jsonc +++ b/backend/wrangler.jsonc @@ -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": { diff --git a/frontend/package.json b/frontend/package.json index 56dbd0b..e9bcbaf 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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" } } diff --git a/frontend/src/App.vue b/frontend/src/app/app.vue similarity index 70% rename from frontend/src/App.vue rename to frontend/src/app/app.vue index 6a9dea4..5fcf067 100644 --- a/frontend/src/App.vue +++ b/frontend/src/app/app.vue @@ -1,14 +1,15 @@ - diff --git a/frontend/src/components/AddAccountManual.vue b/frontend/src/components/AddAccountManual.vue deleted file mode 100644 index 9567da8..0000000 --- a/frontend/src/components/AddAccountManual.vue +++ /dev/null @@ -1,90 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/components/AddAccountScan.vue b/frontend/src/components/AddAccountScan.vue deleted file mode 100644 index 4c28df4..0000000 --- a/frontend/src/components/AddAccountScan.vue +++ /dev/null @@ -1,60 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/components/QrScanner.vue b/frontend/src/components/QrScanner.vue deleted file mode 100644 index 3239288..0000000 --- a/frontend/src/components/QrScanner.vue +++ /dev/null @@ -1,219 +0,0 @@ - - - - - \ No newline at end of file diff --git a/frontend/src/components/ToolQrParser.vue b/frontend/src/components/ToolQrParser.vue deleted file mode 100644 index bdd9d02..0000000 --- a/frontend/src/components/ToolQrParser.vue +++ /dev/null @@ -1,129 +0,0 @@ - - - \ No newline at end of file diff --git a/frontend/src/features/auth/store/userStore.js b/frontend/src/features/auth/store/userStore.js new file mode 100644 index 0000000..b29317f --- /dev/null +++ b/frontend/src/features/auth/store/userStore.js @@ -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 + } +}) \ No newline at end of file diff --git a/frontend/src/views/Login.vue b/frontend/src/features/auth/views/login.vue similarity index 97% rename from frontend/src/views/Login.vue rename to frontend/src/features/auth/views/login.vue index f62929d..44a6451 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/features/auth/views/login.vue @@ -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) diff --git a/frontend/src/views/OAuthCallback.vue b/frontend/src/features/auth/views/oauthCallback.vue similarity index 96% rename from frontend/src/views/OAuthCallback.vue rename to frontend/src/features/auth/views/oauthCallback.vue index cc5ad5d..b002eb9 100644 --- a/frontend/src/views/OAuthCallback.vue +++ b/frontend/src/features/auth/views/oauthCallback.vue @@ -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('/') diff --git a/frontend/src/components/BackupSettings.vue b/frontend/src/features/backup/components/backupSettings.vue similarity index 81% rename from frontend/src/components/BackupSettings.vue rename to frontend/src/features/backup/components/backupSettings.vue index 4cd8dc7..cc38ccf 100644 --- a/frontend/src/components/BackupSettings.vue +++ b/frontend/src/features/backup/components/backupSettings.vue @@ -33,10 +33,10 @@

上次备份: - {{ new Date(provider.last_backup_at).toLocaleString() }} + {{ new Date(provider.lastBackupAt).toLocaleString() }} 从未 - - {{ provider.last_backup_status }} + + {{ provider.lastBackupStatus }}

@@ -54,7 +54,7 @@ - + @@ -123,6 +123,10 @@ 系统将继续使用原有的自动备份密码。
+ + +
填 0 表示不对历史备份做任何限制与清理
+