diff --git a/README.md b/README.md index 1cf2e54..7391dd5 100644 --- a/README.md +++ b/README.md @@ -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 天。 + --- ## 常见问题 diff --git a/README_EN.md b/README_EN.md index c97ae4d..6ff43b1 100644 --- a/README_EN.md +++ b/README_EN.md @@ -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 diff --git a/migrations/0001_init.sql b/migrations/0001_init.sql index 1cf76d0..1970926 100644 --- a/migrations/0001_init.sql +++ b/migrations/0001_init.sql @@ -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, diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index c80dcf4..bf445fb 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -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, diff --git a/src/handlers/devices.ts b/src/handlers/devices.ts new file mode 100644 index 0000000..9187ac4 --- /dev/null +++ b/src/handlers/devices.ts @@ -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 { + 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 { + 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, + }); +} + diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index 6f0cdb2..eb9e2d9 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -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 { + 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 { @@ -30,7 +72,11 @@ export async function handleToken(request: Request, env: Env): Promise // 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 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 expires_in: LIMITS.auth.accessTokenTtlSeconds, token_type: 'Bearer', refresh_token: refreshToken, + ...(trustedTwoFactorTokenToReturn ? { TwoFactorToken: trustedTwoFactorTokenToReturn } : {}), Key: user.key, PrivateKey: user.privateKey, Kdf: user.kdfType, diff --git a/src/handlers/sync.ts b/src/handlers/sync.ts index 0c4bb7b..6619171 100644 --- a/src/handlers/sync.ts +++ b/src/handlers/sync.ts @@ -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, diff --git a/src/index.ts b/src/index.ts index 2983a8d..0847301 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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 | 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 { + 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 { - // 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); diff --git a/src/router.ts b/src/router.ts index 5bb42d1..e5341df 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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 { 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 { + 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 { @@ -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 { + const digest = await this.sha256Hex(token); + return `sha256:${digest}`; + } + + // --- Devices --- + + async upsertDevice(userId: string, deviceIdentifier: string, name: string, type: number): Promise { + 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 { + 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 { + const user = await this.getUser(email); + if (!user) return false; + return this.isKnownDevice(user.id, deviceIdentifier); + } + + async getDevicesByUserId(userId: string): Promise { + 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(); + 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 { + 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 { + 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 { diff --git a/src/setup/pageTemplate.ts b/src/setup/pageTemplate.ts index 023fb5a..95ba026 100644 --- a/src/setup/pageTemplate.ts +++ b/src/setup/pageTemplate.ts @@ -528,7 +528,7 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
-

Final step

+

Create account

@@ -561,6 +561,35 @@ export function renderRegisterPageHTML(jwtState: JwtSecretState | null): string
+
+ +
+

Optional: login TOTP (2FA)

+

+ +
+

Enable on server (Cloudflare Workers)

+
    +
  1. +
  2. +
  3. +
+
+ +
+

Use in Bitwarden client

+
    +
  1. +
  2. +
  3. +
+
+ +
+ +
+

Final step

+