mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: Implement TOTP-based two-factor authentication
- Added TOTP support for two-factor authentication in user profiles and login flows. - Introduced device management endpoints to handle known devices and their registration. - Enhanced database schema to include devices and trusted two-factor tokens. - Updated response handling to include two-factor token in successful login responses. - Modified registration and login pages to guide users through enabling TOTP. - Improved device identification and management utilities for better user experience.
This commit is contained in:
@@ -18,10 +18,10 @@ English:[`README_EN.md`](./README_EN.md)
|
||||
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
|
||||
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
|
||||
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
|
||||
| 密码条目 TOTP 字段 | ❌ | ✅ |官方需要会员,我们的不需要 |
|
||||
| passkey、TOTP | ❌ | ✅ |官方需要会员,我们的不需要 |
|
||||
| 多用户 | ✅ | ❌ | NodeWarden 定位单用户 |
|
||||
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
|
||||
| 登录 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | 暂未实现 |
|
||||
| 登录 2FA(TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ 部分支持 | 仅支持 TOTP(通过 `TOTP_SECRET`) |
|
||||
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
|
||||
| Send | ✅ | ❌ | 基本没人用 |
|
||||
| 紧急访问 | ✅ | ❌ | 没必要实现 |
|
||||
@@ -58,6 +58,13 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## 可选:登录 TOTP(2FA)
|
||||
|
||||
- 在 Workers 的 Variables and Secrets 里新增 Secret:`TOTP_SECRET`(Base32)。
|
||||
- 配置了 `TOTP_SECRET` 就启用登录 TOTP;删除该变量即关闭。
|
||||
- 客户端流程:密码 -> TOTP 验证码。
|
||||
- 支持“记住此设备”30 天。
|
||||
|
||||
---
|
||||
|
||||
## 常见问题
|
||||
|
||||
+9
-2
@@ -20,10 +20,10 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
|
||||
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
|
||||
| Import flow (common clients) | ✅ | ✅ | Common import paths covered |
|
||||
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` |
|
||||
| Vault item TOTP field | ❌ | ✅ | Official service requires premium; NodeWarden does not |
|
||||
| passkey、TOTP | ❌ | ✅ | Official service requires premium; NodeWarden does not |
|
||||
| Multi-user | ✅ | ❌ | NodeWarden is single-user by design |
|
||||
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement |
|
||||
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ❌ | Not implemented yet |
|
||||
| Login 2FA (TOTP/WebAuthn/Duo/Email) | ✅ | ⚠️ Partial | TOTP-only via `TOTP_SECRET` |
|
||||
| SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
|
||||
| Send | ✅ | ❌ | Not necessary to implement |
|
||||
| Emergency access | ✅ | ❌ | Not necessary to implement |
|
||||
@@ -61,6 +61,13 @@ npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
## Optional Login TOTP (2FA)
|
||||
|
||||
- Add Workers Secret `TOTP_SECRET` (Base32) to enable login TOTP.
|
||||
- Remove `TOTP_SECRET` to disable login TOTP.
|
||||
- Client flow: password -> TOTP code.
|
||||
- "Remember this device" is supported for 30 days.
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
-- IMPORTANT:
|
||||
-- Keep this file in sync with src/services/storage.ts (SCHEMA_STATEMENTS).
|
||||
-- Any new table/column/index must be added to both places together.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS config (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
@@ -77,6 +81,28 @@ CREATE TABLE IF NOT EXISTS refresh_tokens (
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS devices (
|
||||
user_id TEXT NOT NULL,
|
||||
device_identifier TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
type INTEGER NOT NULL,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL,
|
||||
PRIMARY KEY (user_id, device_identifier),
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL,
|
||||
device_identifier TEXT NOT NULL,
|
||||
expires_at INTEGER NOT NULL,
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device
|
||||
ON trusted_two_factor_device_tokens(user_id, device_identifier);
|
||||
|
||||
-- Rate limiting
|
||||
CREATE TABLE IF NOT EXISTS login_attempts_ip (
|
||||
ip TEXT PRIMARY KEY,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { AuthService } from '../services/auth';
|
||||
import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled } from '../utils/totp';
|
||||
|
||||
function looksLikeEncString(value: string): boolean {
|
||||
if (!value) return false;
|
||||
@@ -128,7 +129,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: null,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: false,
|
||||
twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET),
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: null,
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
import { Env } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse } from '../utils/response';
|
||||
import { readKnownDeviceProbe } from '../utils/device';
|
||||
|
||||
// GET /api/devices/knowndevice
|
||||
// Compatible with Bitwarden/Vaultwarden behavior:
|
||||
// - X-Request-Email: base64url(email) without padding
|
||||
// - X-Device-Identifier: client device identifier
|
||||
export async function handleKnownDevice(request: Request, env: Env): Promise<Response> {
|
||||
const storage = new StorageService(env.DB);
|
||||
const { email, deviceIdentifier } = readKnownDeviceProbe(request);
|
||||
|
||||
if (!email || !deviceIdentifier) {
|
||||
return jsonResponse(false);
|
||||
}
|
||||
|
||||
const known = await storage.isKnownDeviceByEmail(email, deviceIdentifier);
|
||||
return jsonResponse(known);
|
||||
}
|
||||
|
||||
// GET /api/devices
|
||||
export async function handleGetDevices(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
void request;
|
||||
const storage = new StorageService(env.DB);
|
||||
const devices = await storage.getDevicesByUserId(userId);
|
||||
|
||||
return jsonResponse({
|
||||
data: devices.map(device => ({
|
||||
id: device.deviceIdentifier,
|
||||
name: device.name,
|
||||
identifier: device.deviceIdentifier,
|
||||
type: device.type,
|
||||
creationDate: device.createdAt,
|
||||
revisionDate: device.updatedAt,
|
||||
object: 'device',
|
||||
})),
|
||||
object: 'list',
|
||||
continuationToken: null,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,6 +4,48 @@ import { AuthService } from '../services/auth';
|
||||
import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
|
||||
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||
import { createRefreshToken } from '../utils/jwt';
|
||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response {
|
||||
// Bitwarden clients rely on these fields to trigger the 2FA UI flow.
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: message,
|
||||
TwoFactorProviders: [0],
|
||||
TwoFactorProviders2: {
|
||||
'0': {
|
||||
Priority: 1,
|
||||
},
|
||||
},
|
||||
ErrorModel: {
|
||||
Message: message,
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
400
|
||||
);
|
||||
}
|
||||
|
||||
async function recordFailedLoginAndBuildResponse(
|
||||
rateLimit: RateLimitService,
|
||||
loginIdentifier: string,
|
||||
message: string
|
||||
): Promise<Response> {
|
||||
const result = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
if (result.locked) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
return identityErrorResponse(message, 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
// POST /identity/connect/token
|
||||
export async function handleToken(request: Request, env: Env): Promise<Response> {
|
||||
@@ -30,7 +72,11 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
// Login with password
|
||||
const email = body.username?.toLowerCase();
|
||||
const passwordHash = body.password;
|
||||
const twoFactorToken = body.twoFactorToken;
|
||||
const twoFactorProvider = body.twoFactorProvider;
|
||||
const twoFactorRemember = body.twoFactorRemember;
|
||||
const loginIdentifier = getClientIdentifier(request);
|
||||
const deviceInfo = readAuthRequestDeviceInfo(body, request);
|
||||
|
||||
if (!email || !passwordHash) {
|
||||
// Bitwarden clients expect OAuth-style error fields.
|
||||
@@ -55,16 +101,60 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
|
||||
const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
|
||||
if (!valid) {
|
||||
// Record failed login attempt
|
||||
const result = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
if (result.locked) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
return recordFailedLoginAndBuildResponse(
|
||||
rateLimit,
|
||||
loginIdentifier,
|
||||
'Username or password is incorrect. Try again'
|
||||
);
|
||||
}
|
||||
|
||||
if (deviceInfo.deviceIdentifier) {
|
||||
await storage.upsertDevice(user.id, deviceInfo.deviceIdentifier, deviceInfo.deviceName, deviceInfo.deviceType);
|
||||
}
|
||||
|
||||
// Optional 2FA: enabled only when TOTP_SECRET is configured in Workers env.
|
||||
let trustedTwoFactorTokenToReturn: string | undefined;
|
||||
if (isTotpEnabled(env.TOTP_SECRET)) {
|
||||
const rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
|
||||
|
||||
// Bitwarden may reuse twoFactorToken as a remembered-device token on subsequent logins.
|
||||
let passedByRememberToken = false;
|
||||
if (twoFactorToken && !/^\d{6}$/.test(twoFactorToken) && deviceInfo.deviceIdentifier) {
|
||||
const trustedUserId = await storage.getTrustedTwoFactorDeviceTokenUserId(
|
||||
twoFactorToken,
|
||||
deviceInfo.deviceIdentifier
|
||||
);
|
||||
passedByRememberToken = trustedUserId === user.id;
|
||||
}
|
||||
|
||||
if (!passedByRememberToken && !twoFactorToken) {
|
||||
return twoFactorRequiredResponse();
|
||||
}
|
||||
|
||||
if (!passedByRememberToken) {
|
||||
const totpOk = await verifyTotpToken(env.TOTP_SECRET!, twoFactorToken);
|
||||
if (!totpOk) {
|
||||
const failed = await rateLimit.recordFailedLogin(loginIdentifier);
|
||||
if (failed.locked) {
|
||||
return identityErrorResponse(
|
||||
`Too many failed login attempts. Account locked for ${Math.ceil(failed.retryAfterSeconds! / 60)} minutes.`,
|
||||
'TooManyRequests',
|
||||
429
|
||||
);
|
||||
}
|
||||
return identityErrorResponse('Invalid two-factor token', 'invalid_grant', 400);
|
||||
}
|
||||
}
|
||||
|
||||
if (rememberRequested && deviceInfo.deviceIdentifier) {
|
||||
trustedTwoFactorTokenToReturn = createRefreshToken();
|
||||
await storage.saveTrustedTwoFactorDeviceToken(
|
||||
trustedTwoFactorTokenToReturn,
|
||||
user.id,
|
||||
deviceInfo.deviceIdentifier,
|
||||
Date.now() + TWO_FACTOR_REMEMBER_TTL_MS
|
||||
);
|
||||
}
|
||||
return identityErrorResponse('Username or password is incorrect. Try again', 'invalid_grant', 400);
|
||||
}
|
||||
|
||||
// Successful login - clear failed attempts
|
||||
@@ -78,6 +168,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
|
||||
expires_in: LIMITS.auth.accessTokenTtlSeconds,
|
||||
token_type: 'Bearer',
|
||||
refresh_token: refreshToken,
|
||||
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
|
||||
Key: user.key,
|
||||
PrivateKey: user.privateKey,
|
||||
Kdf: user.kdfType,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { StorageService } from '../services/storage';
|
||||
import { errorResponse } from '../utils/response';
|
||||
import { cipherToResponse } from './ciphers';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled } from '../utils/totp';
|
||||
|
||||
interface SyncCacheEntry {
|
||||
body: string;
|
||||
@@ -73,7 +74,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
|
||||
usesKeyConnector: false,
|
||||
masterPasswordHint: null,
|
||||
culture: 'en-US',
|
||||
twoFactorEnabled: false,
|
||||
twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET),
|
||||
key: user.key,
|
||||
privateKey: user.privateKey,
|
||||
accountKeys: null,
|
||||
|
||||
+55
-26
@@ -3,39 +3,68 @@ import { handleRequest } from './router';
|
||||
import { StorageService } from './services/storage';
|
||||
import { applyCors, jsonResponse } from './utils/response';
|
||||
|
||||
// Per-isolate flags. Each Worker isolate may have its own copy of these flags.
|
||||
// initializeDatabase() only validates schema presence, so retries are cheap.
|
||||
let dbInitialized = false;
|
||||
let dbInitError: string | null = null;
|
||||
let dbInitPromise: Promise<void> | null = null;
|
||||
|
||||
function shouldSkipDatabaseInit(request: Request): boolean {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
const method = request.method;
|
||||
|
||||
if (method === 'OPTIONS') return true;
|
||||
if (method === 'GET' && (path === '/favicon.ico' || path === '/favicon.svg')) return true;
|
||||
if (method === 'GET' && path === '/.well-known/appspecific/com.chrome.devtools.json') return true;
|
||||
if (method === 'GET' && path.startsWith('/icons/')) return true;
|
||||
if (path.startsWith('/notifications/')) return true;
|
||||
if (method === 'GET' && (path === '/config' || path === '/api/config' || path === '/api/version')) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function ensureDatabaseInitialized(env: Env): Promise<void> {
|
||||
if (dbInitialized) return;
|
||||
|
||||
if (!dbInitPromise) {
|
||||
dbInitPromise = (async () => {
|
||||
const storage = new StorageService(env.DB);
|
||||
await storage.initializeDatabase();
|
||||
dbInitialized = true;
|
||||
dbInitError = null;
|
||||
})()
|
||||
.catch((error: unknown) => {
|
||||
console.error('Failed to initialize database:', error);
|
||||
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
|
||||
})
|
||||
.finally(() => {
|
||||
dbInitPromise = null;
|
||||
});
|
||||
}
|
||||
|
||||
await dbInitPromise;
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
|
||||
// Auto-initialize database on first request
|
||||
if (!dbInitialized) {
|
||||
try {
|
||||
const storage = new StorageService(env.DB);
|
||||
await storage.initializeDatabase();
|
||||
dbInitialized = true;
|
||||
dbInitError = null;
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize database:', error);
|
||||
dbInitError = error instanceof Error ? error.message : 'Unknown database initialization error';
|
||||
}
|
||||
}
|
||||
void ctx;
|
||||
const requiresDatabase = !shouldSkipDatabaseInit(request);
|
||||
|
||||
if (dbInitError) {
|
||||
const resp = jsonResponse(
|
||||
{
|
||||
error: 'Database not initialized',
|
||||
error_description: dbInitError,
|
||||
ErrorModel: {
|
||||
Message: dbInitError,
|
||||
Object: 'error',
|
||||
if (requiresDatabase) {
|
||||
await ensureDatabaseInitialized(env);
|
||||
if (dbInitError) {
|
||||
const resp = jsonResponse(
|
||||
{
|
||||
error: 'Database not initialized',
|
||||
error_description: dbInitError,
|
||||
ErrorModel: {
|
||||
Message: dbInitError,
|
||||
Object: 'error',
|
||||
},
|
||||
},
|
||||
},
|
||||
500
|
||||
);
|
||||
return applyCors(request, resp);
|
||||
500
|
||||
);
|
||||
return applyCors(request, resp);
|
||||
}
|
||||
}
|
||||
|
||||
const resp = await handleRequest(request, env);
|
||||
|
||||
+6
-9
@@ -37,6 +37,7 @@ import { handleSync } from './handlers/sync';
|
||||
|
||||
// Setup handlers
|
||||
import { handleSetupPage, handleSetupStatus, handleDisableSetup } from './handlers/setup';
|
||||
import { handleKnownDevice, handleGetDevices } from './handlers/devices';
|
||||
|
||||
// Import handler
|
||||
import { handleCiphersImport } from './handlers/import';
|
||||
@@ -218,13 +219,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
return new Response(null, { status: 200 });
|
||||
}
|
||||
|
||||
// Known device check (no auth required) - returns plain string "true" or "false"
|
||||
if (path.startsWith('/api/devices/knowndevice')) {
|
||||
return new Response('true', {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
},
|
||||
});
|
||||
// Known device check (no auth required)
|
||||
if (path === '/api/devices/knowndevice' && method === 'GET') {
|
||||
return handleKnownDevice(request, env);
|
||||
}
|
||||
|
||||
// Identity endpoints (no auth required)
|
||||
@@ -540,9 +537,9 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
}
|
||||
}
|
||||
|
||||
// Devices endpoint (stub) - for authenticated requests
|
||||
// Devices endpoint
|
||||
if (path === '/api/devices' && method === 'GET') {
|
||||
return jsonResponse({ data: [], object: 'list', continuationToken: null });
|
||||
return handleGetDevices(request, env, userId);
|
||||
}
|
||||
|
||||
// Not found
|
||||
|
||||
+185
-53
@@ -1,6 +1,71 @@
|
||||
import { User, Cipher, Folder, Attachment } from '../types';
|
||||
import { User, Cipher, Folder, Attachment, Device } from '../types';
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const SCHEMA_HASH_CONFIG_KEY = 'schema_hash';
|
||||
|
||||
// IMPORTANT:
|
||||
// Keep this schema list in sync with migrations/0001_init.sql.
|
||||
// Any new table/column/index must be added to both places together.
|
||||
const SCHEMA_STATEMENTS: readonly string[] = [
|
||||
'CREATE TABLE IF NOT EXISTS users (' +
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
||||
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS attachments (' +
|
||||
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
|
||||
'size_name TEXT NOT NULL, key TEXT, ' +
|
||||
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS devices (' +
|
||||
'user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, name TEXT NOT NULL, type INTEGER NOT NULL, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'PRIMARY KEY (user_id, device_identifier), ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_devices_user_updated ON devices(user_id, updated_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS trusted_two_factor_device_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, device_identifier TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_trusted_two_factor_device_tokens_user_device ON trusted_two_factor_device_tokens(user_id, device_identifier)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
|
||||
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
|
||||
'PRIMARY KEY (identifier, window_start))',
|
||||
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
|
||||
];
|
||||
|
||||
// D1-backed storage.
|
||||
// Contract:
|
||||
// - All methods are scoped by userId where applicable.
|
||||
@@ -56,69 +121,49 @@ export class StorageService {
|
||||
}
|
||||
|
||||
// --- Database initialization ---
|
||||
// One-click deploy requires zero manual migration steps.
|
||||
// This method idempotently creates required schema objects on first request.
|
||||
// Strategy:
|
||||
// - Run only once per isolate.
|
||||
// - Persist schema hash in DB config; if unchanged, skip all schema SQL.
|
||||
// - Keep statements idempotent so updates are safe.
|
||||
async initializeDatabase(): Promise<void> {
|
||||
if (StorageService.schemaVerified) return;
|
||||
|
||||
const schemaStatements = [
|
||||
'PRAGMA foreign_keys = ON',
|
||||
await this.db.prepare('PRAGMA foreign_keys = ON').run();
|
||||
await this.db.prepare('CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)').run();
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS config (key TEXT PRIMARY KEY, value TEXT NOT NULL)',
|
||||
const schemaHash = await this.sha256Hex(SCHEMA_STATEMENTS.join('\n'));
|
||||
const current = await this.db.prepare('SELECT value FROM config WHERE key = ?')
|
||||
.bind(SCHEMA_HASH_CONFIG_KEY)
|
||||
.first<{ value: string }>();
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS users (' +
|
||||
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' +
|
||||
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
|
||||
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
|
||||
'security_stamp TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
|
||||
if (current?.value !== schemaHash) {
|
||||
for (const stmt of SCHEMA_STATEMENTS) {
|
||||
await this.executeSchemaStatement(stmt);
|
||||
}
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS ciphers (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, type INTEGER NOT NULL, folder_id TEXT, name TEXT, notes TEXT, ' +
|
||||
'favorite INTEGER NOT NULL DEFAULT 0, data TEXT NOT NULL, reprompt INTEGER, key TEXT, ' +
|
||||
'created_at TEXT NOT NULL, updated_at TEXT NOT NULL, deleted_at TEXT, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_updated ON ciphers(user_id, updated_at)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_ciphers_user_deleted ON ciphers(user_id, deleted_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS folders (' +
|
||||
'id TEXT PRIMARY KEY, user_id TEXT NOT NULL, name TEXT NOT NULL, created_at TEXT NOT NULL, updated_at TEXT NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_folders_user_updated ON folders(user_id, updated_at)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS attachments (' +
|
||||
'id TEXT PRIMARY KEY, cipher_id TEXT NOT NULL, file_name TEXT NOT NULL, size INTEGER NOT NULL, ' +
|
||||
'size_name TEXT NOT NULL, key TEXT, ' +
|
||||
'FOREIGN KEY (cipher_id) REFERENCES ciphers(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_attachments_cipher ON attachments(cipher_id)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS refresh_tokens (' +
|
||||
'token TEXT PRIMARY KEY, user_id TEXT NOT NULL, expires_at INTEGER NOT NULL, ' +
|
||||
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)',
|
||||
'CREATE INDEX IF NOT EXISTS idx_refresh_tokens_user ON refresh_tokens(user_id)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS api_rate_limits (' +
|
||||
'identifier TEXT NOT NULL, window_start INTEGER NOT NULL, count INTEGER NOT NULL, ' +
|
||||
'PRIMARY KEY (identifier, window_start))',
|
||||
'CREATE INDEX IF NOT EXISTS idx_api_rate_window ON api_rate_limits(window_start)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS login_attempts_ip (' +
|
||||
'ip TEXT PRIMARY KEY, attempts INTEGER NOT NULL, locked_until INTEGER, updated_at INTEGER NOT NULL)',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS used_attachment_download_tokens (' +
|
||||
'jti TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)',
|
||||
];
|
||||
|
||||
for (const stmt of schemaStatements) {
|
||||
await this.db.prepare(stmt).run();
|
||||
await this.db.prepare(
|
||||
'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
|
||||
)
|
||||
.bind(SCHEMA_HASH_CONFIG_KEY, schemaHash)
|
||||
.run();
|
||||
}
|
||||
|
||||
StorageService.schemaVerified = true;
|
||||
}
|
||||
|
||||
private async executeSchemaStatement(statement: string): Promise<void> {
|
||||
try {
|
||||
await this.db.prepare(statement).run();
|
||||
} catch (error) {
|
||||
const msg = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||
// Keep migration resilient if a future non-idempotent DDL is retried.
|
||||
if (msg.includes('already exists') || msg.includes('duplicate column name')) {
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config / setup ---
|
||||
|
||||
async isRegistered(): Promise<boolean> {
|
||||
@@ -613,6 +658,93 @@ export class StorageService {
|
||||
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run();
|
||||
}
|
||||
|
||||
private async trustedTwoFactorTokenKey(token: string): Promise<string> {
|
||||
const digest = await this.sha256Hex(token);
|
||||
return `sha256:${digest}`;
|
||||
}
|
||||
|
||||
// --- Devices ---
|
||||
|
||||
async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number): Promise<void> {
|
||||
const now = new Date().toISOString();
|
||||
await this.db.prepare(
|
||||
'INSERT INTO devices(user_id, device_identifier, name, type, created_at, updated_at) VALUES(?, ?, ?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(user_id, device_identifier) DO UPDATE SET name=excluded.name, type=excluded.type, updated_at=excluded.updated_at'
|
||||
)
|
||||
.bind(userId, deviceIdentifier, name, type, now, now)
|
||||
.run();
|
||||
}
|
||||
|
||||
async isKnownDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
|
||||
const row = await this.db
|
||||
.prepare('SELECT 1 FROM devices WHERE user_id = ? AND device_identifier = ? LIMIT 1')
|
||||
.bind(userId, deviceIdentifier)
|
||||
.first<{ '1': number }>();
|
||||
return !!row;
|
||||
}
|
||||
|
||||
async isKnownDeviceByEmail(email: string, deviceIdentifier: string): Promise<boolean> {
|
||||
const user = await this.getUser(email);
|
||||
if (!user) return false;
|
||||
return this.isKnownDevice(user.id, deviceIdentifier);
|
||||
}
|
||||
|
||||
async getDevicesByUserId(userId: string): Promise<Device[]> {
|
||||
const res = await this.db
|
||||
.prepare(
|
||||
'SELECT user_id, device_identifier, name, type, created_at, updated_at ' +
|
||||
'FROM devices WHERE user_id = ? ORDER BY updated_at DESC'
|
||||
)
|
||||
.bind(userId)
|
||||
.all<any>();
|
||||
return (res.results || []).map(row => ({
|
||||
userId: row.user_id,
|
||||
deviceIdentifier: row.device_identifier,
|
||||
name: row.name,
|
||||
type: row.type,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
}));
|
||||
}
|
||||
|
||||
// --- Trusted 2FA remember tokens (device-bound) ---
|
||||
|
||||
async saveTrustedTwoFactorDeviceToken(
|
||||
token: string,
|
||||
userId: string,
|
||||
deviceIdentifier: string,
|
||||
expiresAtMs?: number
|
||||
): Promise<void> {
|
||||
const expiresAt = expiresAtMs ?? (Date.now() + TWO_FACTOR_REMEMBER_TTL_MS);
|
||||
const tokenKey = await this.trustedTwoFactorTokenKey(token);
|
||||
|
||||
await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(Date.now()).run();
|
||||
await this.db.prepare(
|
||||
'INSERT INTO trusted_two_factor_device_tokens(token, user_id, device_identifier, expires_at) VALUES(?, ?, ?, ?) ' +
|
||||
'ON CONFLICT(token) DO UPDATE SET user_id=excluded.user_id, device_identifier=excluded.device_identifier, expires_at=excluded.expires_at'
|
||||
)
|
||||
.bind(tokenKey, userId, deviceIdentifier, expiresAt)
|
||||
.run();
|
||||
}
|
||||
|
||||
async getTrustedTwoFactorDeviceTokenUserId(token: string, deviceIdentifier: string): Promise<string | null> {
|
||||
const now = Date.now();
|
||||
const tokenKey = await this.trustedTwoFactorTokenKey(token);
|
||||
const row = await this.db
|
||||
.prepare(
|
||||
'SELECT user_id, expires_at FROM trusted_two_factor_device_tokens WHERE token = ? AND device_identifier = ?'
|
||||
)
|
||||
.bind(tokenKey, deviceIdentifier)
|
||||
.first<{ user_id: string; expires_at: number }>();
|
||||
|
||||
if (!row) return null;
|
||||
if (row.expires_at && row.expires_at < now) {
|
||||
await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE token = ?').bind(tokenKey).run();
|
||||
return null;
|
||||
}
|
||||
return row.user_id;
|
||||
}
|
||||
|
||||
// --- Revision dates ---
|
||||
|
||||
async getRevisionDate(userId: string): Promise<string> {
|
||||
|
||||
+93
-16
@@ -528,7 +528,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
</section>
|
||||
|
||||
<section id="step4" class="step">
|
||||
<h2 id="t_s4_title">Final step</h2>
|
||||
<h2 id="t_s4_title">Create account</h2>
|
||||
<p class="lead" id="t_s4_desc"></p>
|
||||
|
||||
<div id="setup-form">
|
||||
@@ -561,6 +561,35 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
<button type="submit" id="submitBtn" class="btn primary" style="width:100%;height:52px;">Create account</button>
|
||||
</form>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="step5" class="step">
|
||||
<h2 id="t_s5_title">Optional: login TOTP (2FA)</h2>
|
||||
<p class="lead" id="t_s5_desc"></p>
|
||||
|
||||
<div class="kv" style="margin-top:14px;">
|
||||
<h3 id="t_s5_enable_title">Enable on server (Cloudflare Workers)</h3>
|
||||
<ol>
|
||||
<li id="t_s5_enable_1"></li>
|
||||
<li id="t_s5_enable_2"></li>
|
||||
<li id="t_s5_enable_3"></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div class="kv">
|
||||
<h3 id="t_s5_client_title">Use in Bitwarden client</h3>
|
||||
<ol>
|
||||
<li id="t_s5_client_1"></li>
|
||||
<li id="t_s5_client_2"></li>
|
||||
<li id="t_s5_client_3"></li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
</section>
|
||||
|
||||
<section id="step6" class="step">
|
||||
<h2 id="t_s6_title">Final step</h2>
|
||||
<p class="lead" id="t_s6_desc"></p>
|
||||
|
||||
<div id="registered-view" style="display:none;">
|
||||
<div class="kv">
|
||||
@@ -594,6 +623,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
<span class="dot" data-step="2"></span>
|
||||
<span class="dot" data-step="3"></span>
|
||||
<span class="dot" data-step="4"></span>
|
||||
<span class="dot" data-step="5"></span>
|
||||
<span class="dot" data-step="6"></span>
|
||||
</div>
|
||||
<div class="flow-actions" style="justify-content:flex-end;">
|
||||
<button id="nextBtn" class="btn primary" type="button">Next</button>
|
||||
@@ -667,7 +698,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
copy: '复制',
|
||||
copied: '已复制',
|
||||
|
||||
s3Title: '同步策略(可跳过)',
|
||||
s3Title: '更新策略(可跳过)',
|
||||
s3CommonTitle: '共同前置步骤',
|
||||
s3Common1: '如果还没 fork,请先 fork 本项目到你自己的 GitHub。',
|
||||
s3Common2: 'Cloudflare 控制台 → Workers 和 Pages → NodeWarden → 设置 → 构建 → Git 存储库 → 断开联机。',
|
||||
@@ -682,8 +713,20 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
s3AutoStep2: '点击 “I understand my workflows, go ahead and enable them”。',
|
||||
s3AutoStep3: '默认每天凌晨 3 点自动同步;需要时可手动点 “Run workflow”。',
|
||||
|
||||
s4Title: '最终:创建账号',
|
||||
s4Desc: '填写信息并创建你的唯一账号,完成初始化。',
|
||||
s4Title: '创建账号',
|
||||
s4Desc: '填写信息并创建你的唯一账号。创建成功后会进入登录 TOTP 教程。',
|
||||
s5Title: '开启登录 TOTP(2FA,可跳过)',
|
||||
s5Desc: '这一页可跳过。如果你想开启登录二次验证,按下面步骤设置。',
|
||||
s5EnableTitle: '服务端开启(Cloudflare Workers)',
|
||||
s5Enable1: '打开 Cloudflare 控制台 -> Workers 和 Pages -> NodeWarden -> 设置 -> 变量和机密。',
|
||||
s5Enable2: '新增 Secret:TOTP_SECRET,值填写 Base32 密钥 。',
|
||||
s5Enable3: '保存并等待重新部署。删除 TOTP_SECRET 即关闭登录 2FA。',
|
||||
s5ClientTitle: '客户端使用',
|
||||
s5Client1: '下次登录时先输入主密码。',
|
||||
s5Client2: '客户端会弹出 2FA 验证码输入框,输入验证器里的 6 位码。',
|
||||
s5Client3: '如需可勾选记住此设备(30 天)。',
|
||||
s6Title: '最终页面',
|
||||
s6Desc: '最后一步:查看客户端使用地址,并可选择隐藏初始化页面。',
|
||||
nameLabel: '昵称',
|
||||
emailLabel: '邮箱',
|
||||
pwLabel: '主密码',
|
||||
@@ -769,8 +812,20 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
s3AutoStep2: 'Click “I understand my workflows, go ahead and enable them”.',
|
||||
s3AutoStep3: 'It runs daily at 03:00 by default; you can also click “Run workflow”.',
|
||||
|
||||
s4Title: 'Final: create account',
|
||||
s4Desc: 'Create your single user account to finish setup.',
|
||||
s4Title: 'Create account',
|
||||
s4Desc: 'Create your single user account. After success, you will see the optional login TOTP guide.',
|
||||
s5Title: 'Optional: login TOTP (2FA)',
|
||||
s5Desc: 'You can skip this step. If you want login 2FA, follow the steps below.',
|
||||
s5EnableTitle: 'Enable on server (Cloudflare Workers)',
|
||||
s5Enable1: 'Open Cloudflare Dashboard -> Workers & Pages -> your service -> Settings -> Variables and Secrets.',
|
||||
s5Enable2: 'Add Secret: TOTP_SECRET, value is a Base32 secret (example: JBSWY3DPEHPK3PXP).',
|
||||
s5Enable3: 'Save and wait for redeploy. Remove TOTP_SECRET to disable login 2FA.',
|
||||
s5ClientTitle: 'Use in Bitwarden client',
|
||||
s5Client1: 'On next login, enter your master password first.',
|
||||
s5Client2: 'Client will prompt 2FA code. Enter the 6-digit code from your authenticator app.',
|
||||
s5Client3: 'Optionally choose remember this device (30 days).',
|
||||
s6Title: 'Final step',
|
||||
s6Desc: 'Last step: check your server URL, then optionally hide this setup page.',
|
||||
nameLabel: 'Name',
|
||||
emailLabel: 'Email',
|
||||
pwLabel: 'Master password',
|
||||
@@ -886,6 +941,18 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
setText('t_hide_title', t('hideTitle'));
|
||||
setText('t_hide_desc', t('hideDesc'));
|
||||
setText('hideBtn', t('hideBtn'));
|
||||
setText('t_s5_title', t('s5Title'));
|
||||
setText('t_s5_desc', t('s5Desc'));
|
||||
setText('t_s5_enable_title', t('s5EnableTitle'));
|
||||
setText('t_s5_enable_1', t('s5Enable1'));
|
||||
setText('t_s5_enable_2', t('s5Enable2'));
|
||||
setText('t_s5_enable_3', t('s5Enable3'));
|
||||
setText('t_s5_client_title', t('s5ClientTitle'));
|
||||
setText('t_s5_client_1', t('s5Client1'));
|
||||
setText('t_s5_client_2', t('s5Client2'));
|
||||
setText('t_s5_client_3', t('s5Client3'));
|
||||
setText('t_s6_title', t('s6Title'));
|
||||
setText('t_s6_desc', t('s6Desc'));
|
||||
setText('hideModalTitle', t('hideModalTitle'));
|
||||
setText('hideModalDesc', t('hideModalDesc'));
|
||||
setText('hideModalWarn', t('hideModalWarn'));
|
||||
@@ -952,10 +1019,11 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
|
||||
function goToStep(targetStep) {
|
||||
// 安全限制:JWT_SECRET 不合规时,只允许访问第 1/2 步。
|
||||
const maxStep = JWT_STATE ? 2 : 4;
|
||||
const maxStep = JWT_STATE ? 2 : (isRegistered ? 6 : 4);
|
||||
currentStep = Math.max(1, Math.min(maxStep, targetStep));
|
||||
if (isRegistered && currentStep === 4) currentStep = 5;
|
||||
|
||||
for (let i = 1; i <= 4; i++) {
|
||||
for (let i = 1; i <= 6; i++) {
|
||||
const el = document.getElementById('step' + i);
|
||||
if (el) el.classList.toggle('active', i === currentStep);
|
||||
}
|
||||
@@ -971,7 +1039,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
|
||||
const nextBtn = document.getElementById('nextBtn');
|
||||
if (nextBtn) {
|
||||
nextBtn.style.display = currentStep >= 4 ? 'none' : 'inline-flex';
|
||||
const reachedEnd = isRegistered ? (currentStep >= 6) : (currentStep >= 4);
|
||||
nextBtn.style.display = reachedEnd ? 'none' : 'inline-flex';
|
||||
if (currentStep === 2 && !!JWT_STATE) {
|
||||
nextBtn.disabled = true;
|
||||
nextBtn.textContent = t('keyWaitRefresh');
|
||||
@@ -999,8 +1068,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
// 已注册但密钥不安全:只能停留在首页/密钥页,不能直接进入后续页面。
|
||||
goToStep(2);
|
||||
} else {
|
||||
goToStep(4);
|
||||
showRegisteredView();
|
||||
goToStep(6);
|
||||
showFinalView();
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
@@ -1008,8 +1077,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
}
|
||||
}
|
||||
|
||||
function showRegisteredView() {
|
||||
isRegistered = true;
|
||||
function showFinalView() {
|
||||
const setupForm = document.getElementById('setup-form');
|
||||
const registeredView = document.getElementById('registered-view');
|
||||
const serverUrl = document.getElementById('serverUrl');
|
||||
@@ -1132,7 +1200,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
async function handleSubmit(event) {
|
||||
event.preventDefault();
|
||||
if (isRegistered) {
|
||||
showRegisteredView();
|
||||
goToStep(6);
|
||||
showFinalView();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1197,7 +1266,9 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
|
||||
const result = await response.json();
|
||||
if (response.ok && result.success) {
|
||||
showRegisteredView();
|
||||
isRegistered = true;
|
||||
goToStep(5);
|
||||
showFinalView();
|
||||
} else {
|
||||
showMessage(result.error || (result.ErrorModel && result.ErrorModel.Message) || t('errRegisterFailed'), 'error');
|
||||
if (btn) {
|
||||
@@ -1226,6 +1297,10 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
if (prevBtn) {
|
||||
prevBtn.addEventListener('click', () => {
|
||||
if (currentStep <= 1) return;
|
||||
if (isRegistered && currentStep === 5) {
|
||||
goToStep(3);
|
||||
return;
|
||||
}
|
||||
goToStep(currentStep - 1);
|
||||
});
|
||||
}
|
||||
@@ -1235,7 +1310,9 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
|
||||
nextBtn.addEventListener('click', () => {
|
||||
if (currentStep === 1) goToStep(2);
|
||||
else if (currentStep === 2) goToStep(3);
|
||||
else if (currentStep === 3) goToStep(4);
|
||||
else if (currentStep === 3) goToStep(isRegistered ? 5 : 4);
|
||||
else if (currentStep === 4) goToStep(5);
|
||||
else if (currentStep === 5) goToStep(6);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ export interface Env {
|
||||
DB: D1Database;
|
||||
ATTACHMENTS: R2Bucket;
|
||||
JWT_SECRET: string;
|
||||
TOTP_SECRET?: string;
|
||||
}
|
||||
|
||||
// Sample JWT secret used by `.dev.vars.example`.
|
||||
@@ -147,6 +148,15 @@ export interface Folder {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface Device {
|
||||
userId: string;
|
||||
deviceIdentifier: string;
|
||||
name: string;
|
||||
type: number;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// JWT Payload
|
||||
export interface JWTPayload {
|
||||
sub: string; // user id
|
||||
@@ -190,6 +200,7 @@ export interface TokenResponse {
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
refresh_token: string;
|
||||
TwoFactorToken?: string;
|
||||
Key: string;
|
||||
PrivateKey: string | null;
|
||||
Kdf: number;
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
const DEFAULT_DEVICE_NAME = 'Unknown device';
|
||||
const DEFAULT_DEVICE_TYPE = 14;
|
||||
|
||||
function decodeBase64UrlUtf8(value: string): string | null {
|
||||
try {
|
||||
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
|
||||
const padding = normalized.length % 4;
|
||||
const padded = padding === 0 ? normalized : normalized + '='.repeat(4 - padding);
|
||||
const binary = atob(padded);
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) {
|
||||
bytes[i] = binary.charCodeAt(i);
|
||||
}
|
||||
return new TextDecoder().decode(bytes);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeDeviceIdentifier(value: string | undefined | null): string | null {
|
||||
if (!value) return null;
|
||||
const normalized = String(value).trim();
|
||||
if (!normalized) return null;
|
||||
return normalized.slice(0, 128);
|
||||
}
|
||||
|
||||
function normalizeDeviceName(value: string | undefined | null): string {
|
||||
const normalized = String(value || '').trim();
|
||||
if (!normalized) return DEFAULT_DEVICE_NAME;
|
||||
return normalized.slice(0, 128);
|
||||
}
|
||||
|
||||
function parseDeviceType(value: string | number | undefined | null): number {
|
||||
if (typeof value === 'number' && Number.isFinite(value)) {
|
||||
return Math.max(0, Math.floor(value));
|
||||
}
|
||||
const parsed = Number.parseInt(String(value || ''), 10);
|
||||
if (Number.isFinite(parsed) && parsed >= 0) return parsed;
|
||||
return DEFAULT_DEVICE_TYPE;
|
||||
}
|
||||
|
||||
export interface AuthRequestDeviceInfo {
|
||||
deviceIdentifier: string | null;
|
||||
deviceName: string;
|
||||
deviceType: number;
|
||||
}
|
||||
|
||||
export function readAuthRequestDeviceInfo(
|
||||
body: Record<string, string | undefined>,
|
||||
request: Request
|
||||
): AuthRequestDeviceInfo {
|
||||
const bodyIdentifier = body.deviceIdentifier || body.device_identifier;
|
||||
const headerIdentifier = request.headers.get('X-Device-Identifier') || undefined;
|
||||
const bodyName = body.deviceName || body.device_name;
|
||||
const headerName = request.headers.get('X-Device-Name') || undefined;
|
||||
const bodyType = body.deviceType || body.device_type;
|
||||
const headerType = request.headers.get('Device-Type') || undefined;
|
||||
|
||||
return {
|
||||
deviceIdentifier: normalizeDeviceIdentifier(bodyIdentifier || headerIdentifier),
|
||||
deviceName: normalizeDeviceName(bodyName || headerName),
|
||||
deviceType: parseDeviceType(bodyType || headerType),
|
||||
};
|
||||
}
|
||||
|
||||
export function readKnownDeviceProbe(request: Request): { email: string | null; deviceIdentifier: string | null } {
|
||||
const encodedEmail = request.headers.get('X-Request-Email') || '';
|
||||
const decodedEmail = decodeBase64UrlUtf8(encodedEmail);
|
||||
const fallbackRawEmail = request.headers.get('X-Request-Email');
|
||||
const email = (decodedEmail || fallbackRawEmail || '').trim().toLowerCase() || null;
|
||||
const deviceIdentifier = normalizeDeviceIdentifier(request.headers.get('X-Device-Identifier'));
|
||||
return { email, deviceIdentifier };
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { LIMITS } from '../config/limits';
|
||||
|
||||
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS';
|
||||
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version';
|
||||
const CORS_HEADERS = 'Content-Type, Authorization, Accept, Device-Type, Bitwarden-Client-Name, Bitwarden-Client-Version, X-Request-Email, X-Device-Identifier, X-Device-Name';
|
||||
|
||||
function isTrustedClientOrigin(origin: string): boolean {
|
||||
// Official browser extension / desktop-webview common origins.
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
const TOTP_STEP_SECONDS = 30;
|
||||
const TOTP_DIGITS = 6;
|
||||
const TOTP_WINDOW = 1; // allow previous/current/next step for small clock drift
|
||||
|
||||
function normalizeBase32(input: string): string {
|
||||
return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
|
||||
}
|
||||
|
||||
function base32Decode(input: string): Uint8Array | null {
|
||||
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
||||
const normalized = normalizeBase32(input);
|
||||
if (!normalized) return null;
|
||||
|
||||
let bits = 0;
|
||||
let value = 0;
|
||||
const output: number[] = [];
|
||||
|
||||
for (const char of normalized) {
|
||||
const idx = alphabet.indexOf(char);
|
||||
if (idx === -1) return null;
|
||||
value = (value << 5) | idx;
|
||||
bits += 5;
|
||||
if (bits >= 8) {
|
||||
bits -= 8;
|
||||
output.push((value >> bits) & 0xff);
|
||||
}
|
||||
}
|
||||
|
||||
return output.length > 0 ? new Uint8Array(output) : null;
|
||||
}
|
||||
|
||||
async function hotp(secret: Uint8Array, counter: number): Promise<string> {
|
||||
const counterBytes = new Uint8Array(8);
|
||||
let c = counter;
|
||||
for (let i = 7; i >= 0; i--) {
|
||||
counterBytes[i] = c & 0xff;
|
||||
c = Math.floor(c / 256);
|
||||
}
|
||||
|
||||
const key = await crypto.subtle.importKey(
|
||||
'raw',
|
||||
secret,
|
||||
{ name: 'HMAC', hash: 'SHA-1' },
|
||||
false,
|
||||
['sign']
|
||||
);
|
||||
|
||||
const signature = new Uint8Array(await crypto.subtle.sign('HMAC', key, counterBytes));
|
||||
const offset = signature[signature.length - 1] & 0x0f;
|
||||
const binary =
|
||||
((signature[offset] & 0x7f) << 24) |
|
||||
((signature[offset + 1] & 0xff) << 16) |
|
||||
((signature[offset + 2] & 0xff) << 8) |
|
||||
(signature[offset + 3] & 0xff);
|
||||
|
||||
const otp = binary % (10 ** TOTP_DIGITS);
|
||||
return otp.toString().padStart(TOTP_DIGITS, '0');
|
||||
}
|
||||
|
||||
function normalizeToken(token: string): string {
|
||||
return token.replace(/\s+/g, '');
|
||||
}
|
||||
|
||||
export async function verifyTotpToken(secretRaw: string, tokenRaw: string, nowMs: number = Date.now()): Promise<boolean> {
|
||||
const token = normalizeToken(tokenRaw);
|
||||
if (!/^\d{6}$/.test(token)) return false;
|
||||
|
||||
const secret = base32Decode(secretRaw);
|
||||
if (!secret) return false;
|
||||
|
||||
const currentCounter = Math.floor(nowMs / 1000 / TOTP_STEP_SECONDS);
|
||||
for (let delta = -TOTP_WINDOW; delta <= TOTP_WINDOW; delta++) {
|
||||
const expected = await hotp(secret, currentCounter + delta);
|
||||
if (expected === token) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function isTotpEnabled(secretRaw: string | undefined | null): boolean {
|
||||
return Boolean(secretRaw && normalizeBase32(secretRaw).length > 0);
|
||||
}
|
||||
Reference in New Issue
Block a user