diff --git a/.dev.vars.example b/.dev.vars.example index 97feb45..df2fedb 100644 --- a/.dev.vars.example +++ b/.dev.vars.example @@ -1,3 +1,5 @@ # JWT Secret for signing tokens (required) +# IMPORTANT: change this value before any real deployment. # Generate one with: openssl rand -hex 32 -JWT_SECRET=your-secret-key-herexxs22fd2ds +# (Example only, 64 hex chars = 32 bytes) +JWT_SECRET=Enter-your-JWT-key-here-at-least-32-characters diff --git a/README.md b/README.md index 699be63..f742740 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # NodeWarden 中文文档:[`README_ZH.md`](./README_ZH.md) -A **Bitwarden-compatible** server that runs on **Cloudflare Workers**, designed for personal use. +A **Bitwarden-compatible** server that runs on **Cloudflare Workers**. - Simple deploy (no VPS) - Focused feature set diff --git a/README_ZH.md b/README_ZH.md index b5bed9a..f342ff2 100644 --- a/README_ZH.md +++ b/README_ZH.md @@ -2,7 +2,7 @@ # NodeWarden English:[`README.md`](./README.md) -一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现,面向个人使用场景。 +一个运行在 **Cloudflare Workers** 上的 **Bitwarden 兼容**服务端实现。 - 部署简单(不需要 VPS) - 功能聚焦 diff --git a/package.json b/package.json index 6283dc5..f96acb0 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "main": "src/index.ts", "type": "module", "scripts": { - "dev": "wrangler dev -c wrangler.dev.toml", + "dev": "wrangler dev -c wrangler.toml", "deploymy": "wrangler deploy -c wrangler.my.toml", "deploy": "wrangler deploy " }, diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 0958b25..dbf637d 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -1,13 +1,32 @@ -import { Env, User, ProfileResponse } from '../types'; +import { Env, User, ProfileResponse, DEFAULT_DEV_SECRET } from '../types'; import { StorageService } from '../services/storage'; import { AuthService } from '../services/auth'; import { jsonResponse, errorResponse } from '../utils/response'; import { generateUUID } from '../utils/uuid'; +function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { + const secret = (env.JWT_SECRET || '').trim(); + if (!secret) return 'missing'; + if (secret === DEFAULT_DEV_SECRET) return 'default'; + if (secret.length < 32) return 'too_short'; + return null; +} + // POST /api/accounts/register (only used from setup page, not client) export async function handleRegister(request: Request, env: Env): Promise { const storage = new StorageService(env.VAULT); + // Enforce safe JWT_SECRET before allowing first registration. + const unsafe = jwtSecretUnsafeReason(env); + if (unsafe) { + const message = unsafe === 'missing' + ? 'JWT_SECRET is not set' + : unsafe === 'default' + ? 'JWT_SECRET is using the default/sample value. Please change it.' + : 'JWT_SECRET must be at least 32 characters'; + return errorResponse(message, 400); + } + // Check if already registered const isRegistered = await storage.isRegistered(); if (isRegistered) { diff --git a/src/handlers/setup.ts b/src/handlers/setup.ts index 6a1c91e..65b41a2 100644 --- a/src/handlers/setup.ts +++ b/src/handlers/setup.ts @@ -1,769 +1,17 @@ -import { Env } from '../types'; +import { Env, DEFAULT_DEV_SECRET } from '../types'; import { StorageService } from '../services/storage'; import { jsonResponse, htmlResponse, errorResponse } from '../utils/response'; +import { renderJwtSecretWarningPage, JwtSecretState } from './setupPages'; +import { handleRegisterPage } from './setupRegisterPage'; -// Setup page HTML (single-file, no external assets) -const setupPageHTML = ` - - - - - NodeWarden - - - -
- -
- - - -`; +function getJwtSecretState(env: Env): JwtSecretState | null { + const secret = (env.JWT_SECRET || '').trim(); + if (!secret) return 'missing'; + // Block common "forgot to change" sample value (matches .dev.vars.example) + if (secret === DEFAULT_DEV_SECRET) return 'default'; + if (secret.length < 32) return 'too_short'; + return null; +} // GET / - Setup page export async function handleSetupPage(request: Request, env: Env): Promise { @@ -772,7 +20,15 @@ export async function handleSetupPage(request: Request, env: Env): Promise = { + app: 'NodeWarden', + tag: '部署在 Cloudflare Workers 上的 Bitwarden 兼容服务端。', + + // Config warning page + cfgTitle: '需要配置 JWT_SECRET', + cfgDescMissing: '当前服务没有配置 JWT_SECRET(用于签名登录令牌)。为了安全起见,必须先配置后才能注册/使用。', + cfgDescDefault: '检测到你正在使用示例/默认 JWT_SECRET。为了安全起见,请先修改为随机强密钥后再注册/使用。', + cfgDescTooShort: '检测到 JWT_SECRET 长度不足 32 个字符。为了安全起见,请使用至少 32 位的随机字符串。', + cfgStepsTitle: '如何在 Cloudflare 修改 JWT_SECRET', + cfgSteps: '打开 Cloudflare 控制台 → Workers 和 Pages → 选择 nodewarden → 设置 → 变量和机密 → 添加变量。\n类型:密钥\n名称:JWT_SECRET\n值:粘贴你生成的随机密钥\n保存后,等待重新部署生效。', + cfgGenTitle: '随机密钥生成器', + cfgGenHint: '建议长度:至少 32 字符(推荐 64+)。点击刷新生成新的随机值。', + cfgCopy: '复制', + cfgRefresh: '刷新', + + // Shared + by: '作者', + github: 'GitHub', + }; + + const en: Record = { + app: 'NodeWarden', + tag: 'Minimal Bitwarden-compatible server on Cloudflare Workers.', + + // Config warning page + cfgTitle: 'JWT_SECRET is required', + cfgDescMissing: 'This server has no JWT_SECRET configured (used to sign login tokens). For safety, you must configure it before registration/usage.', + cfgDescDefault: 'You are using the sample/default JWT_SECRET. For safety, please change it to a strong random secret before registration/usage.', + cfgDescTooShort: 'JWT_SECRET is shorter than 32 characters. For safety, use a random string with at least 32 characters.', + cfgStepsTitle: 'How to set JWT_SECRET in Cloudflare', + cfgSteps: 'Open Cloudflare Dashboard → Workers & Pages → select nodewarden → Settings → Variables and Secrets → Add variable.\nType: Secret\nName: JWT_SECRET\nValue: paste a random secret\nSave, and wait for redeploy to take effect.', + cfgGenTitle: 'Random secret generator', + cfgGenHint: 'Recommended length: 32+ characters (64+ preferred). Click refresh to generate a new one.', + cfgCopy: 'Copy', + cfgRefresh: 'Refresh', + + // Shared + by: 'By', + github: 'GitHub', + }; + + return (lang === 'zh' ? zh : en)[key] ?? key; +} + +function baseStyles(): string { + // Keep consistent with existing setup page look & feel. + return ` + :root { + color-scheme: light; + --bg0: #0b0b0f; + --bg1: #0f1020; + --card: rgba(255, 255, 255, 0.08); + --card2: rgba(255, 255, 255, 0.06); + --border: rgba(255, 255, 255, 0.14); + --text: rgba(255, 255, 255, 0.92); + --muted: rgba(255, 255, 255, 0.62); + --muted2: rgba(255, 255, 255, 0.52); + --accent: #0a84ff; + --accent2: #64d2ff; + --danger: #ff453a; + --ok: #32d74b; + --shadow: 0 16px 60px rgba(0, 0, 0, 0.50); + --radius: 18px; + --radius2: 14px; + --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + } + * { box-sizing: border-box; } + html, body { height: 100%; } + body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, "SF Pro Display", "SF Pro Text", "Segoe UI", Roboto, Helvetica, Arial, sans-serif; + background: + radial-gradient(900px 600px at 15% 10%, rgba(100, 210, 255, 0.25), transparent 60%), + radial-gradient(900px 600px at 85% 20%, rgba(10, 132, 255, 0.22), transparent 60%), + radial-gradient(900px 600px at 50% 90%, rgba(50, 215, 75, 0.10), transparent 60%), + linear-gradient(180deg, var(--bg0), var(--bg1)); + color: var(--text); + display: flex; + align-items: center; + justify-content: center; + padding: 24px; + } + .shell { width: max(500px); } + .panel { + padding: 22px; + border: 1px solid var(--border); + background: rgba(255,255,255,0.06); + border-radius: var(--radius); + box-shadow: var(--shadow); + backdrop-filter: blur(16px); + -webkit-backdrop-filter: blur(16px); + } + .top { + display: flex; + gap: 14px; + align-items: center; + margin-bottom: 14px; + } + .mark { + width: 46px; + height: 46px; + border-radius: 14px; + background: linear-gradient(135deg, rgba(10,132,255,0.85), rgba(100,210,255,0.55)); + border: 1px solid rgba(255,255,255,0.20); + box-shadow: 0 10px 40px rgba(10, 132, 255, 0.30); + display: flex; + align-items: center; + justify-content: center; + font-weight: 800; + letter-spacing: 1px; + color: rgba(255,255,255,0.96); + text-transform: uppercase; + user-select: none; + } + .title { display: flex; flex-direction: column; gap: 4px; } + .title h1 { font-size: 22px; margin: 0; letter-spacing: -0.3px; } + .title p { margin: 0; color: var(--muted); font-size: 13px; line-height: 1.5; } + + h2 { font-size: 16px; margin: 14px 0 10px 0; letter-spacing: -0.2px; } + .lead { font-size: 13px; line-height: 1.7; color: rgba(255,255,255,0.86); } + + .kv { + border-radius: var(--radius2); + border: 1px solid rgba(255,255,255,0.14); + background: rgba(255,255,255,0.05); + padding: 14px; + margin-top: 12px; + } + .kv h3 { margin: 0 0 8px 0; font-size: 13px; color: rgba(255,255,255,0.86); } + .kv p { margin: 0; font-size: 12px; line-height: 1.55; color: var(--muted); white-space: pre-line; } + + .server { + margin-top: 10px; + font-family: var(--mono); + font-size: 12px; + padding: 10px 12px; + border-radius: 12px; + background: rgba(0,0,0,0.25); + border: 1px solid rgba(255,255,255,0.12); + word-break: break-all; + color: rgba(255,255,255,0.90); + } + + .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } + .btn { + height: 38px; + padding: 0 12px; + border-radius: 12px; + border: 1px solid rgba(255,255,255,0.18); + background: rgba(0,0,0,0.18); + color: rgba(255,255,255,0.92); + font-weight: 700; + cursor: pointer; + } + .btn.primary { + background: linear-gradient(135deg, rgba(10,132,255,0.95), rgba(100,210,255,0.60)); + } + .btn:disabled { opacity: 0.55; cursor: not-allowed; } + + a { color: rgba(100, 210, 255, 0.92); text-decoration: none; } + a:hover { text-decoration: underline; } + + .footer { + margin-top: 18px; + padding-top: 14px; + border-top: 1px solid rgba(255,255,255,0.10); + display: flex; + justify-content: space-between; + gap: 12px; + flex-wrap: wrap; + font-size: 12px; + color: rgba(255,255,255,0.55); + } + `; +} + +export type JwtSecretState = 'missing' | 'default' | 'too_short'; + +export function renderJwtSecretWarningPage(request: Request, state: JwtSecretState): string { + const lang: Lang = isChineseFromRequest(request) ? 'zh' : 'en'; + + const descKey = state === 'missing' ? 'cfgDescMissing' : state === 'default' ? 'cfgDescDefault' : 'cfgDescTooShort'; + + return ` + + + + + NodeWarden + + + +
+ +
+ + + +`; +} diff --git a/src/handlers/setupRegisterPage.ts b/src/handlers/setupRegisterPage.ts new file mode 100644 index 0000000..a6a9bba --- /dev/null +++ b/src/handlers/setupRegisterPage.ts @@ -0,0 +1,668 @@ +import { Env } from '../types'; +import { StorageService } from '../services/storage'; +import { htmlResponse } from '../utils/response'; + +// Registration/setup page HTML (single-file, no external assets) +// Split out from the old monolithic `setup.ts` as requested. +const registerPageHTML = ` + + + + + NodeWarden + + + +
+ +
+ + + +`; + +export async function handleRegisterPage(request: Request, env: Env): Promise { + const storage = new StorageService(env.VAULT); + const disabled = await storage.isSetupDisabled(); + if (disabled) { + return new Response(null, { status: 404 }); + } + return htmlResponse(registerPageHTML); +} diff --git a/src/index.ts b/src/index.ts index 64a8284..217a09f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -3,16 +3,7 @@ import { handleRequest } from './router'; export default { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { - // Security check: JWT_SECRET must be set - if (!env.JWT_SECRET) { - return new Response('Server configuration error: JWT_SECRET is not set', { status: 500 }); - } - - // Security check: warn if JWT_SECRET is too weak - if (env.JWT_SECRET.length < 32) { - console.warn('[SECURITY WARNING] JWT_SECRET should be at least 32 characters for adequate security'); - } - - return handleRequest(request, env); + + return handleRequest(request, env); }, }; diff --git a/src/router.ts b/src/router.ts index f014105..dea7f2b 100644 --- a/src/router.ts +++ b/src/router.ts @@ -89,6 +89,7 @@ export async function handleRequest(request: Request, env: Env): Promise