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:
shuaiplus
2026-02-20 15:59:55 +08:00
parent d1a43f2e95
commit cdbe87aac2
15 changed files with 695 additions and 119 deletions
+9 -2
View File
@@ -18,10 +18,10 @@ English[`README_EN.md`](./README_EN.md)
| 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 | | 附件上传/下载 | ✅ | ✅ | 基于 Cloudflare R2 |
| 导入功能 | ✅ | ✅ | 覆盖常见导入路径 | | 导入功能 | ✅ | ✅ | 覆盖常见导入路径 |
| 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` | | 网站图标代理 | ✅ | ✅ | 通过 `/icons/{hostname}/icon.png` |
| 密码条目 TOTP 字段 | ❌ | ✅ |官方需要会员,我们的不需要 | | passkey、TOTP | ❌ | ✅ |官方需要会员,我们的不需要 |
| 多用户 | ✅ | ❌ | NodeWarden 定位单用户 | | 多用户 | ✅ | ❌ | NodeWarden 定位单用户 |
| 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 | | 组织/集合/成员权限 | ✅ | ❌ | 没必要实现 |
| 登录 2FATOTP/WebAuthn/Duo/Email | ✅ | ❌ | 暂未实现 | | 登录 2FATOTP/WebAuthn/Duo/Email | ✅ | ⚠️ 部分支持 | 仅支持 TOTP(通过 `TOTP_SECRET` |
| SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 | | SSO / SCIM / 企业目录 | ✅ | ❌ | 没必要实现 |
| Send | ✅ | ❌ | 基本没人用 | | Send | ✅ | ❌ | 基本没人用 |
| 紧急访问 | ✅ | ❌ | 没必要实现 | | 紧急访问 | ✅ | ❌ | 没必要实现 |
@@ -58,6 +58,13 @@ npm install
npm run dev npm run dev
``` ```
## 可选:登录 TOTP2FA
- 在 Workers 的 Variables and Secrets 里新增 Secret`TOTP_SECRET`Base32)。
- 配置了 `TOTP_SECRET` 就启用登录 TOTP;删除该变量即关闭。
- 客户端流程:密码 -> TOTP 验证码。
- 支持“记住此设备”30 天。
--- ---
## 常见问题 ## 常见问题
+9 -2
View File
@@ -20,10 +20,10 @@ A **Bitwarden-compatible** server that runs on **Cloudflare Workers**.
| Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 | | Attachment upload/download | ✅ | ✅ | Backed by Cloudflare R2 |
| Import flow (common clients) | ✅ | ✅ | Common import paths covered | | Import flow (common clients) | ✅ | ✅ | Common import paths covered |
| Website icon proxy | ✅ | ✅ | Via `/icons/{hostname}/icon.png` | | 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 | | Multi-user | ✅ | ❌ | NodeWarden is single-user by design |
| Organizations / Collections / Member roles | ✅ | ❌ | Not necessary to implement | | 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 | | SSO / SCIM / Enterprise directory | ✅ | ❌ | Not necessary to implement |
| Send | ✅ | ❌ | Not necessary to implement | | Send | ✅ | ❌ | Not necessary to implement |
| Emergency access | ✅ | ❌ | Not necessary to implement | | Emergency access | ✅ | ❌ | Not necessary to implement |
@@ -61,6 +61,13 @@ npm install
npm run dev 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 ## FAQ
+26
View File
@@ -1,5 +1,9 @@
PRAGMA foreign_keys = ON; 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 ( CREATE TABLE IF NOT EXISTS config (
key TEXT PRIMARY KEY, key TEXT PRIMARY KEY,
value TEXT NOT NULL 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 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 -- Rate limiting
CREATE TABLE IF NOT EXISTS login_attempts_ip ( CREATE TABLE IF NOT EXISTS login_attempts_ip (
ip TEXT PRIMARY KEY, ip TEXT PRIMARY KEY,
+2 -1
View File
@@ -4,6 +4,7 @@ import { AuthService } from '../services/auth';
import { jsonResponse, errorResponse } from '../utils/response'; import { jsonResponse, errorResponse } from '../utils/response';
import { generateUUID } from '../utils/uuid'; import { generateUUID } from '../utils/uuid';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled } from '../utils/totp';
function looksLikeEncString(value: string): boolean { function looksLikeEncString(value: string): boolean {
if (!value) return false; if (!value) return false;
@@ -128,7 +129,7 @@ export async function handleGetProfile(request: Request, env: Env, userId: strin
usesKeyConnector: false, usesKeyConnector: false,
masterPasswordHint: null, masterPasswordHint: null,
culture: 'en-US', culture: 'en-US',
twoFactorEnabled: false, twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET),
key: user.key, key: user.key,
privateKey: user.privateKey, privateKey: user.privateKey,
accountKeys: null, accountKeys: null,
+42
View File
@@ -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,
});
}
+99 -8
View File
@@ -4,6 +4,48 @@ import { AuthService } from '../services/auth';
import { RateLimitService, getClientIdentifier } from '../services/ratelimit'; import { RateLimitService, getClientIdentifier } from '../services/ratelimit';
import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response'; import { jsonResponse, errorResponse, identityErrorResponse } from '../utils/response';
import { LIMITS } from '../config/limits'; 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 // POST /identity/connect/token
export async function handleToken(request: Request, env: Env): Promise<Response> { 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 // Login with password
const email = body.username?.toLowerCase(); const email = body.username?.toLowerCase();
const passwordHash = body.password; const passwordHash = body.password;
const twoFactorToken = body.twoFactorToken;
const twoFactorProvider = body.twoFactorProvider;
const twoFactorRemember = body.twoFactorRemember;
const loginIdentifier = getClientIdentifier(request); const loginIdentifier = getClientIdentifier(request);
const deviceInfo = readAuthRequestDeviceInfo(body, request);
if (!email || !passwordHash) { if (!email || !passwordHash) {
// Bitwarden clients expect OAuth-style error fields. // 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); const valid = await auth.verifyPassword(passwordHash, user.masterPasswordHash);
if (!valid) { if (!valid) {
// Record failed login attempt return recordFailedLoginAndBuildResponse(
const result = await rateLimit.recordFailedLogin(loginIdentifier); rateLimit,
if (result.locked) { loginIdentifier,
return identityErrorResponse( 'Username or password is incorrect. Try again'
`Too many failed login attempts. Account locked for ${Math.ceil(result.retryAfterSeconds! / 60)} minutes.`, );
'TooManyRequests', }
429
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 // Successful login - clear failed attempts
@@ -78,6 +168,7 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
expires_in: LIMITS.auth.accessTokenTtlSeconds, expires_in: LIMITS.auth.accessTokenTtlSeconds,
token_type: 'Bearer', token_type: 'Bearer',
refresh_token: refreshToken, refresh_token: refreshToken,
...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}),
Key: user.key, Key: user.key,
PrivateKey: user.privateKey, PrivateKey: user.privateKey,
Kdf: user.kdfType, Kdf: user.kdfType,
+2 -1
View File
@@ -3,6 +3,7 @@ import { StorageService } from '../services/storage';
import { errorResponse } from '../utils/response'; import { errorResponse } from '../utils/response';
import { cipherToResponse } from './ciphers'; import { cipherToResponse } from './ciphers';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
import { isTotpEnabled } from '../utils/totp';
interface SyncCacheEntry { interface SyncCacheEntry {
body: string; body: string;
@@ -73,7 +74,7 @@ export async function handleSync(request: Request, env: Env, userId: string): Pr
usesKeyConnector: false, usesKeyConnector: false,
masterPasswordHint: null, masterPasswordHint: null,
culture: 'en-US', culture: 'en-US',
twoFactorEnabled: false, twoFactorEnabled: isTotpEnabled(env.TOTP_SECRET),
key: user.key, key: user.key,
privateKey: user.privateKey, privateKey: user.privateKey,
accountKeys: null, accountKeys: null,
+55 -26
View File
@@ -3,39 +3,68 @@ import { handleRequest } from './router';
import { StorageService } from './services/storage'; import { StorageService } from './services/storage';
import { applyCors, jsonResponse } from './utils/response'; 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 dbInitialized = false;
let dbInitError: string | null = null; 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 { export default {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
// Auto-initialize database on first request void ctx;
if (!dbInitialized) { const requiresDatabase = !shouldSkipDatabaseInit(request);
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';
}
}
if (dbInitError) { if (requiresDatabase) {
const resp = jsonResponse( await ensureDatabaseInitialized(env);
{ if (dbInitError) {
error: 'Database not initialized', const resp = jsonResponse(
error_description: dbInitError, {
ErrorModel: { error: 'Database not initialized',
Message: dbInitError, error_description: dbInitError,
Object: 'error', ErrorModel: {
Message: dbInitError,
Object: 'error',
},
}, },
}, 500
500 );
); return applyCors(request, resp);
return applyCors(request, resp); }
} }
const resp = await handleRequest(request, env); const resp = await handleRequest(request, env);
+6 -9
View File
@@ -37,6 +37,7 @@ import { handleSync } from './handlers/sync';
// Setup handlers // Setup handlers
import { handleSetupPage, handleSetupStatus, handleDisableSetup } from './handlers/setup'; import { handleSetupPage, handleSetupStatus, handleDisableSetup } from './handlers/setup';
import { handleKnownDevice, handleGetDevices } from './handlers/devices';
// Import handler // Import handler
import { handleCiphersImport } from './handlers/import'; 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 }); return new Response(null, { status: 200 });
} }
// Known device check (no auth required) - returns plain string "true" or "false" // Known device check (no auth required)
if (path.startsWith('/api/devices/knowndevice')) { if (path === '/api/devices/knowndevice' && method === 'GET') {
return new Response('true', { return handleKnownDevice(request, env);
headers: {
'Content-Type': 'text/plain',
},
});
} }
// Identity endpoints (no auth required) // 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') { if (path === '/api/devices' && method === 'GET') {
return jsonResponse({ data: [], object: 'list', continuationToken: null }); return handleGetDevices(request, env, userId);
} }
// Not found // Not found
+185 -53
View File
@@ -1,6 +1,71 @@
import { User, Cipher, Folder, Attachment } from '../types'; import { User, Cipher, Folder, Attachment, Device } from '../types';
import { LIMITS } from '../config/limits'; 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. // D1-backed storage.
// Contract: // Contract:
// - All methods are scoped by userId where applicable. // - All methods are scoped by userId where applicable.
@@ -56,69 +121,49 @@ export class StorageService {
} }
// --- Database initialization --- // --- Database initialization ---
// One-click deploy requires zero manual migration steps. // Strategy:
// This method idempotently creates required schema objects on first request. // - 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> { async initializeDatabase(): Promise<void> {
if (StorageService.schemaVerified) return; if (StorageService.schemaVerified) return;
const schemaStatements = [ await this.db.prepare('PRAGMA foreign_keys = ON').run();
'PRAGMA foreign_keys = ON', 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 (' + if (current?.value !== schemaHash) {
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' + for (const stmt of SCHEMA_STATEMENTS) {
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + await this.executeSchemaStatement(stmt);
'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 (' + await this.db.prepare(
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' + 'INSERT INTO config(key, value) VALUES(?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value'
'FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE)', )
.bind(SCHEMA_HASH_CONFIG_KEY, schemaHash)
'CREATE TABLE IF NOT EXISTS ciphers (' + .run();
'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();
} }
StorageService.schemaVerified = true; 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 --- // --- Config / setup ---
async isRegistered(): Promise<boolean> { async isRegistered(): Promise<boolean> {
@@ -613,6 +658,93 @@ export class StorageService {
await this.db.prepare('DELETE FROM refresh_tokens WHERE token = ?').bind(tokenKey).run(); 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 --- // --- Revision dates ---
async getRevisionDate(userId: string): Promise<string> { async getRevisionDate(userId: string): Promise<string> {
+93 -16
View File
@@ -528,7 +528,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
</section> </section>
<section id="step4" class="step"> <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> <p class="lead" id="t_s4_desc"></p>
<div id="setup-form"> <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> <button type="submit" id="submitBtn" class="btn primary" style="width:100%;height:52px;">Create account</button>
</form> </form>
</div> </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 id="registered-view" style="display:none;">
<div class="kv"> <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="2"></span>
<span class="dot" data-step="3"></span> <span class="dot" data-step="3"></span>
<span class="dot" data-step="4"></span> <span class="dot" data-step="4"></span>
<span class="dot" data-step="5"></span>
<span class="dot" data-step="6"></span>
</div> </div>
<div class="flow-actions" style="justify-content:flex-end;"> <div class="flow-actions" style="justify-content:flex-end;">
<button id="nextBtn" class="btn primary" type="button">Next</button> <button id="nextBtn" class="btn primary" type="button">Next</button>
@@ -667,7 +698,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
copy: '复制', copy: '复制',
copied: '已复制', copied: '已复制',
s3Title: '同步策略(可跳过)', s3Title: '更新策略(可跳过)',
s3CommonTitle: '共同前置步骤', s3CommonTitle: '共同前置步骤',
s3Common1: '如果还没 fork,请先 fork 本项目到你自己的 GitHub。', s3Common1: '如果还没 fork,请先 fork 本项目到你自己的 GitHub。',
s3Common2: 'Cloudflare 控制台 → Workers 和 Pages → NodeWarden → 设置 → 构建 → Git 存储库 → 断开联机。', 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”。', s3AutoStep2: '点击 “I understand my workflows, go ahead and enable them”。',
s3AutoStep3: '默认每天凌晨 3 点自动同步;需要时可手动点 “Run workflow”。', s3AutoStep3: '默认每天凌晨 3 点自动同步;需要时可手动点 “Run workflow”。',
s4Title: '最终:创建账号', s4Title: '创建账号',
s4Desc: '填写信息并创建你的唯一账号,完成初始化。', s4Desc: '填写信息并创建你的唯一账号。创建成功后会进入登录 TOTP 教程。',
s5Title: '开启登录 TOTP2FA,可跳过)',
s5Desc: '这一页可跳过。如果你想开启登录二次验证,按下面步骤设置。',
s5EnableTitle: '服务端开启(Cloudflare Workers',
s5Enable1: '打开 Cloudflare 控制台 -> Workers 和 Pages -> NodeWarden -> 设置 -> 变量和机密。',
s5Enable2: '新增 SecretTOTP_SECRET,值填写 Base32 密钥 。',
s5Enable3: '保存并等待重新部署。删除 TOTP_SECRET 即关闭登录 2FA。',
s5ClientTitle: '客户端使用',
s5Client1: '下次登录时先输入主密码。',
s5Client2: '客户端会弹出 2FA 验证码输入框,输入验证器里的 6 位码。',
s5Client3: '如需可勾选记住此设备(30 天)。',
s6Title: '最终页面',
s6Desc: '最后一步:查看客户端使用地址,并可选择隐藏初始化页面。',
nameLabel: '昵称', nameLabel: '昵称',
emailLabel: '邮箱', emailLabel: '邮箱',
pwLabel: '主密码', pwLabel: '主密码',
@@ -769,8 +812,20 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
s3AutoStep2: 'Click “I understand my workflows, go ahead and enable them”.', 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”.', s3AutoStep3: 'It runs daily at 03:00 by default; you can also click “Run workflow”.',
s4Title: 'Final: create account', s4Title: 'Create account',
s4Desc: 'Create your single user account to finish setup.', 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', nameLabel: 'Name',
emailLabel: 'Email', emailLabel: 'Email',
pwLabel: 'Master password', pwLabel: 'Master password',
@@ -886,6 +941,18 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
setText('t_hide_title', t('hideTitle')); setText('t_hide_title', t('hideTitle'));
setText('t_hide_desc', t('hideDesc')); setText('t_hide_desc', t('hideDesc'));
setText('hideBtn', t('hideBtn')); 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('hideModalTitle', t('hideModalTitle'));
setText('hideModalDesc', t('hideModalDesc')); setText('hideModalDesc', t('hideModalDesc'));
setText('hideModalWarn', t('hideModalWarn')); setText('hideModalWarn', t('hideModalWarn'));
@@ -952,10 +1019,11 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
function goToStep(targetStep) { function goToStep(targetStep) {
// 安全限制:JWT_SECRET 不合规时,只允许访问第 1/2 步。 // 安全限制: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)); 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); const el = document.getElementById('step' + i);
if (el) el.classList.toggle('active', i === currentStep); if (el) el.classList.toggle('active', i === currentStep);
} }
@@ -971,7 +1039,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
const nextBtn = document.getElementById('nextBtn'); const nextBtn = document.getElementById('nextBtn');
if (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) { if (currentStep === 2 && !!JWT_STATE) {
nextBtn.disabled = true; nextBtn.disabled = true;
nextBtn.textContent = t('keyWaitRefresh'); nextBtn.textContent = t('keyWaitRefresh');
@@ -999,8 +1068,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
// 已注册但密钥不安全:只能停留在首页/密钥页,不能直接进入后续页面。 // 已注册但密钥不安全:只能停留在首页/密钥页,不能直接进入后续页面。
goToStep(2); goToStep(2);
} else { } else {
goToStep(4); goToStep(6);
showRegisteredView(); showFinalView();
} }
} }
} catch (e) { } catch (e) {
@@ -1008,8 +1077,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
} }
} }
function showRegisteredView() { function showFinalView() {
isRegistered = true;
const setupForm = document.getElementById('setup-form'); const setupForm = document.getElementById('setup-form');
const registeredView = document.getElementById('registered-view'); const registeredView = document.getElementById('registered-view');
const serverUrl = document.getElementById('serverUrl'); const serverUrl = document.getElementById('serverUrl');
@@ -1132,7 +1200,8 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
async function handleSubmit(event) { async function handleSubmit(event) {
event.preventDefault(); event.preventDefault();
if (isRegistered) { if (isRegistered) {
showRegisteredView(); goToStep(6);
showFinalView();
return; return;
} }
@@ -1197,7 +1266,9 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
const result = await response.json(); const result = await response.json();
if (response.ok && result.success) { if (response.ok && result.success) {
showRegisteredView(); isRegistered = true;
goToStep(5);
showFinalView();
} else { } else {
showMessage(result.error || (result.ErrorModel && result.ErrorModel.Message) || t('errRegisterFailed'), 'error'); showMessage(result.error || (result.ErrorModel && result.ErrorModel.Message) || t('errRegisterFailed'), 'error');
if (btn) { if (btn) {
@@ -1226,6 +1297,10 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
if (prevBtn) { if (prevBtn) {
prevBtn.addEventListener('click', () => { prevBtn.addEventListener('click', () => {
if (currentStep <= 1) return; if (currentStep <= 1) return;
if (isRegistered && currentStep === 5) {
goToStep(3);
return;
}
goToStep(currentStep - 1); goToStep(currentStep - 1);
}); });
} }
@@ -1235,7 +1310,9 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
nextBtn.addEventListener('click', () => { nextBtn.addEventListener('click', () => {
if (currentStep === 1) goToStep(2); if (currentStep === 1) goToStep(2);
else if (currentStep === 2) goToStep(3); 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);
}); });
} }
+11
View File
@@ -3,6 +3,7 @@ export interface Env {
DB: D1Database; DB: D1Database;
ATTACHMENTS: R2Bucket; ATTACHMENTS: R2Bucket;
JWT_SECRET: string; JWT_SECRET: string;
TOTP_SECRET?: string;
} }
// Sample JWT secret used by `.dev.vars.example`. // Sample JWT secret used by `.dev.vars.example`.
@@ -147,6 +148,15 @@ export interface Folder {
updatedAt: string; updatedAt: string;
} }
export interface Device {
userId: string;
deviceIdentifier: string;
name: string;
type: number;
createdAt: string;
updatedAt: string;
}
// JWT Payload // JWT Payload
export interface JWTPayload { export interface JWTPayload {
sub: string; // user id sub: string; // user id
@@ -190,6 +200,7 @@ export interface TokenResponse {
expires_in: number; expires_in: number;
token_type: string; token_type: string;
refresh_token: string; refresh_token: string;
TwoFactorToken?: string;
Key: string; Key: string;
PrivateKey: string | null; PrivateKey: string | null;
Kdf: number; Kdf: number;
+74
View File
@@ -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 -1
View File
@@ -1,7 +1,7 @@
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
const CORS_METHODS = 'GET, POST, PUT, DELETE, PATCH, OPTIONS'; 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 { function isTrustedClientOrigin(origin: string): boolean {
// Official browser extension / desktop-webview common origins. // Official browser extension / desktop-webview common origins.
+81
View File
@@ -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);
}