diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml new file mode 100644 index 00000000..48aa97bb --- /dev/null +++ b/.github/workflows/deploy.yml @@ -0,0 +1,115 @@ +name: Deploy to Cloudflare + +on: + push: + branches: [main, uat, release*] + workflow_dispatch: + +env: + DATABASE_NAME: cfwarden-data + +jobs: + deploy: + name: Deploy Cloudflare Worker + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Wrangler CLI + run: npm install -g wrangler + + - name: Setup Rust toolchain + run: | + rustup toolchain install stable --profile minimal + rustup target add wasm32-unknown-unknown + + - name: Install system dependencies + run: sudo apt install -y lld clang gcc llvm + + - name: Install worker-build + run: cargo install worker-build + + - name: Authenticate with Cloudflare + run: echo "Authenticated with Cloudflare" + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Create D1 database + id: create_db + run: | + # Create D1 database and capture output + DB_OUTPUT=$(wrangler d1 create $DATABASE_NAME) + echo "$DB_OUTPUT" + + # Extract database_id from output + DATABASE_ID=$(echo "$DB_OUTPUT" | grep -oP 'database_id: \"\\K[^\"]+' || echo "") + + if [ -n "$DATABASE_ID" ]; then + echo "DATABASE_ID=$DATABASE_ID" >> $GITHUB_OUTPUT + echo "Database created with ID: $DATABASE_ID" + else + echo "Warning: Could not extract database_id from output" + echo "DATABASE_ID=${{ secrets.D1_DATABASE_ID }}" >> $GITHUB_OUTPUT + fi + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Update wrangler.jsonc + run: | + cat < wrangler.jsonc + { + "name": "cfwarden-work", + "main": "build/index.js", + "compatibility_date": "2025-09-19", + "account_id": "${{ secrets.CLOUDFLARE_ACCOUNT_ID }}", + "d1_databases": [ + { + "binding": "cfwarden_data", + "database_name": "cfwarden-data", + "database_id": "${{ steps.create_db.outputs.DATABASE_ID }}", + "remote": true + } + ], + "vars": { + "JWT_SECRET": "${{ secrets.JWT_SECRET }}", + "JWT_REFRESH_SECRET": "${{ secrets.JWT_REFRESH_SECRET }}", + "ALLOWED_EMAILS": "${{ secrets.ALLOWED_EMAILS }}", + "TWO_FACTOR_ENC_KEY": "${{ secrets.TWO_FACTOR_ENC_KEY }}" + }, + "observability": { + "logs": { + "enabled": true, + "head_sampling_rate": 1, + "invocation_logs": true, + "persist": true + } + }, + "build": { + "command": "worker-build --release" + } + } + EOF + + - name: Initialize database schema + run: wrangler d1 execute $DATABASE_NAME --remote --file=sql/schema_full.sql + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + + - name: Build worker + run: worker-build --release + + - name: Deploy to Cloudflare + run: wrangler deploy + env: + CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} + CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} diff --git a/GITHUB.md b/GITHUB.md new file mode 100644 index 00000000..14f761a0 --- /dev/null +++ b/GITHUB.md @@ -0,0 +1,86 @@ +# Cloudflare Worker 部署指南 + +本文档说明如何使用GitHub Actions自动部署Cloudflare Worker。 + +## 前置条件 + +在运行GitHub Action之前,您需要在GitHub仓库中配置以下环境变量: + +### 必需的环境变量 + +1. **CLOUDFLARE_API_TOKEN** + - 描述:Cloudflare API令牌,用于认证和部署 + - 获取方式:在Cloudflare仪表板中创建API令牌,需要包含以下权限: + - Account: Workers Scripts: Edit + - Account: Workers KV Storage: Edit + - Account: D1: Edit + - User: User Details: Read + +2. **CLOUDFLARE_ACCOUNT_ID** + - 描述:您的Cloudflare账户ID + - 获取方式:在Cloudflare仪表板的Overview页面找到Account ID + +3. **JWT_SECRET** + - 描述:JWT访问令牌签名密钥 + - 要求:至少32字符的随机字符串 + +4. **JWT_REFRESH_SECRET** + - 描述:JWT刷新令牌签名密钥 + - 要求:至少32字符的随机字符串,与JWT_SECRET不同 + +5. **ALLOWED_EMAILS** + - 描述:注册白名单,多个邮箱用英文逗号分隔 + - 示例:`user1@example.com,user2@example.com` + - 特殊值:使用`*`表示允许所有邮箱注册 + +### 可选的环境变量 + +6. **TWO_FACTOR_ENC_KEY**(可选) + - 描述:Base64编码的32字节密钥,用于加密存储TOTP秘钥 + - 如果不设置,TOTP秘钥将以明文形式存储(不推荐) + +7. **D1_DATABASE_ID**(可选) + - 描述:现有的D1数据库ID + - 如果已存在数据库,可以设置此变量跳过数据库创建步骤 + +## 配置步骤 + +### 1. 在GitHub仓库中设置环境变量 + +1. 进入您的GitHub仓库 +2. 点击 Settings → Secrets and variables → Actions +3. 点击 "New repository secret" +4. 为每个必需的环境变量添加对应的值 + +### 2. 手动触发部署 + +配置完成后,您可以: + +1. 推送代码到main分支(自动触发) +2. 或者在GitHub仓库的Actions标签页中手动运行工作流 + +## 工作流执行流程 + +GitHub Action将按以下顺序执行: + +1. **环境准备**:安装Node.js、Rust、Wrangler CLI等必要工具 +2. **数据库创建**:创建新的D1数据库(如果D1_DATABASE_ID未设置) +3. **数据库初始化**:执行sql/schema_full.sql初始化数据库结构 +4. **密钥配置**:配置JWT密钥、白名单等环境变量 +5. **构建部署**:构建Worker并部署到Cloudflare + +## 注意事项 + +- 首次部署时会创建新的D1数据库,后续部署会重用现有数据库 +- sql/schema_full.sql会清空现有数据,仅适用于全新部署 +- 如需保留数据,请使用sql/schema.sql进行增量更新 +- 确保所有环境变量都已正确设置,否则部署会失败 + +## 故障排除 + +如果部署失败,请检查: + +1. Cloudflare API令牌权限是否足够 +2. 环境变量值是否正确 +3. Cloudflare账户是否有效 +4. 网络连接是否正常 \ No newline at end of file diff --git a/README.md b/README.md index dc6cc6b2..e0a53bb4 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,24 @@ Warden Worker 是一个运行在 Cloudflare Workers 上的轻量级 Bitwarden - 核心能力:注册/登录、同步、密码项(Cipher)增删改、文件夹、TOTP(Authenticator)二步验证 - 官方安卓兼容:支持 `/api/devices/knowndevice` 与 remember-device(twoFactorProvider=5)流程 -## 快速部署(Cloudflare) +## 自动部署(Github Action) + +### 0. 前置条件 +- Cloudflare 账号 + R2数据库 +- Github 账号 + Action构建权 + +### 1. 部署仓库 + +1. 点击[Fork本项目](https://github.com/PIKACHUIM/warden-worker/fork),克隆到你的Github账号 +![QQ20260127-153440.jpg](images/QQ20260127-153440.jpg) + +2. +2. +![QQ20260127-150742.jpg](images/QQ20260127-150742.jpg) + + + +## 手动部署(Cloudflare) ### 0. 前置条件 @@ -26,7 +43,7 @@ Warden Worker 是一个运行在 Cloudflare Workers 上的轻量级 Bitwarden ### 1. 创建 D1 数据库 ```bash -wrangler d1 create vault1 +wrangler d1 create cfwarden-db ``` 把输出的 `database_id` 写入 `wrangler.jsonc` 的 `d1_databases`。 @@ -36,7 +53,7 @@ wrangler d1 create vault1 注意:`sql/schema_full.sql` 会 `DROP TABLE`,仅用于全新部署(会清空数据)。 ```bash -wrangler d1 execute vault1 --remote --file=sql/schema_full.sql +wrangler d1 execute cfwarden-db --remote --file=sql/schema_full.sql ``` `sql/schema.sql` 仅保留为历史/兼容用途;推荐新部署直接使用 `sql/schema_full.sql`。 @@ -52,7 +69,7 @@ wrangler secret put TWO_FACTOR_ENC_KEY - JWT_SECRET:访问令牌签名密钥 - JWT_REFRESH_SECRET:刷新令牌签名密钥 -- ALLOWED_EMAILS:首个账号注册白名单(仅在“数据库还没有任何用户”时启用),多个邮箱用英文逗号分隔 +- ALLOWED_EMAILS:注册白名单,多个邮箱用英文逗号分隔;可使用 `*` 表示允许所有邮箱注册 - TWO_FACTOR_ENC_KEY:可选,Base64 的 32 字节密钥;用于加密存储 TOTP 秘钥(不设置则以 `plain:` 形式存储) ### 4. 部署 @@ -81,7 +98,7 @@ wrangler deploy ## 本地开发 ```bash -wrangler d1 execute vault1 --local --file=sql/schema_full.sql +wrangler d1 execute cfwarden-db --local --file=sql/schema_full.sql wrangler dev ``` diff --git a/images/QQ20260127-150742.jpg b/images/QQ20260127-150742.jpg new file mode 100644 index 00000000..f6685937 Binary files /dev/null and b/images/QQ20260127-150742.jpg differ diff --git a/images/QQ20260127-153440.jpg b/images/QQ20260127-153440.jpg new file mode 100644 index 00000000..5e7cbd56 Binary files /dev/null and b/images/QQ20260127-153440.jpg differ diff --git a/scripts/rate_limit_test.js b/scripts/rate_limit_test.js new file mode 100644 index 00000000..d2f4d43d --- /dev/null +++ b/scripts/rate_limit_test.js @@ -0,0 +1,36 @@ +const url = "https://warden.2x.nz/identity/connect/token"; + +async function main() { + const ip = "203.0.113.10"; + for (let i = 1; i <= 35; i++) { + const body = + "grant_type=password" + + "&username=none%40example.com" + + "&password=x" + + "&deviceIdentifier=d" + + "&deviceName=n" + + "&deviceType=0" + + "&scope=api+offline_access" + + "&client_id=mobile"; + + const r = await fetch(url, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + "x-forwarded-for": ip, + }, + body, + }); + + const t = await r.text(); + if ([1, 30, 31, 35].includes(i)) { + console.log(i, r.status, t); + } + } +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); + diff --git a/sql/schema_full.sql b/sql/schema_full.sql index f8a01293..7d428aa4 100644 --- a/sql/schema_full.sql +++ b/sql/schema_full.sql @@ -72,6 +72,12 @@ CREATE TABLE IF NOT EXISTS devices ( FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE ); +CREATE TABLE IF NOT EXISTS login_rate_limits ( + key TEXT PRIMARY KEY NOT NULL, + count INTEGER NOT NULL, + reset_at INTEGER NOT NULL +); + CREATE INDEX IF NOT EXISTS idx_ciphers_user_id ON ciphers(user_id); CREATE INDEX IF NOT EXISTS idx_ciphers_folder_id ON ciphers(folder_id); CREATE INDEX IF NOT EXISTS idx_folders_user_id ON folders(user_id); diff --git a/src/auth.rs b/src/auth.rs index 77458ab3..fa329c99 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -2,7 +2,7 @@ use axum::{ extract::FromRequestParts, http::{header, request::Parts}, }; -use jsonwebtoken::{decode, DecodingKey, Validation}; +use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation}; use serde::{Deserialize, Serialize}; use std::sync::Arc; use worker::Env; @@ -45,7 +45,11 @@ impl FromRequestParts> for Claims // Decode and validate the token let decoding_key = DecodingKey::from_secret(secret.to_string().as_ref()); - let token_data = decode::(&token, &decoding_key, &Validation::default()) + let mut validation = Validation::new(Algorithm::HS256); + validation.validate_nbf = true; + validation.leeway = 60; + + let token_data = decode::(&token, &decoding_key, &validation) .map_err(|_| AppError::Unauthorized("Invalid token".to_string()))?; Ok(token_data.claims) diff --git a/src/db.rs b/src/db.rs index 7416df11..ac2ee4a3 100644 --- a/src/db.rs +++ b/src/db.rs @@ -3,5 +3,5 @@ use std::sync::Arc; use worker::{D1Database, Env}; pub fn get_db(env: &Arc) -> Result { - env.d1("vault1").map_err(AppError::Worker) + env.d1("cfwarden_data").map_err(AppError::Worker) } diff --git a/src/error.rs b/src/error.rs index 56a819ec..64a8bc61 100644 --- a/src/error.rs +++ b/src/error.rs @@ -21,6 +21,9 @@ pub enum AppError { #[error("Unauthorized: {0}")] Unauthorized(String), + #[error("Too many requests: {0}")] + TooManyRequests(String), + #[error("Cryptography error: {0}")] Crypto(String), @@ -45,6 +48,7 @@ impl IntoResponse for AppError { AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), AppError::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg), + AppError::TooManyRequests(msg) => (StatusCode::TOO_MANY_REQUESTS, msg), AppError::Crypto(msg) => ( StatusCode::INTERNAL_SERVER_ERROR, format!("Crypto error: {}", msg), diff --git a/src/handlers/accounts.rs b/src/handlers/accounts.rs index b691345d..4dadcfb2 100644 --- a/src/handlers/accounts.rs +++ b/src/handlers/accounts.rs @@ -117,32 +117,27 @@ pub async fn register( Json(payload): Json, ) -> Result, AppError> { let db = db::get_db(&env)?; - let user_count: Option = db - .prepare("SELECT COUNT(1) AS user_count FROM users") - .first(Some("user_count")) - .await - .map_err(|_| AppError::Database)?; - let user_count = user_count.unwrap_or(0); - if user_count == 0 { - let allowed_emails = env - .secret("ALLOWED_EMAILS") - .map_err(|_| AppError::Internal)?; - let allowed_emails = allowed_emails - .as_ref() - .as_string() - .ok_or_else(|| AppError::Internal)?; - if allowed_emails - .split(",") - .all(|email| email.trim() != payload.email) - { - return Err(AppError::Unauthorized("Not allowed to signup".to_string())); - } + let allowed_emails = env + .secret("ALLOWED_EMAILS") + .map_err(|_| AppError::Unauthorized("Not allowed to signup".to_string()))?; + let allowed_emails = allowed_emails + .as_ref() + .as_string() + .ok_or_else(|| AppError::Internal)?; + let email = payload.email.to_lowercase(); + let allowed = allowed_emails + .split(',') + .map(|s| s.trim().to_lowercase()) + .any(|s| s == "*" || s == email); + if !allowed { + return Err(AppError::Unauthorized("Not allowed to signup".to_string())); } + let now = Utc::now().to_rfc3339(); let user = User { id: Uuid::new_v4().to_string(), name: payload.name, - email: payload.email.to_lowercase(), + email, email_verified: false, master_password_hash: payload.master_password_hash, master_password_hint: payload.master_password_hint, @@ -326,3 +321,141 @@ fn to_js_val>(val: Option) -> JsValue { pub async fn send_verification_email() -> String { "fixed-token-to-mock".to_string() } + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct VerifyPasswordRequest { + pub master_password_hash: String, +} + +#[worker::send] +pub async fn export_vault( + claims: Claims, + State(env): State>, + Json(payload): Json, +) -> Result, AppError> { + let db = db::get_db(&env)?; + + // 验证密码 + let user: Value = db + .prepare("SELECT * FROM users WHERE id = ?1") + .bind(&[claims.sub.clone().into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?; + + if !constant_time_eq( + user.master_password_hash.as_bytes(), + payload.master_password_hash.as_bytes(), + ) { + return Err(AppError::Unauthorized("Invalid credentials".to_string())); + } + + // 获取所有文件夹 + let folders_raw: Vec = db + .prepare("SELECT * FROM folders WHERE user_id = ?1") + .bind(&[claims.sub.clone().into()])? + .all() + .await? + .results()?; + + // 转换文件夹格式(只保留id和name) + let folders: Vec = folders_raw + .into_iter() + .map(|f| { + json!({ + "id": f.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "name": f.get("name").and_then(|v| v.as_str()).unwrap_or("") + }) + }) + .collect(); + + // 获取所有密码项 + let ciphers_raw: Vec = db + .prepare("SELECT * FROM ciphers WHERE user_id = ?1") + .bind(&[claims.sub.clone().into()])? + .all() + .await? + .results()?; + + // 转换密码项格式(展开data字段到顶层) + let items: Vec = ciphers_raw + .into_iter() + .map(|c| { + let mut item = json!({ + "id": c.get("id").and_then(|v| v.as_str()).unwrap_or(""), + "type": c.get("type").and_then(|v| v.as_i64()).unwrap_or(1), + "favorite": c.get("favorite").and_then(|v| v.as_i64()).unwrap_or(0) != 0, + "folderId": c.get("folder_id"), + "organizationId": c.get("organization_id"), + "revisionDate": c.get("updated_at").and_then(|v| v.as_str()).unwrap_or(""), + "creationDate": c.get("created_at").and_then(|v| v.as_str()).unwrap_or(""), + "deletedDate": c.get("deleted_at"), + "reprompt": 0, + "collectionIds": Value::Null + }); + + // 解析data字段并合并到item中 + if let Some(data_str) = c.get("data").and_then(|v| v.as_str()) { + if let Ok(data) = serde_json::from_str::(data_str) { + if let Some(data_obj) = data.as_object() { + // 合并data中的字段 + if let Some(item_obj) = item.as_object_mut() { + for (key, value) in data_obj { + item_obj.insert(key.clone(), value.clone()); + } + } + } + } + } + + item + }) + .collect(); + + // 返回备份数据(Bitwarden导出格式) + Ok(Json(json!({ + "encrypted": false, + "folders": folders, + "items": items, + }))) +} + +#[worker::send] +pub async fn delete_account( + claims: Claims, + State(env): State>, + Json(payload): Json, +) -> Result, AppError> { + let db = db::get_db(&env)?; + + // 验证密码 + let user: Value = db + .prepare("SELECT * FROM users WHERE id = ?1") + .bind(&[claims.sub.clone().into()])? + .first(None) + .await + .map_err(|_| AppError::Database)? + .ok_or_else(|| AppError::NotFound("User not found".to_string()))?; + let user: User = serde_json::from_value(user).map_err(|_| AppError::Internal)?; + + if !constant_time_eq( + user.master_password_hash.as_bytes(), + payload.master_password_hash.as_bytes(), + ) { + return Err(AppError::Unauthorized("Invalid credentials".to_string())); + } + + // 删除用户数据(由于外键约束,会级联删除相关数据) + db.prepare("DELETE FROM users WHERE id = ?1") + .bind(&[claims.sub.into()])? + .run() + .await + .map_err(|_| AppError::Database)?; + + Ok(Json(json!({ + "message": "Account deleted successfully" + }))) +} diff --git a/src/handlers/ciphers.rs b/src/handlers/ciphers.rs index 6b7d0e5d..745ba21a 100644 --- a/src/handlers/ciphers.rs +++ b/src/handlers/ciphers.rs @@ -10,6 +10,28 @@ use crate::error::AppError; use crate::models::cipher::{Cipher, CipherData, CipherRequestData, CreateCipherRequest, CipherRequestFlat}; use axum::extract::Path; +async fn ensure_folder_owned_by_user( + db: &worker::D1Database, + user_id: &str, + folder_id: &str, +) -> Result<(), AppError> { + let exists: Option = query!( + db, + "SELECT 1 AS ok FROM folders WHERE id = ?1 AND user_id = ?2 LIMIT 1", + folder_id, + user_id + ) + .map_err(|_| AppError::Database)? + .first(Some("ok")) + .await + .map_err(|_| AppError::Database)?; + + if exists.is_none() { + return Err(AppError::NotFound("Folder not found".to_string())); + } + Ok(()) +} + async fn create_cipher_inner( claims: Claims, env: &Arc, @@ -20,6 +42,10 @@ async fn create_cipher_inner( let now = Utc::now(); let now = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); + if let Some(folder_id) = cipher_data_req.folder_id.as_deref() { + ensure_folder_owned_by_user(&db, &claims.sub, folder_id).await?; + } + let cipher_data = CipherData { name: cipher_data_req.name, notes: cipher_data_req.notes, @@ -120,6 +146,10 @@ pub async fn update_cipher( let cipher_data_req = payload; + if let Some(folder_id) = cipher_data_req.folder_id.as_deref() { + ensure_folder_owned_by_user(&db, &claims.sub, folder_id).await?; + } + let cipher_data = CipherData { name: cipher_data_req.name, notes: cipher_data_req.notes, diff --git a/src/handlers/identity.rs b/src/handlers/identity.rs index 2fdc60bb..664f1886 100644 --- a/src/handlers/identity.rs +++ b/src/handlers/identity.rs @@ -1,4 +1,4 @@ -use axum::{extract::State, response::IntoResponse, Form, Json}; +use axum::{extract::State, http::HeaderMap, response::IntoResponse, Form, Json}; use axum::http::StatusCode; use axum::response::Response; use chrono::{Duration, Utc}; @@ -171,6 +171,81 @@ async fn ensure_devices_table(db: &worker::D1Database) -> Result<(), AppError> { Ok(()) } +async fn ensure_login_rate_limits_table(db: &worker::D1Database) -> Result<(), AppError> { + db.prepare( + "CREATE TABLE IF NOT EXISTS login_rate_limits ( + key TEXT PRIMARY KEY NOT NULL, + count INTEGER NOT NULL, + reset_at INTEGER NOT NULL + )", + ) + .run() + .await + .map_err(|_| AppError::Database)?; + Ok(()) +} + +fn client_ip(headers: &HeaderMap) -> String { + headers + .get("cf-connecting-ip") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .or_else(|| { + headers + .get("x-forwarded-for") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.split(',').next()) + .map(|s| s.trim().to_string()) + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +async fn enforce_login_rate_limit(db: &worker::D1Database, key: &str) -> Result<(), AppError> { + ensure_login_rate_limits_table(db).await?; + let now = Utc::now().timestamp_millis(); + let window_ms: i64 = 5 * 60 * 1000; + let max_attempts: i64 = 30; + + let row: Option = db + .prepare("SELECT count, reset_at FROM login_rate_limits WHERE key = ?1") + .bind(&[key.into()])? + .first(None) + .await + .map_err(|_| AppError::Database)?; + + let (mut count, mut reset_at) = if let Some(row) = row { + let count = row.get("count").and_then(|v| v.as_f64()).map(|v| v as i64).unwrap_or(0); + let reset_at = row.get("reset_at").and_then(|v| v.as_f64()).map(|v| v as i64).unwrap_or(0); + (count, reset_at) + } else { + (0, 0) + }; + + if now >= reset_at { + count = 0; + reset_at = now + window_ms; + } + + count += 1; + + db.prepare( + "INSERT INTO login_rate_limits (key, count, reset_at) VALUES (?1, ?2, ?3) + ON CONFLICT(key) DO UPDATE SET count = excluded.count, reset_at = excluded.reset_at", + ) + .bind(&[key.into(), (count as f64).into(), (reset_at as f64).into()])? + .run() + .await + .map_err(|_| AppError::Database)?; + + if count > max_attempts { + return Err(AppError::TooManyRequests( + "Too many login attempts. Please try again later.".to_string(), + )); + } + + Ok(()) +} + fn sha256_hex(input: &str) -> String { let mut hasher = Sha256::new(); hasher.update(input.as_bytes()); @@ -214,11 +289,16 @@ fn invalid_two_factor_response() -> Response { #[worker::send] pub async fn token( State(env): State>, + headers: HeaderMap, Form(payload): Form, ) -> Result { let db = db::get_db(&env)?; match payload.grant_type.as_str() { "password" => { + let ip = client_ip(&headers); + let rate_key = format!("login:{}", ip); + enforce_login_rate_limit(&db, &rate_key).await?; + let username = payload .username .ok_or_else(|| AppError::BadRequest("Missing username".to_string()))?; diff --git a/src/handlers/import.rs b/src/handlers/import.rs index d79313a3..4b425465 100644 --- a/src/handlers/import.rs +++ b/src/handlers/import.rs @@ -24,12 +24,15 @@ pub async fn import_data( let now = Utc::now(); let now = now.format("%Y-%m-%dT%H:%M:%S%.3fZ").to_string(); - let folder_query = "INSERT OR IGNORE INTO folders (id, user_id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)"; + let folder_query = "INSERT INTO folders (id, user_id, name, created_at, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)"; + let mut folder_id_map: std::collections::HashMap = std::collections::HashMap::new(); let mut folder_stmts: Vec = Vec::new(); for import_folder in &payload.folders { + let new_id = Uuid::new_v4().to_string(); + folder_id_map.insert(import_folder.id.clone(), new_id.clone()); let folder = Folder { - id: import_folder.id.clone(), + id: new_id, user_id: claims.sub.clone(), name: import_folder.name.clone(), created_at: now.clone(), @@ -53,7 +56,7 @@ pub async fn import_data( for relationship in payload.folder_relationships { if let Some(cipher) = payload.ciphers.get_mut(relationship.key) { if let Some(folder) = payload.folders.get(relationship.value) { - cipher.folder_id = Some(folder.id.clone()); + cipher.folder_id = folder_id_map.get(&folder.id).cloned(); } } } @@ -81,6 +84,9 @@ pub async fn import_data( let id = Uuid::new_v4().to_string(); let user_id = claims.sub.clone(); let data = serde_json::to_string(&cipher_data).map_err(|_| AppError::Internal)?; + let folder_id = import_cipher + .folder_id + .and_then(|id| folder_id_map.get(&id).cloned()); cipher_stmts.push(db.prepare(cipher_query).bind(&[ id.into(), @@ -89,7 +95,7 @@ pub async fn import_data( import_cipher.r#type.into(), data.into(), import_cipher.favorite.into(), - to_js_val(import_cipher.folder_id), + to_js_val(folder_id), now.clone().into(), now.clone().into(), ])?); diff --git a/src/router.rs b/src/router.rs index 8b51529e..6333d55f 100644 --- a/src/router.rs +++ b/src/router.rs @@ -27,6 +27,8 @@ pub fn api_router(env: Env) -> Router { ) .route("/api/accounts/profile", get(accounts::profile)) .route("/api/accounts/revision-date", get(accounts::revision_date)) + .route("/api/accounts/export", post(accounts::export_vault)) + .route("/api/accounts", delete(accounts::delete_account)) .route("/api/devices/knowndevice", get(devices::knowndevice)) .route("/api/accounts/password", put(accounts::change_master_password)) .route("/api/accounts/email", put(accounts::change_email)) diff --git a/static/index.html b/static/index.html index b080909e..ea94801b 100644 --- a/static/index.html +++ b/static/index.html @@ -5,640 +5,2103 @@ Warden Worker - Bitwarden 兼容服务器 -
-

Warden Worker

-

- 这是一个运行在 Cloudflare Workers 上的 Bitwarden 兼容服务器。
- 您可以使用官方 Bitwarden 客户端连接到此服务器。 -

- -
- 关于修改主密码
- Bitwarden/Vaultwarden 的“网页端改主密码”本质是:明文密码只在浏览器里参与计算,服务端只接收派生出的 Hash 和新的受保护主密钥。
- 为避免操作失误导致无法解密,请在修改前先做好导出备份。 + + + +
+

🔐 Warden Worker

+
+ 运行在 Cloudflare Workers 上的 Bitwarden 兼容服务器
+ 开源、安全、快速、易用的密码管理解决方案
+ 服务器地址: + +
+ + +
+ + + + + +
+ + +
+
+
用户注册
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+

+
+ 💡 温馨提示:主密码是解密所有数据的唯一密钥,请务必牢记。
+ 💡 隐私声明:服务器不存储明文密码,忘记密码后将无法找回。 +
+ +
+
+ 所有密码操作均在浏览器本地完成,服务器仅接收加密后的数据
+ 在进行重要操作前,请先导出备份,避免数据丢失 +
+
-
- 网页端修改主密码
- 只需要输入邮箱、旧密码、新密码(明文仅在浏览器内使用)。

-
-
邮箱
- + +
+
+
修改密码
+
+ +
-
-
旧主密码
- +
+ +
-
-
新主密码
- +
+ +
-
-
主密码提示(可选)
- +
+ +
-
- 修改主密码 +

+
+ 💡 温馨提示:主密码是解密所有数据的唯一密钥,请务必牢记。
+ 💡 隐私声明:服务器不存储明文密码,忘记密码后将无法找回。
-
+ +
+
+ +
+ 所有密码操作均在浏览器本地完成,服务器仅接收加密后的数据
+ 在进行重要操作前,请先导出备份,避免数据丢失
+
-
- 网页端修改邮箱
- 只需要输入当前邮箱、新邮箱、主密码(明文仅在浏览器内使用)。

-
-
当前邮箱
- + +
+
+
修改邮箱
+
+ + +
+
+ + +
+
+ +
-
-
新邮箱
- +
+ +
-
-
主密码
- +
+
+ 💡 温馨提示:修改邮箱后,主密钥会使用新邮箱重新派生。
+ 请使用新邮箱登录 Bitwarden 客户端。
- -
- 登录二次验证(TOTP)
- 启用后,Bitwarden 客户端登录会要求输入 6 位验证码。

-
-
邮箱
- +
+ 所有密码操作均在浏览器本地完成,服务器仅接收加密后的数据
+ 在进行重要操作前,请先导出备份,避免数据丢失 +
+
+ + +
+
+
登录二次验证(TOTP)
+
启用后,登录时需要输入动态验证码,提升账户安全性
+ +
+ +
-
-
主密码
- +
+ +
-
+ + -
-
Secret
- + +
+ + +
+
+ +
-
-
otpauth
- + +
+ +
+ TOTP QR Code +
-
-
二维码
- TOTP QR +

+
+ 📱 请使用认证App(如Google Authenticator)扫描二维码或手动输入密钥
-
-
验证码
- + +
+ +
- - - - + + showResult(resultDeleteEl, '✅ 账号已成功删除!', 'success'); + deleteEmailInput.value = ''; + deletePasswordInput.value = ''; + }); + + // 点击背景关闭 + confirmDialog.addEventListener('click', (e) => { + if (e.target === confirmDialog) { + document.body.removeChild(confirmDialog); + } + }); + + } catch (err) { + showResult(resultDeleteEl, '❌ ' + String(err), 'error'); + } + }); + diff --git a/wrangler.jsonc b/wrangler.jsonc index f5ac1956..3f89eca7 100644 --- a/wrangler.jsonc +++ b/wrangler.jsonc @@ -1,31 +1,31 @@ { - // Worker 基本信息 - "name": "warden-worker", - "main": "build/index.js", - "compatibility_date": "2025-09-19", - - - // D1 数据库绑定(必须完整,否则会被认为是新建/覆盖) - "d1_databases": [ - { - "binding": "vault1", - "database_id": "91d6795d-e822-4ca9-aa15-b327765bf3a9" - // database_name 可选,但建议写上以增强可读性 - // "database_name": "vault1" - } - ], - - "observability": { - "logs": { - "enabled": true, - "head_sampling_rate": 1, - "invocation_logs": true, - "persist": true - } - }, - - // 构建配置(等价于 [build]) - "build": { - "command": "worker-build --release" - } -} + // Worker 基本信息 + "name": "cfwarden-work", + "main": "build/index.js", + "compatibility_date": "2025-09-19", + "d1_databases": [ + { + "binding": "cfwarden_data", + "database_name": "cfwarden-data", + "database_id": "", + "remote": true + } + ], + "vars": { + "JWT_SECRET": "", + "JWT_REFRESH_SECRET": "", + "ALLOWED_EMAILS": "*", + "TWO_FACTOR_ENC_KEY": "" + }, + "observability": { + "logs": { + "enabled": true, + "head_sampling_rate": 1, + "invocation_logs": true, + "persist": true + } + }, + "build": { + "command": "worker-build --release" + } +} \ No newline at end of file