From 26447cd9b48965a467fc606eec49f23956012190 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 1 Mar 2026 19:31:03 +0800 Subject: [PATCH] docs: update README files for clarity on deployment steps and features --- README.md | 25 +++++++++++----- README_EN.md | 40 +++++++++++++++---------- src/config/limits.ts | 3 ++ src/handlers/accounts.ts | 26 +++++++++++++--- src/handlers/identity.ts | 12 +++++++- src/handlers/sends.ts | 20 +++++++++++-- src/router.ts | 34 +++++++++++++++++---- src/services/ratelimit.ts | 11 +++++++ webapp/src/App.tsx | 17 +++-------- webapp/src/components/SettingsPage.tsx | 41 ++++++-------------------- webapp/src/lib/api.ts | 23 ++++----------- webapp/src/lib/i18n.ts | 7 +++-- webapp/src/styles.css | 5 ++++ 13 files changed, 164 insertions(+), 100 deletions(-) diff --git a/README.md b/README.md index 685394a..a07b965 100644 --- a/README.md +++ b/README.md @@ -32,9 +32,9 @@ English:[`README_EN.md`](./README_EN.md) | 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 | | 导入功能 | ✅ | ✅ | 覆盖常见导入路径 | | 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` | -| passkey、TOTP | ❌ | ✅ |官方需要会员,我们的不需要 | +| passkey、TOTP字段 | ❌ | ✅ |官方需要会员,我们的不需要 | | Send | ✅ | ✅ | 已支持文本 Send 与文件 Send | -| 多用户 | ✅ | ✅ | 完整的用户管理 | +| 多用户 | ✅ | ✅ | 完整的用户管理,邀请机制 | | 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 | | 登录 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ 部分支持 | 仅支持 TOTP(通过 `TOTP_SECRET`) | | SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 | @@ -47,8 +47,8 @@ English:[`README_EN.md`](./README_EN.md) - ✅ Windows 客户端(v2026.1.0) - ✅ 手机 App(v2026.1.0) - ✅ 浏览器扩展(v2026.1.0) +- ✅ Linux 客户端(v2026.1.0) - ⬜ macOS 客户端(未测试) -- ⬜ Linux 客户端(未测试) --- # 快速开始 @@ -57,9 +57,15 @@ English:[`README_EN.md`](./README_EN.md) **部署步骤:** -1. 先在右上角fork此项目(若后续不需要更新,可不fork) -2. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) -3. 打开部署后生成的链接,并根据网页提示完成后续操作。 +1. 首先Fork本仓库,命名为**NodeWarden** +2. 点击下面的一键部署按钮,修改项目名称为**NodeWarden2**,修改**JWT_SECRET**成32为随机字符串 +3. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) +4. 部署完成后,同一页面打开workers设置,将**Git存储库**断开连接 +5. 同一位置,**Git存储库**链接至第一步Fork的仓库 + +**同步上游(更新):** +- 手动:Github打开你Fork的私人仓库,看到顶部同步提示时,点击 “Sync fork”。 +- 自动:进入你的 Fork 仓库 → Actions,点击 “I understand my workflows, go ahead and enable them”,每天凌晨三点自动同步至上游 ### CLI 部署 @@ -80,6 +86,11 @@ npx wrangler r2 bucket create nodewarden-attachments # 部署 npx wrangler deploy + +# 需更新时重新拉取仓库,重新部署即可,无需创建云资源 +git clone https://github.com/shuaiplus/NodeWarden.git +cd NodeWarden +npx wrangler deploy ``` --- @@ -102,7 +113,7 @@ A: 在客户端中选择「导出密码库」,保存 JSON 文件。 A: 无法恢复,这是端到端加密的特性。建议妥善保管主密码。 **Q: 可以多人使用吗?** -A: 不建议。本项目为单用户设计,多人使用请选择 Vaultwarden。 +A: 支持。第一个注册的用户自动成为管理员,管理员可在管理页面生成邀请码,其他用户凭邀请码注册。 --- diff --git a/README_EN.md b/README_EN.md index 025d76f..fd423c9 100644 --- a/README_EN.md +++ b/README_EN.md @@ -16,10 +16,9 @@ 中文文档:[`README.md`](./README.md) -> Disclaimer -> - This project is for learning and communication only. -> - We are not responsible for any data loss. Regular vault backups are strongly recommended. -> - This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team. +> **Disclaimer** +> This project is for learning and communication purposes only. We are not responsible for any data loss; regular vault backups are strongly recommended. +> This project is not affiliated with Bitwarden. Please do not report issues to the official Bitwarden team. --- @@ -27,14 +26,14 @@ | Capability | Bitwarden | NodeWarden | Notes | |---|---|---|---| -| Single-user vault (logins/notes/cards/identities) | ✅ | ✅ | Core vault model supported | +| Single-user vault (logins/notes/cards/identities) | ✅ | ✅ | Based on Cloudflare D1 | | Folders / Favorites | ✅ | ✅ | Common vault organization supported | -| Full sync `/api/sync` | ✅ | ✅ | Compatibility-focused implementation | +| Full sync `/api/sync` | ✅ | ✅ | Compatibility and performance optimized | | Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 | | Import flow (common clients) | ✅ | ✅ | Common import paths covered | | Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` | -| passkey、TOTP | ❌ | ✅ | Official service requires premium; NodeWarden does not | -| Multi-user | ✅ | ✅ | Full User Management | +| passkey、TOTP fields | ❌ | ✅ | Official service requires premium; NodeWarden does not | +| Multi-user | ✅ | ✅ | Full user management with invitation mechanism | | Send | ✅ | ✅ | Text Send and File Send are supported | | Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement | | Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | TOTP-only via `TOTP_SECRET` | @@ -47,10 +46,10 @@ ## Tested clients / platforms - ✅ Windows desktop client (v2026.1.0) -- ✅ Android app (v2026.1.0) +- ✅ Mobile app (v2026.1.0) - ✅ Browser extension (v2026.1.0) +- ✅ Linux desktop client (v2026.1.0) - ⬜ macOS desktop client (not tested) -- ⬜ Linux desktop client (not tested) --- @@ -60,9 +59,15 @@ **Deploy steps:** -1. Fork this project (you don't need to fork it if you don't need to update it later). -2. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) -3. Open the generated service URL and follow the on-page instructions. +1. Fork this repository and name it **NodeWarden**. +2. Click the deploy button below, rename the project to **NodeWarden2**, and set **JWT_SECRET** to a 32-character random string. +3. [![Deploy to Cloudflare Workers](https://deploy.workers.cloudflare.com/button)](https://deploy.workers.cloudflare.com/?url=https://github.com/shuaiplus/nodewarden) +4. After deployment, open the Workers settings on the same page and disconnect the **Git repository**. +5. From the same location, reconnect the **Git repository** to the fork you created in step 1. + +**Sync upstream (update):** +- Manual: Open your forked repository on GitHub and click **Sync fork** when the sync prompt appears at the top. +- Automatic: Go to your fork → Actions, click "I understand my workflows, go ahead and enable them". The repository will auto-sync with upstream every day at 3 AM. ### CLI deploy @@ -83,9 +88,14 @@ npx wrangler r2 bucket create nodewarden-attachments # Deploy npx wrangler deploy + +# To update later: re-clone and re-deploy — no need to recreate cloud resources +git clone https://github.com/shuaiplus/NodeWarden.git +cd NodeWarden +npx wrangler deploy ``` - +--- ## Local development This repo is a Cloudflare Workers TypeScript project (Wrangler). @@ -105,7 +115,7 @@ A: Use **Export vault** in your client and save the JSON file. A: It can’t be recovered (end-to-end encryption). Keep it safe. **Q: Can multiple people use it?** -A: Not recommended. This project is designed for single-user usage. For multi-user usage, choose Vaultwarden. +A: Yes. The first registered user becomes the admin. The admin can generate invite codes from the admin panel, and other users register with those codes. --- diff --git a/src/config/limits.ts b/src/config/limits.ts index a2725aa..c8a8aa2 100644 --- a/src/config/limits.ts +++ b/src/config/limits.ts @@ -41,6 +41,9 @@ // /api/devices/knowndevice probe budget per IP per minute. // /api/devices/knowndevice 每 IP 每分钟探测配额。 knownDeviceRequestsPerMinute: 10, + // Public Send access budget per IP per minute. + // 公共 Send 访问接口每 IP 每分钟配额。 + publicSendRequestsPerMinute: 60, // Fixed window size for API rate limiting in seconds. // API 限流固定窗口大小(秒)。 apiWindowSeconds: 60, diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index ca1845a..8d7439d 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -1,6 +1,7 @@ import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types'; import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; +import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; import { LIMITS } from '../config/limits'; @@ -449,6 +450,7 @@ export async function handleGetTotpRecoveryCode(request: Request, env: Env, user export async function handleRecoverTwoFactor(request: Request, env: Env): Promise { const storage = new StorageService(env.DB); const auth = new AuthService(env); + const rateLimit = new RateLimitService(env.DB); let body: Record; try { @@ -466,20 +468,35 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis const email = String(body.email || body.username || '').trim().toLowerCase(); const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim(); const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || '')); + const recoverLimitKey = `${getClientIdentifier(request)}:recover-2fa:${email || 'unknown'}`; + + const recoverAttemptCheck = await rateLimit.checkLoginAttempt(recoverLimitKey); + if (!recoverAttemptCheck.allowed) { + return errorResponse( + `Too many failed recovery attempts. Try again in ${Math.ceil((recoverAttemptCheck.retryAfterSeconds || 60) / 60)} minutes.`, + 429 + ); + } if (!email || !masterPasswordHash || !recoveryCode) { return errorResponse('Email, masterPasswordHash and recoveryCode are required', 400); } const user = await storage.getUser(email); - if (!user) return errorResponse('Invalid credentials', 400); - if (user.status !== 'active') return errorResponse('Account is disabled', 403); + if (!user || user.status !== 'active') { + await rateLimit.recordFailedLogin(recoverLimitKey); + return errorResponse('Invalid credentials or recovery code', 400); + } const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash); - if (!validPassword) return errorResponse('Invalid credentials', 400); + if (!validPassword) { + await rateLimit.recordFailedLogin(recoverLimitKey); + return errorResponse('Invalid credentials or recovery code', 400); + } if (!recoveryCodeEquals(recoveryCode, user.totpRecoveryCode)) { - return errorResponse('Recovery code is incorrect. Try again.', 400); + await rateLimit.recordFailedLogin(recoverLimitKey); + return errorResponse('Invalid credentials or recovery code', 400); } user.totpSecret = null; @@ -488,6 +505,7 @@ export async function handleRecoverTwoFactor(request: Request, env: Env): Promis user.updatedAt = new Date().toISOString(); await storage.saveUser(user); await storage.deleteRefreshTokensByUserId(user.id); + await rateLimit.clearLoginAttempts(recoverLimitKey); return jsonResponse({ success: true, diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 9575a3c..a2ae71a 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -105,6 +105,7 @@ export async function handleToken(request: Request, env: Env): Promise } const grantType = body.grant_type; + const clientIdentifier = getClientIdentifier(request); if (grantType === 'password') { // Login with password @@ -113,7 +114,7 @@ export async function handleToken(request: Request, env: Env): Promise const twoFactorToken = body.twoFactorToken; const twoFactorProvider = body.twoFactorProvider; const twoFactorRemember = body.twoFactorRemember; - const loginIdentifier = getClientIdentifier(request); + const loginIdentifier = clientIdentifier; const deviceInfo = readAuthRequestDeviceInfo(body, request); if (!email || !passwordHash) { @@ -266,6 +267,15 @@ export async function handleToken(request: Request, env: Env): Promise return jsonResponse(response); } else if (grantType === 'send_access') { + const sendAccessLimit = await rateLimit.consumePublicSendAccessBudget(`${clientIdentifier}:public-send-oauth`); + if (!sendAccessLimit.allowed) { + return identityErrorResponse( + `Too many public Send requests. Try again in ${sendAccessLimit.retryAfterSeconds} seconds.`, + 'TooManyRequests', + 429 + ); + } + const sendId = String(body.send_id || body.sendId || '').trim(); if (!sendId) { return jsonResponse( diff --git a/src/handlers/sends.ts b/src/handlers/sends.ts index ee51132..715303d 100644 --- a/src/handlers/sends.ts +++ b/src/handlers/sends.ts @@ -322,6 +322,14 @@ function hasEmailAuth(send: Send): boolean { return send.authType === SendAuthType.Email; } +function getSafeJwtSecret(env: Env): { ok: true; secret: string } | { ok: false; response: Response } { + const secret = (env.JWT_SECRET || '').trim(); + if (!secret || secret.length < LIMITS.auth.jwtSecretMinLength || secret === DEFAULT_DEV_SECRET) { + return { ok: false, response: errorResponse('Server configuration error', 500) }; + } + return { ok: true, secret }; +} + function extractBearerToken(request: Request): string | null { const authHeader = request.headers.get('Authorization'); if (!authHeader) return null; @@ -1078,12 +1086,15 @@ export async function handleAccessSendFile( // POST /api/sends/access (v2 bearer) export async function handleAccessSendV2(request: Request, env: Env): Promise { + const jwt = getSafeJwtSecret(env); + if (!jwt.ok) return jwt.response; + const token = extractBearerToken(request); if (!token) { return errorResponse('Unauthorized', 401); } - const claims = await verifySendAccessToken(token, env.JWT_SECRET); + const claims = await verifySendAccessToken(token, jwt.secret); if (!claims) { return errorResponse('Unauthorized', 401); } @@ -1196,6 +1207,11 @@ export async function issueSendAccessToken( passwordHashB64?: string | null, password?: string | null ): Promise<{ token: string } | { error: Response }> { + const jwt = getSafeJwtSecret(env); + if (!jwt.ok) { + return { error: jwt.response }; + } + const storage = new StorageService(env.DB); const send = await resolveSendFromIdOrAccessId(storage, sendIdOrAccessId); @@ -1260,7 +1276,7 @@ export async function issueSendAccessToken( } } - const token = await createSendAccessToken(send.id, env.JWT_SECRET); + const token = await createSendAccessToken(send.id, jwt.secret); return { token }; } diff --git a/src/router.ts b/src/router.ts index 37ebfb9..fcf385f 100644 --- a/src/router.ts +++ b/src/router.ts @@ -12,7 +12,6 @@ import { handleToken, handlePrelogin, handleRevocation } from './handlers/identi import { handleRegister, handleGetProfile, - handleUpdateProfile, handleSetKeys, handleGetRevisionDate, handleVerifyPassword, @@ -214,6 +213,24 @@ export async function handleRequest(request: Request, env: Env): Promise { + const rateLimit = new RateLimitService(env.DB); + const check = await rateLimit.consumePublicSendAccessBudget(`${clientId}:public-send`); + if (check.allowed) return null; + return new Response(JSON.stringify({ + error: 'Too many requests', + error_description: `Too many public Send requests. Try again in ${check.retryAfterSeconds} seconds.`, + }), { + status: 429, + headers: { + 'Content-Type': 'application/json', + 'Retry-After': String(check.retryAfterSeconds || 60), + 'X-RateLimit-Remaining': '0', + }, + }); + } // Handle CORS preflight if (method === 'OPTIONS') { @@ -272,23 +289,31 @@ export async function handleRequest(request: Request, env: Env): Promise { + return this.consumeFixedWindowBudget( + identifier, + CONFIG.PUBLIC_SEND_REQUESTS_PER_MINUTE, + CONFIG.API_WINDOW_SECONDS + ); + } } export function getClientIdentifier(request: Request): string { diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 5c1e6c4..5dea394 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1,7 +1,7 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; import { Link, Route, Switch, useLocation } from 'wouter'; import { useQuery } from '@tanstack/react-query'; -import { CircleHelp, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact'; +import { CircleHelp, Lock, LogOut, Send as SendIcon, Settings as SettingsIcon, Shield, ShieldUser, Vault } from 'lucide-preact'; import AuthViews from '@/components/AuthViews'; import ConfirmDialog from '@/components/ConfirmDialog'; import ToastHost from '@/components/ToastHost'; @@ -53,7 +53,6 @@ import { updateSend, buildSendShareKey, unlockVaultKey, - updateProfile, verifyMasterPassword, } from '@/lib/api'; import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto'; @@ -580,16 +579,6 @@ export default function App() { }; }, [session?.symEncKey, session?.symMacKey, foldersQuery.data, ciphersQuery.data, sendsQuery.data]); - async function saveProfileAction(name: string, email: string) { - try { - const updated = await updateProfile(authedFetch, { name: name.trim(), email: email.trim().toLowerCase() }); - setProfile(updated); - pushToast('success', t('txt_profile_updated')); - } catch (error) { - pushToast('error', error instanceof Error ? error.message : t('txt_save_profile_failed')); - } - } - async function changePasswordAction(currentPassword: string, nextPassword: string, nextPassword2: string) { if (!profile) return; if (!currentPassword || !nextPassword) { @@ -955,6 +944,9 @@ export default function App() { {profile?.email} + @@ -1026,7 +1018,6 @@ export default function App() { { await enableTotpAction(secret, token); diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index 664b911..02ab725 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useState } from 'preact/hooks'; -import { Clipboard, KeyRound, RefreshCw, Save, ShieldCheck, ShieldOff } from 'lucide-preact'; +import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff } from 'lucide-preact'; import qrcode from 'qrcode-generator'; import type { Profile } from '@/lib/types'; import { t } from '@/lib/i18n'; @@ -7,7 +7,6 @@ import { t } from '@/lib/i18n'; interface SettingsPageProps { profile: Profile; totpEnabled: boolean; - onSaveProfile: (name: string, email: string) => Promise; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise; onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; @@ -30,8 +29,6 @@ function buildOtpUri(email: string, secret: string): string { export default function SettingsPage(props: SettingsPageProps) { const totpSecretStorageKey = `nodewarden.totp.secret.${props.profile.id}`; - const [name, setName] = useState(props.profile.name || ''); - const [email, setEmail] = useState(props.profile.email || ''); const [currentPassword, setCurrentPassword] = useState(''); const [newPassword, setNewPassword] = useState(''); const [newPassword2, setNewPassword2] = useState(''); @@ -49,12 +46,13 @@ export default function SettingsPage(props: SettingsPageProps) { setTotpLocked(true); }, [props.totpEnabled]); - const qrSvg = useMemo(() => { + const qrDataUrl = useMemo(() => { const qr = qrcode(0, 'M'); - qr.addData(buildOtpUri(email || props.profile.email, secret)); + qr.addData(buildOtpUri(props.profile.email, secret)); qr.make(); - return qr.createSvgTag({ scalable: true, margin: 0 }); - }, [email, props.profile.email, secret]); + const svg = qr.createSvgTag({ scalable: true, margin: 0 }); + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`; + }, [props.profile.email, secret]); async function enableTotp(): Promise { await props.onEnableTotp(secret, token); @@ -70,29 +68,6 @@ export default function SettingsPage(props: SettingsPageProps) { return (
-
-

{t('txt_profile')}

-
- - -
- -
-

{t('txt_change_master_password')}