feat: add recovery code functionality and device management

This commit is contained in:
shuaiplus
2026-03-01 08:49:35 +08:00
committed by Shuai
parent 8852127743
commit 8641df3cff
15 changed files with 995 additions and 63 deletions
+100 -1
View File
@@ -5,6 +5,7 @@ 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, verifyTotpToken } from '../utils/totp'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
function looksLikeEncString(value: string): boolean { function looksLikeEncString(value: string): boolean {
if (!value) return false; if (!value) return false;
@@ -20,6 +21,10 @@ function normalizeTotpSecret(input: string): string {
return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, ''); return input.toUpperCase().replace(/[\s-]/g, '').replace(/=+$/g, '');
} }
function normalizeRecoveryCodeInput(input: string): string {
return String(input || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
}
function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null { function jwtSecretUnsafeReason(env: Env): 'missing' | 'default' | 'too_short' | null {
const secret = (env.JWT_SECRET || '').trim(); const secret = (env.JWT_SECRET || '').trim();
if (!secret) return 'missing'; if (!secret) return 'missing';
@@ -132,6 +137,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
role: 'user', role: 'user',
status: 'active', status: 'active',
totpSecret: null, totpSecret: null,
totpRecoveryCode: null,
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
@@ -375,10 +381,13 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
return errorResponse('Invalid TOTP token', 400); return errorResponse('Invalid TOTP token', 400);
} }
user.totpSecret = normalizedSecret; user.totpSecret = normalizedSecret;
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
}
user.updatedAt = new Date().toISOString(); user.updatedAt = new Date().toISOString();
await storage.saveUser(user); await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id); await storage.deleteRefreshTokensByUserId(user.id);
return jsonResponse({ enabled: true, object: 'twoFactor' }); return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
} }
if (body.enabled === false) { if (body.enabled === false) {
@@ -398,6 +407,96 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
return errorResponse('enabled must be true or false', 400); return errorResponse('enabled must be true or false', 400);
} }
// POST /api/accounts/totp/recovery-code
export async function handleGetTotpRecoveryCode(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404);
let body: Record<string, string | undefined>;
try {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries()) as Record<string, string>;
} else {
body = await request.json();
}
} catch {
return errorResponse('Invalid JSON', 400);
}
const currentHash = String(body.masterPasswordHash || body.master_password_hash || body.password || '').trim();
if (!currentHash) return errorResponse('masterPasswordHash is required', 400);
const valid = await auth.verifyPassword(currentHash, user.masterPasswordHash);
if (!valid) return errorResponse('Invalid password', 400);
if (!user.totpRecoveryCode) {
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
}
return jsonResponse({
code: user.totpRecoveryCode,
object: 'twoFactorRecover',
});
}
// POST /identity/accounts/recover-2fa
// Disable TOTP by recovery code + password, then rotate recovery code.
export async function handleRecoverTwoFactor(request: Request, env: Env): Promise<Response> {
const storage = new StorageService(env.DB);
const auth = new AuthService(env);
let body: Record<string, string | undefined>;
try {
const contentType = request.headers.get('content-type') || '';
if (contentType.includes('application/x-www-form-urlencoded')) {
const formData = await request.formData();
body = Object.fromEntries(formData.entries()) as Record<string, string>;
} else {
body = await request.json();
}
} catch {
return errorResponse('Invalid JSON', 400);
}
const email = String(body.email || body.username || '').trim().toLowerCase();
const masterPasswordHash = String(body.masterPasswordHash || body.password || '').trim();
const recoveryCode = normalizeRecoveryCodeInput(String(body.recoveryCode || body.twoFactorToken || body.recovery_code || ''));
if (!email || !masterPasswordHash || !recoveryCode) {
return errorResponse('Email, masterPasswordHash and recoveryCode are required', 400);
}
const user = await storage.getUser(email);
if (!user) return errorResponse('Invalid credentials', 400);
if (user.status !== 'active') return errorResponse('Account is disabled', 403);
const validPassword = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash);
if (!validPassword) return errorResponse('Invalid credentials', 400);
if (!recoveryCodeEquals(recoveryCode, user.totpRecoveryCode)) {
return errorResponse('Recovery code is incorrect. Try again.', 400);
}
user.totpSecret = null;
user.totpRecoveryCode = createRecoveryCode();
user.securityStamp = generateUUID();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
return jsonResponse({
success: true,
twoFactorEnabled: false,
newRecoveryCode: user.totpRecoveryCode,
object: 'twoFactorRecovery',
});
}
// GET /api/accounts/revision-date // GET /api/accounts/revision-date
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> { export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
void request; void request;
+98 -1
View File
@@ -1,6 +1,6 @@
import { Env } from '../types'; import { Env } from '../types';
import { StorageService } from '../services/storage'; import { StorageService } from '../services/storage';
import { jsonResponse } from '../utils/response'; import { errorResponse, jsonResponse } from '../utils/response';
import { readKnownDeviceProbe } from '../utils/device'; import { readKnownDeviceProbe } from '../utils/device';
// GET /api/devices/knowndevice // GET /api/devices/knowndevice
@@ -40,6 +40,103 @@ export async function handleGetDevices(request: Request, env: Env, userId: strin
}); });
} }
// GET /api/devices/authorized
// Returns known devices together with active 2FA remember-token expiry.
export async function handleGetAuthorizedDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const [devices, trusted] = await Promise.all([
storage.getDevicesByUserId(userId),
storage.getTrustedDeviceTokenSummariesByUserId(userId),
]);
const trustedByIdentifier = new Map<string, { expiresAt: number; tokenCount: number }>();
for (const row of trusted) {
trustedByIdentifier.set(row.deviceIdentifier, { expiresAt: row.expiresAt, tokenCount: row.tokenCount });
}
const knownIdentifiers = new Set<string>();
const data = devices.map(device => {
knownIdentifiers.add(device.deviceIdentifier);
const trustedInfo = trustedByIdentifier.get(device.deviceIdentifier);
return {
id: device.deviceIdentifier,
name: device.name,
identifier: device.deviceIdentifier,
type: device.type,
creationDate: device.createdAt,
revisionDate: device.updatedAt,
trusted: !!trustedInfo,
trustedTokenCount: trustedInfo?.tokenCount || 0,
trustedUntil: trustedInfo?.expiresAt ? new Date(trustedInfo.expiresAt).toISOString() : null,
object: 'device',
};
});
for (const row of trusted) {
if (knownIdentifiers.has(row.deviceIdentifier)) continue;
data.push({
id: row.deviceIdentifier,
name: 'Unknown device',
identifier: row.deviceIdentifier,
type: 14,
creationDate: '',
revisionDate: '',
trusted: true,
trustedTokenCount: row.tokenCount,
trustedUntil: row.expiresAt ? new Date(row.expiresAt).toISOString() : null,
object: 'device',
});
}
return jsonResponse({
data,
object: 'list',
continuationToken: null,
});
}
// DELETE /api/devices/authorized
export async function handleRevokeAllTrustedDevices(request: Request, env: Env, userId: string): Promise<Response> {
void request;
const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByUserId(userId);
return jsonResponse({ success: true, removed });
}
// DELETE /api/devices/authorized/:deviceIdentifier
export async function handleRevokeTrustedDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
const removed = await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
return jsonResponse({ success: true, removed });
}
// DELETE /api/devices/:deviceIdentifier
export async function handleDeleteDevice(
request: Request,
env: Env,
userId: string,
deviceIdentifier: string
): Promise<Response> {
void request;
const normalized = String(deviceIdentifier || '').trim();
if (!normalized) return errorResponse('Invalid device identifier', 400);
const storage = new StorageService(env.DB);
await storage.deleteTrustedTwoFactorTokensByDevice(userId, normalized);
const deleted = await storage.deleteDevice(userId, normalized);
return jsonResponse({ success: deleted });
}
// PUT /api/devices/identifier/{deviceIdentifier}/token // PUT /api/devices/identifier/{deviceIdentifier}/token
// Bitwarden mobile reports push token updates to this endpoint. // Bitwarden mobile reports push token updates to this endpoint.
// NodeWarden does not implement push notifications, so accept and no-op. // NodeWarden does not implement push notifications, so accept and no-op.
+26 -9
View File
@@ -7,11 +7,13 @@ import { LIMITS } from '../config/limits';
import { isTotpEnabled, verifyTotpToken } from '../utils/totp'; import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
import { createRefreshToken } from '../utils/jwt'; import { createRefreshToken } from '../utils/jwt';
import { readAuthRequestDeviceInfo } from '../utils/device'; import { readAuthRequestDeviceInfo } from '../utils/device';
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
import { issueSendAccessToken } from './sends'; import { issueSendAccessToken } from './sends';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0; const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
const TWO_FACTOR_PROVIDER_REMEMBER = 5; const TWO_FACTOR_PROVIDER_REMEMBER = 5;
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
function resolveTotpSecret(userSecret: string | null, envSecret: string | undefined): string | null { function resolveTotpSecret(userSecret: string | null, envSecret: string | undefined): string | null {
if (userSecret && isTotpEnabled(userSecret)) { if (userSecret && isTotpEnabled(userSecret)) {
@@ -23,16 +25,20 @@ function resolveTotpSecret(userSecret: string | null, envSecret: string | undefi
return null; return null;
} }
function twoFactorRequiredResponse(message: string = 'Two factor required.'): Response { function twoFactorRequiredResponse(message: string = 'Two factor required.', includeRecoveryCode: boolean = false): Response {
const providers = includeRecoveryCode
? [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR), String(TWO_FACTOR_PROVIDER_RECOVERY_CODE)]
: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)];
const providers2: Record<string, null> = {};
for (const provider of providers) providers2[provider] = null;
// Bitwarden clients rely on these fields to trigger the 2FA UI flow. // Bitwarden clients rely on these fields to trigger the 2FA UI flow.
return jsonResponse( return jsonResponse(
{ {
error: 'invalid_grant', error: 'invalid_grant',
error_description: message, error_description: message,
TwoFactorProviders: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)], TwoFactorProviders: providers,
TwoFactorProviders2: { TwoFactorProviders2: providers2,
[String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)]: null,
},
// Required by current Android parser (nullable value is acceptable). // Required by current Android parser (nullable value is acceptable).
SsoEmail2faSessionToken: null, SsoEmail2faSessionToken: null,
// Keep payload shape close to upstream implementations. // Keep payload shape close to upstream implementations.
@@ -148,21 +154,22 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
let trustedTwoFactorTokenToReturn: string | undefined; let trustedTwoFactorTokenToReturn: string | undefined;
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret, env.TOTP_SECRET); const effectiveTotpSecret = resolveTotpSecret(user.totpSecret, env.TOTP_SECRET);
if (effectiveTotpSecret) { if (effectiveTotpSecret) {
const canUseRecoveryCode = !!user.totpRecoveryCode;
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim(); const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').trim();
const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim(); const normalizedTwoFactorToken = String(twoFactorToken ?? '').trim();
const rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim()); let rememberRequested = ['1', 'true', 'True', 'TRUE', 'on', 'yes', 'Yes', 'YES'].includes(String(twoFactorRemember || '').trim());
const hasProvider = normalizedTwoFactorProvider.length > 0; const hasProvider = normalizedTwoFactorProvider.length > 0;
const hasToken = normalizedTwoFactorToken.length > 0; const hasToken = normalizedTwoFactorToken.length > 0;
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing, // Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
// respond with a 2FA challenge payload. // respond with a 2FA challenge payload.
if (!hasProvider || !hasToken) { if (!hasProvider || !hasToken) {
return twoFactorRequiredResponse(); return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
} }
const parsedProvider = Number.parseInt(normalizedTwoFactorProvider, 10); const parsedProvider = Number.parseInt(normalizedTwoFactorProvider, 10);
if (!Number.isFinite(parsedProvider)) { if (!Number.isFinite(parsedProvider)) {
return twoFactorRequiredResponse(); return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
} }
let passedByRememberToken = false; let passedByRememberToken = false;
@@ -177,13 +184,23 @@ export async function handleToken(request: Request, env: Env): Promise<Response>
// Remember token missing/invalid/expired should re-enter the 2FA challenge flow. // Remember token missing/invalid/expired should re-enter the 2FA challenge flow.
if (!passedByRememberToken) { if (!passedByRememberToken) {
return twoFactorRequiredResponse(); return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
} }
} else if (parsedProvider === TWO_FACTOR_PROVIDER_AUTHENTICATOR) { } else if (parsedProvider === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken); const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
if (!totpOk) { if (!totpOk) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
} }
} else if (parsedProvider === TWO_FACTOR_PROVIDER_RECOVERY_CODE) {
if (!recoveryCodeEquals(normalizedTwoFactorToken, user.totpRecoveryCode)) {
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
}
user.totpSecret = null;
user.totpRecoveryCode = createRecoveryCode();
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await storage.deleteRefreshTokensByUserId(user.id);
rememberRequested = false;
} else { } else {
// Unsupported provider for this server profile behaves as an invalid 2FA attempt. // Unsupported provider for this server profile behaves as an invalid 2FA attempt.
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier); return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
+36 -1
View File
@@ -19,6 +19,8 @@ import {
handleChangePassword, handleChangePassword,
handleGetTotpStatus, handleGetTotpStatus,
handleSetTotpStatus, handleSetTotpStatus,
handleGetTotpRecoveryCode,
handleRecoverTwoFactor,
} from './handlers/accounts'; } from './handlers/accounts';
// Cipher handlers // Cipher handlers
@@ -68,7 +70,15 @@ import { handleSync } from './handlers/sync';
// Setup handlers // Setup handlers
import { handleSetupStatus } from './handlers/setup'; import { handleSetupStatus } from './handlers/setup';
import { handleKnownDevice, handleGetDevices, handleUpdateDeviceToken } from './handlers/devices'; import {
handleKnownDevice,
handleGetAuthorizedDevices,
handleGetDevices,
handleRevokeAllTrustedDevices,
handleRevokeTrustedDevice,
handleDeleteDevice,
handleUpdateDeviceToken
} from './handlers/devices';
// Import handler // Import handler
import { handleCiphersImport } from './handlers/import'; import { handleCiphersImport } from './handlers/import';
@@ -310,6 +320,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handlePrelogin(request, env); return handlePrelogin(request, env);
} }
if ((path === '/identity/accounts/recover-2fa' || path === '/api/accounts/recover-2fa') && method === 'POST') {
return handleRecoverTwoFactor(request, env);
}
// Config endpoint (no auth required for basic config) // Config endpoint (no auth required for basic config)
// Bitwarden clients call GET "/config" (relative to the API base URL). // Bitwarden clients call GET "/config" (relative to the API base URL).
// They also tolerate different casing, but their response models use PascalCase. // They also tolerate different casing, but their response models use PascalCase.
@@ -467,6 +481,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId); if (method === 'PUT' || method === 'POST') return handleSetTotpStatus(request, env, userId);
} }
if ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
return handleGetTotpRecoveryCode(request, env, userId);
}
// Revision date endpoint // Revision date endpoint
if (path === '/api/accounts/revision-date' && method === 'GET') { if (path === '/api/accounts/revision-date' && method === 'GET') {
return handleGetRevisionDate(request, env, userId); return handleGetRevisionDate(request, env, userId);
@@ -666,6 +684,23 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
return handleGetDevices(request, env, userId); return handleGetDevices(request, env, userId);
} }
if (path === '/api/devices/authorized') {
if (method === 'GET') return handleGetAuthorizedDevices(request, env, userId);
if (method === 'DELETE') return handleRevokeAllTrustedDevices(request, env, userId);
}
const authorizedDeviceMatch = path.match(/^\/api\/devices\/authorized\/([^/]+)$/i);
if (authorizedDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(authorizedDeviceMatch[1]);
return handleRevokeTrustedDevice(request, env, userId, deviceIdentifier);
}
const deleteDeviceMatch = path.match(/^\/api\/devices\/([^/]+)$/i);
if (deleteDeviceMatch && method === 'DELETE') {
const deviceIdentifier = decodeURIComponent(deleteDeviceMatch[1]);
return handleDeleteDevice(request, env, userId, deviceIdentifier);
}
// Admin endpoints // Admin endpoints
if (path === '/api/admin/users' && method === 'GET') { if (path === '/api/admin/users' && method === 'GET') {
return handleAdminListUsers(request, env, currentUser); return handleAdminListUsers(request, env, currentUser);
+57 -10
View File
@@ -1,4 +1,4 @@
import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType } from '../types'; import { User, Cipher, Folder, Attachment, Device, Invite, AuditLog, Send, SendAuthType, TrustedDeviceTokenSummary } from '../types';
import { LIMITS } from '../config/limits'; import { LIMITS } from '../config/limits';
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000; const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
@@ -11,10 +11,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hash TEXT NOT NULL, ' + '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, ' + '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, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', totp_secret TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)', 'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', totp_secret TEXT, totp_recovery_code TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'CREATE TABLE IF NOT EXISTS user_revisions (' + 'CREATE TABLE IF NOT EXISTS user_revisions (' +
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' + 'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
@@ -224,6 +225,7 @@ export class StorageService {
role: row.role === 'admin' ? 'admin' : 'user', role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active', status: row.status === 'banned' ? 'banned' : 'active',
totpSecret: row.totp_secret ?? null, totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null,
createdAt: row.created_at, createdAt: row.created_at,
updatedAt: row.updated_at, updatedAt: row.updated_at,
}; };
@@ -232,7 +234,7 @@ export class StorageService {
async getUser(email: string): Promise<User | null> { async getUser(email: string): Promise<User | null> {
const row = await this.db const row = await this.db
.prepare( .prepare(
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at FROM users WHERE email = ?' 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE email = ?'
) )
.bind(email.toLowerCase()) .bind(email.toLowerCase())
.first<any>(); .first<any>();
@@ -243,7 +245,7 @@ export class StorageService {
async getUserById(id: string): Promise<User | null> { async getUserById(id: string): Promise<User | null> {
const row = await this.db const row = await this.db
.prepare( .prepare(
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at FROM users WHERE id = ?' 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users WHERE id = ?'
) )
.bind(id) .bind(id)
.first<any>(); .first<any>();
@@ -259,7 +261,7 @@ export class StorageService {
async getAllUsers(): Promise<User[]> { async getAllUsers(): Promise<User[]> {
const res = await this.db const res = await this.db
.prepare( .prepare(
'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at FROM users ORDER BY created_at ASC' 'SELECT id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at FROM users ORDER BY created_at ASC'
) )
.all<any>(); .all<any>();
return (res.results || []).map(row => this.mapUserRow(row)); return (res.results || []).map(row => this.mapUserRow(row));
@@ -268,11 +270,11 @@ export class StorageService {
async saveUser(user: User): Promise<void> { async saveUser(user: User): Promise<void> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = this.db.prepare( const stmt = this.db.prepare(
'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at) ' + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' + 'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
'ON CONFLICT(id) DO UPDATE SET ' + 'ON CONFLICT(id) DO UPDATE SET ' +
'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' + 'email=excluded.email, name=excluded.name, master_password_hash=excluded.master_password_hash, key=excluded.key, private_key=excluded.private_key, public_key=excluded.public_key, ' +
'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, updated_at=excluded.updated_at' 'kdf_type=excluded.kdf_type, kdf_iterations=excluded.kdf_iterations, kdf_memory=excluded.kdf_memory, kdf_parallelism=excluded.kdf_parallelism, security_stamp=excluded.security_stamp, role=excluded.role, status=excluded.status, totp_secret=excluded.totp_secret, totp_recovery_code=excluded.totp_recovery_code, updated_at=excluded.updated_at'
); );
await this.safeBind(stmt, await this.safeBind(stmt,
user.id, user.id,
@@ -290,6 +292,7 @@ export class StorageService {
user.role, user.role,
user.status, user.status,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode,
user.createdAt, user.createdAt,
user.updatedAt user.updatedAt
).run(); ).run();
@@ -302,8 +305,8 @@ export class StorageService {
async createFirstUser(user: User): Promise<boolean> { async createFirstUser(user: User): Promise<boolean> {
const email = user.email.toLowerCase(); const email = user.email.toLowerCase();
const stmt = this.db.prepare( const stmt = this.db.prepare(
'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, created_at, updated_at) ' + 'INSERT INTO users(id, email, name, master_password_hash, key, private_key, public_key, kdf_type, kdf_iterations, kdf_memory, kdf_parallelism, security_stamp, role, status, totp_secret, totp_recovery_code, created_at, updated_at) ' +
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' + 'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)' 'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
); );
const result = await this.safeBind(stmt, const result = await this.safeBind(stmt,
@@ -322,6 +325,7 @@ export class StorageService {
user.role, user.role,
user.status, user.status,
user.totpSecret, user.totpSecret,
user.totpRecoveryCode,
user.createdAt, user.createdAt,
user.updatedAt user.updatedAt
).run(); ).run();
@@ -950,6 +954,49 @@ export class StorageService {
})); }));
} }
async deleteDevice(userId: string, deviceIdentifier: string): Promise<boolean> {
const result = await this.db
.prepare('DELETE FROM devices WHERE user_id = ? AND device_identifier = ?')
.bind(userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0) > 0;
}
async getTrustedDeviceTokenSummariesByUserId(userId: string): Promise<TrustedDeviceTokenSummary[]> {
const now = Date.now();
await this.db.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE expires_at < ?').bind(now).run();
const res = await this.db
.prepare(
'SELECT device_identifier, MAX(expires_at) AS expires_at, COUNT(*) AS token_count ' +
'FROM trusted_two_factor_device_tokens WHERE user_id = ? GROUP BY device_identifier ORDER BY expires_at DESC'
)
.bind(userId)
.all<any>();
return (res.results || []).map(row => ({
deviceIdentifier: row.device_identifier,
expiresAt: Number(row.expires_at || 0),
tokenCount: Number(row.token_count || 0),
}));
}
async deleteTrustedTwoFactorTokensByDevice(userId: string, deviceIdentifier: string): Promise<number> {
const result = await this.db
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ? AND device_identifier = ?')
.bind(userId, deviceIdentifier)
.run();
return Number(result.meta.changes ?? 0);
}
async deleteTrustedTwoFactorTokensByUserId(userId: string): Promise<number> {
const result = await this.db
.prepare('DELETE FROM trusted_two_factor_device_tokens WHERE user_id = ?')
.bind(userId)
.run();
return Number(result.meta.changes ?? 0);
}
// --- Trusted 2FA remember tokens (device-bound) --- // --- Trusted 2FA remember tokens (device-bound) ---
async saveTrustedTwoFactorDeviceToken( async saveTrustedTwoFactorDeviceToken(
+7
View File
@@ -40,6 +40,7 @@ export interface User {
role: UserRole; role: UserRole;
status: UserStatus; status: UserStatus;
totpSecret: string | null; totpSecret: string | null;
totpRecoveryCode: string | null;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
} }
@@ -183,6 +184,12 @@ export interface Device {
updatedAt: string; updatedAt: string;
} }
export interface TrustedDeviceTokenSummary {
deviceIdentifier: string;
expiresAt: number;
tokenCount: number;
}
export enum SendType { export enum SendType {
Text = 0, Text = 0,
File = 1, File = 1,
+28
View File
@@ -0,0 +1,28 @@
const RECOVERY_ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
function normalizeRecoveryCode(raw: string): string {
return String(raw || '').toUpperCase().replace(/[^A-Z2-7]/g, '');
}
function formatRecoveryCode(compact: string): string {
return compact.replace(/(.{4})/g, '$1 ').trim();
}
export function createRecoveryCode(): string {
const bytes = crypto.getRandomValues(new Uint8Array(20));
let compact = '';
for (const b of bytes) {
compact += RECOVERY_ALPHABET[b % RECOVERY_ALPHABET.length];
}
// 20 bytes -> 20 chars in this simple mapping. Expand to 32 chars for friendlier grouping.
while (compact.length < 32) {
const extra = crypto.getRandomValues(new Uint8Array(1))[0];
compact += RECOVERY_ALPHABET[extra % RECOVERY_ALPHABET.length];
}
return formatRecoveryCode(compact.slice(0, 32));
}
export function recoveryCodeEquals(input: string, storedCode: string | null | undefined): boolean {
if (!storedCode) return false;
return normalizeRecoveryCode(input) === normalizeRecoveryCode(storedCode);
}
+169 -7
View File
@@ -8,7 +8,9 @@ import ToastHost from '@/components/ToastHost';
import VaultPage from '@/components/VaultPage'; import VaultPage from '@/components/VaultPage';
import SendsPage from '@/components/SendsPage'; import SendsPage from '@/components/SendsPage';
import PublicSendPage from '@/components/PublicSendPage'; import PublicSendPage from '@/components/PublicSendPage';
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
import SettingsPage from '@/components/SettingsPage'; import SettingsPage from '@/components/SettingsPage';
import SecurityDevicesPage from '@/components/SecurityDevicesPage';
import AdminPage from '@/components/AdminPage'; import AdminPage from '@/components/AdminPage';
import HelpPage from '@/components/HelpPage'; import HelpPage from '@/components/HelpPage';
import { import {
@@ -27,19 +29,25 @@ import {
getCiphers, getCiphers,
getFolders, getFolders,
getProfile, getProfile,
getAuthorizedDevices,
getSetupStatus, getSetupStatus,
getSends, getSends,
getTotpStatus, getTotpStatus,
getTotpRecoveryCode,
getWebConfig, getWebConfig,
listAdminInvites, listAdminInvites,
listAdminUsers, listAdminUsers,
loadSession, loadSession,
loginWithPassword, loginWithPassword,
registerAccount, registerAccount,
recoverTwoFactor,
revokeInvite, revokeInvite,
revokeAuthorizedDeviceTrust,
revokeAllAuthorizedDeviceTrust,
saveSession, saveSession,
setTotp, setTotp,
setUserStatus, setUserStatus,
deleteAuthorizedDevice,
updateCipher, updateCipher,
updateSend, updateSend,
buildSendShareKey, buildSendShareKey,
@@ -48,7 +56,7 @@ import {
verifyMasterPassword, verifyMasterPassword,
} from '@/lib/api'; } from '@/lib/api';
import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto'; import { base64ToBytes, decryptBw, decryptStr, hkdf } from '@/lib/crypto';
import type { AppPhase, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types'; import type { AppPhase, AuthorizedDevice, Cipher, Folder, Profile, Send, SendDraft, SessionState, ToastMessage, VaultDraft } from '@/lib/types';
interface PendingTotp { interface PendingTotp {
email: string; email: string;
@@ -90,9 +98,11 @@ export default function App() {
const [unlockPassword, setUnlockPassword] = useState(''); const [unlockPassword, setUnlockPassword] = useState('');
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null); const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
const [totpCode, setTotpCode] = useState(''); const [totpCode, setTotpCode] = useState('');
const [rememberDevice, setRememberDevice] = useState(true);
const [disableTotpOpen, setDisableTotpOpen] = useState(false); const [disableTotpOpen, setDisableTotpOpen] = useState(false);
const [disableTotpPassword, setDisableTotpPassword] = useState(''); const [disableTotpPassword, setDisableTotpPassword] = useState('');
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [confirm, setConfirm] = useState<{ const [confirm, setConfirm] = useState<{
title: string; title: string;
@@ -201,7 +211,7 @@ export default function App() {
} }
try { try {
const derived = await deriveLoginHash(loginValues.email, loginValues.password, defaultKdfIterations); const derived = await deriveLoginHash(loginValues.email, loginValues.password, defaultKdfIterations);
const token = await loginWithPassword(loginValues.email, derived.hash); const token = await loginWithPassword(loginValues.email, derived.hash, { useRememberToken: true });
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
await finalizeLogin(token.access_token, token.refresh_token, loginValues.email.toLowerCase(), derived.masterKey); await finalizeLogin(token.access_token, token.refresh_token, loginValues.email.toLowerCase(), derived.masterKey);
return; return;
@@ -214,6 +224,7 @@ export default function App() {
masterKey: derived.masterKey, masterKey: derived.masterKey,
}); });
setTotpCode(''); setTotpCode('');
setRememberDevice(true);
return; return;
} }
pushToast('error', tokenError.error_description || tokenError.error || 'Login failed'); pushToast('error', tokenError.error_description || tokenError.error || 'Login failed');
@@ -228,7 +239,10 @@ export default function App() {
pushToast('error', 'Please input TOTP code'); pushToast('error', 'Please input TOTP code');
return; return;
} }
const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, totpCode.trim()); const token = await loginWithPassword(pendingTotp.email, pendingTotp.passwordHash, {
totpCode: totpCode.trim(),
rememberDevice,
});
if ('access_token' in token && token.access_token) { if ('access_token' in token && token.access_token) {
await finalizeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey); await finalizeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey);
return; return;
@@ -237,6 +251,34 @@ export default function App() {
pushToast('error', tokenError.error_description || tokenError.error || 'TOTP verify failed'); pushToast('error', tokenError.error_description || tokenError.error || 'TOTP verify failed');
} }
async function handleRecoverTwoFactorSubmit() {
const email = recoverValues.email.trim().toLowerCase();
const password = recoverValues.password;
const recoveryCode = recoverValues.recoveryCode.trim();
if (!email || !password || !recoveryCode) {
pushToast('error', 'Email, password and recovery code are required');
return;
}
try {
const derived = await deriveLoginHash(email, password, defaultKdfIterations);
const recovered = await recoverTwoFactor(email, derived.hash, recoveryCode);
const token = await loginWithPassword(email, derived.hash, { useRememberToken: false });
if ('access_token' in token && token.access_token) {
await finalizeLogin(token.access_token, token.refresh_token, email, derived.masterKey);
if (recovered.newRecoveryCode) {
pushToast('success', `2FA recovered. New recovery code: ${recovered.newRecoveryCode}`);
} else {
pushToast('success', '2FA recovered');
}
return;
}
pushToast('error', 'Recovered but auto-login failed, please sign in.');
navigate('/login');
} catch (error) {
pushToast('error', error instanceof Error ? error.message : 'Recover 2FA failed');
}
}
async function handleRegister() { async function handleRegister() {
if (!registerValues.email || !registerValues.password) { if (!registerValues.email || !registerValues.password) {
pushToast('error', 'Please input email and password'); pushToast('error', 'Please input email and password');
@@ -345,6 +387,11 @@ export default function App() {
queryFn: () => getTotpStatus(authedFetch), queryFn: () => getTotpStatus(authedFetch),
enabled: phase === 'app' && !!session?.accessToken, enabled: phase === 'app' && !!session?.accessToken,
}); });
const authorizedDevicesQuery = useQuery({
queryKey: ['authorized-devices', session?.accessToken],
queryFn: () => getAuthorizedDevices(authedFetch),
enabled: phase === 'app' && !!session?.accessToken,
});
useEffect(() => { useEffect(() => {
if (!session?.symEncKey || !session?.symMacKey) { if (!session?.symEncKey || !session?.symMacKey) {
@@ -592,6 +639,28 @@ export default function App() {
pushToast('success', 'Vault synced'); pushToast('success', 'Vault synced');
} }
async function refreshAuthorizedDevices() {
await authorizedDevicesQuery.refetch();
}
async function revokeDeviceTrustAction(device: AuthorizedDevice) {
await revokeAuthorizedDeviceTrust(authedFetch, device.identifier);
await authorizedDevicesQuery.refetch();
pushToast('success', 'Device authorization revoked');
}
async function revokeAllDeviceTrustAction() {
await revokeAllAuthorizedDeviceTrust(authedFetch);
await authorizedDevicesQuery.refetch();
pushToast('success', 'All device authorizations revoked');
}
async function removeDeviceAction(device: AuthorizedDevice) {
await deleteAuthorizedDevice(authedFetch, device.identifier);
await authorizedDevicesQuery.refetch();
pushToast('success', 'Device removed');
}
async function createVaultItem(draft: VaultDraft) { async function createVaultItem(draft: VaultDraft) {
if (!session) return; if (!session) return;
try { try {
@@ -651,6 +720,16 @@ export default function App() {
} }
} }
async function getRecoveryCodeAction(masterPassword: string): Promise<string> {
if (!profile) throw new Error('Profile unavailable');
const normalized = String(masterPassword || '');
if (!normalized) throw new Error('Master password is required');
const derived = await deriveLoginHash(profile.email, normalized, defaultKdfIterations);
const code = await getTotpRecoveryCode(authedFetch, derived.hash);
if (!code) throw new Error('Recovery code is empty');
return code;
}
async function createSendItem(draft: SendDraft, autoCopyLink: boolean) { async function createSendItem(draft: SendDraft, autoCopyLink: boolean) {
if (!session) return; if (!session) return;
try { try {
@@ -732,8 +811,9 @@ export default function App() {
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : ''; const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw; const hashPath = hashPathRaw.startsWith('#') ? hashPathRaw.slice(1) : hashPathRaw;
const effectiveLocation = hashPath.startsWith('/send/') ? hashPath : location; const effectiveLocation = hashPath.startsWith('/send/') || hashPath === '/recover-2fa' ? hashPath : location;
const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i); const publicSendMatch = effectiveLocation.match(/^\/send\/([^/]+)(?:\/([^/]+))?\/?$/i);
const isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
const isPublicSendRoute = !!publicSendMatch; const isPublicSendRoute = !!publicSendMatch;
useEffect(() => { useEffect(() => {
@@ -749,6 +829,23 @@ export default function App() {
); );
} }
if (isRecoverTwoFactorRoute && phase !== 'app') {
return (
<>
<RecoverTwoFactorPage
values={recoverValues}
onChange={setRecoverValues}
onSubmit={() => void handleRecoverTwoFactorSubmit()}
onCancel={() => {
setRecoverValues({ email: '', password: '', recoveryCode: '' });
navigate('/login');
}}
/>
<ToastHost toasts={toasts} onClose={(id) => setToasts((prev) => prev.filter((x) => x.id !== id))} />
</>
);
}
if (phase === 'loading') { if (phase === 'loading') {
return ( return (
<> <>
@@ -790,12 +887,34 @@ export default function App() {
onCancel={() => { onCancel={() => {
setPendingTotp(null); setPendingTotp(null);
setTotpCode(''); setTotpCode('');
setRememberDevice(true);
}} }}
afterActions={(
<div className="dialog-extra">
<div className="dialog-divider" />
<button
type="button"
className="btn btn-secondary dialog-btn"
onClick={() => {
setPendingTotp(null);
setTotpCode('');
setRememberDevice(true);
navigate('/recover-2fa');
}}
>
Use Recovery Code
</button>
</div>
)}
> >
<label className="field"> <label className="field">
<span>TOTP Code</span> <span>TOTP Code</span>
<input className="input" value={totpCode} onInput={(e) => setTotpCode((e.currentTarget as HTMLInputElement).value)} /> <input className="input" value={totpCode} onInput={(e) => setTotpCode((e.currentTarget as HTMLInputElement).value)} />
</label> </label>
<label className="check-line" style={{ marginBottom: 0 }}>
<input type="checkbox" checked={rememberDevice} onChange={(e) => setRememberDevice((e.currentTarget as HTMLInputElement).checked)} />
<span>Trust this device for 30 days</span>
</label>
</ConfirmDialog> </ConfirmDialog>
</> </>
); );
@@ -815,9 +934,6 @@ export default function App() {
<ShieldUser size={16} /> <ShieldUser size={16} />
<span>{profile?.email}</span> <span>{profile?.email}</span>
</div> </div>
<button type="button" className="btn btn-secondary small" onClick={() => navigate('/settings')}>
<Shield size={14} className="btn-icon" /> Account Security
</button>
<button type="button" className="btn btn-secondary small" onClick={handleLogout}> <button type="button" className="btn btn-secondary small" onClick={handleLogout}>
<LogOut size={14} className="btn-icon" /> Sign Out <LogOut size={14} className="btn-icon" /> Sign Out
</button> </button>
@@ -844,6 +960,10 @@ export default function App() {
<SettingsIcon size={16} /> <SettingsIcon size={16} />
<span>System Settings</span> <span>System Settings</span>
</Link> </Link>
<Link href="/security/devices" className={`side-link ${location === '/security/devices' ? 'active' : ''}`}>
<Shield size={16} />
<span>Account Security</span>
</Link>
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}> <Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
<CircleHelp size={16} /> <CircleHelp size={16} />
<span>Support Center</span> <span>Support Center</span>
@@ -892,9 +1012,51 @@ export default function App() {
await totpStatusQuery.refetch(); await totpStatusQuery.refetch();
}} }}
onOpenDisableTotp={() => setDisableTotpOpen(true)} onOpenDisableTotp={() => setDisableTotpOpen(true)}
onGetRecoveryCode={getRecoveryCodeAction}
onNotify={pushToast}
/> />
)} )}
</Route> </Route>
<Route path="/security/devices">
<SecurityDevicesPage
devices={authorizedDevicesQuery.data || []}
loading={authorizedDevicesQuery.isFetching}
onRefresh={() => void refreshAuthorizedDevices()}
onRevokeTrust={(device) => {
setConfirm({
title: 'Revoke device authorization',
message: `Revoke 30-day TOTP trust for "${device.name}"?`,
danger: true,
onConfirm: () => {
setConfirm(null);
void revokeDeviceTrustAction(device);
},
});
}}
onRemoveDevice={(device) => {
setConfirm({
title: 'Remove device',
message: `Remove device "${device.name}" and clear its 2FA trust?`,
danger: true,
onConfirm: () => {
setConfirm(null);
void removeDeviceAction(device);
},
});
}}
onRevokeAll={() => {
setConfirm({
title: 'Revoke all trusted devices',
message: 'Revoke 30-day TOTP trust from all devices?',
danger: true,
onConfirm: () => {
setConfirm(null);
void revokeAllDeviceTrustAction();
},
});
}}
/>
</Route>
<Route path="/admin"> <Route path="/admin">
<AdminPage <AdminPage
currentUserId={profile?.id || ''} currentUserId={profile?.id || ''}
+2
View File
@@ -11,6 +11,7 @@ interface ConfirmDialogProps {
onConfirm: () => void; onConfirm: () => void;
onCancel: () => void; onCancel: () => void;
children?: ComponentChildren; children?: ComponentChildren;
afterActions?: ComponentChildren;
} }
export default function ConfirmDialog(props: ConfirmDialogProps) { export default function ConfirmDialog(props: ConfirmDialogProps) {
@@ -31,6 +32,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}> <button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
{props.cancelText || 'No'} {props.cancelText || 'No'}
</button> </button>
{props.afterActions}
</div> </div>
</div> </div>
); );
@@ -0,0 +1,65 @@
import { useState } from 'preact/hooks';
import { Eye, EyeOff } from 'lucide-preact';
interface RecoverTwoFactorPageProps {
values: { email: string; password: string; recoveryCode: string };
onChange: (next: { email: string; password: string; recoveryCode: string }) => void;
onSubmit: () => void;
onCancel: () => void;
}
export default function RecoverTwoFactorPage(props: RecoverTwoFactorPageProps) {
const [showPassword, setShowPassword] = useState(false);
return (
<div className="auth-page">
<div className="auth-card">
<h1>Recover Two-step Login</h1>
<p className="muted">Sign in with your one-time recovery code to disable two-step verification.</p>
<label className="field">
<span>Email</span>
<input
className="input"
type="email"
value={props.values.email}
onInput={(e) => props.onChange({ ...props.values, email: (e.currentTarget as HTMLInputElement).value })}
/>
</label>
<label className="field">
<span>Master Password</span>
<div className="password-wrap">
<input
className="input"
type={showPassword ? 'text' : 'password'}
value={props.values.password}
onInput={(e) => props.onChange({ ...props.values, password: (e.currentTarget as HTMLInputElement).value })}
/>
<button type="button" className="eye-btn" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</label>
<label className="field">
<span>Recovery Code</span>
<input
className="input"
value={props.values.recoveryCode}
onInput={(e) => props.onChange({ ...props.values, recoveryCode: (e.currentTarget as HTMLInputElement).value.toUpperCase() })}
/>
</label>
<div className="field-grid">
<button type="button" className="btn btn-primary" onClick={props.onSubmit}>
Submit
</button>
<button type="button" className="btn btn-secondary" onClick={props.onCancel}>
Cancel
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,129 @@
import { Clock3, RefreshCw, ShieldOff, Trash2 } from 'lucide-preact';
import type { AuthorizedDevice } from '@/lib/types';
interface SecurityDevicesPageProps {
devices: AuthorizedDevice[];
loading: boolean;
onRefresh: () => void;
onRevokeTrust: (device: AuthorizedDevice) => void;
onRemoveDevice: (device: AuthorizedDevice) => void;
onRevokeAll: () => void;
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return '-';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '-';
return date.toLocaleString();
}
function mapDeviceTypeName(type: number): string {
switch (type) {
case 0: return 'Android';
case 1: return 'iOS';
case 2: return 'Chrome Extension';
case 3: return 'Firefox Extension';
case 4: return 'Opera Extension';
case 5: return 'Edge Extension';
case 6: return 'Windows Desktop';
case 7: return 'macOS Desktop';
case 8: return 'Linux Desktop';
case 9: return 'Chrome Browser';
case 10: return 'Firefox Browser';
case 11: return 'Opera Browser';
case 12: return 'Edge Browser';
case 13: return 'IE Browser';
case 14: return 'Web';
default: return `Type ${type}`;
}
}
export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
return (
<div className="stack">
<section className="card">
<div className="section-head">
<div>
<h3 style={{ margin: 0 }}>Account Security</h3>
<div className="muted-inline" style={{ marginTop: 4 }}>
Manage authorized devices and 30-day TOTP trusted sessions.
</div>
</div>
<div className="actions">
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
Refresh
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
<ShieldOff size={14} className="btn-icon" />
Revoke All Trusted
</button>
</div>
</div>
</section>
<section className="card">
<h3 style={{ marginTop: 0 }}>Authorized Devices</h3>
<table className="table">
<thead>
<tr>
<th>Device</th>
<th>Type</th>
<th>Added</th>
<th>Last Seen</th>
<th>Trusted Until</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{props.devices.map((device) => (
<tr key={device.identifier}>
<td>
<div>{device.name || 'Unknown device'}</div>
<div className="muted-inline">{device.identifier}</div>
</td>
<td>{mapDeviceTypeName(device.type)}</td>
<td>{formatDateTime(device.creationDate)}</td>
<td>{formatDateTime(device.revisionDate)}</td>
<td>
{device.trusted ? (
<div className="trusted-cell">
<Clock3 size={13} />
<span>{formatDateTime(device.trustedUntil)}</span>
</div>
) : (
<span className="muted-inline">Not trusted</span>
)}
</td>
<td>
<div className="actions">
<button
type="button"
className="btn btn-secondary small"
disabled={!device.trusted}
onClick={() => props.onRevokeTrust(device)}
>
<ShieldOff size={14} className="btn-icon" />
Revoke Trust
</button>
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}>
<Trash2 size={14} className="btn-icon" />
Remove Device
</button>
</div>
</td>
</tr>
))}
{!props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={6}>
<div className="empty" style={{ minHeight: 80 }}>No devices found.</div>
</td>
</tr>
)}
</tbody>
</table>
</section>
</div>
);
}
+61 -2
View File
@@ -10,6 +10,8 @@ interface SettingsPageProps {
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>; onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>; onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void; onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
onNotify?: (type: 'success' | 'error', text: string) => void;
} }
function randomBase32Secret(length: number): string { function randomBase32Secret(length: number): string {
@@ -35,6 +37,8 @@ export default function SettingsPage(props: SettingsPageProps) {
const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32)); const [secret, setSecret] = useState(() => localStorage.getItem(totpSecretStorageKey) || randomBase32Secret(32));
const [token, setToken] = useState(''); const [token, setToken] = useState('');
const [totpLocked, setTotpLocked] = useState(props.totpEnabled); const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
const [recoveryCode, setRecoveryCode] = useState('');
useEffect(() => { useEffect(() => {
if (!props.totpEnabled) { if (!props.totpEnabled) {
@@ -57,6 +61,12 @@ export default function SettingsPage(props: SettingsPageProps) {
setTotpLocked(true); setTotpLocked(true);
} }
async function loadRecoveryCode(): Promise<void> {
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
setRecoveryCode(code);
props.onNotify?.('success', 'Recovery code loaded');
}
return ( return (
<div className="stack"> <div className="stack">
<section className="card"> <section className="card">
@@ -112,6 +122,8 @@ export default function SettingsPage(props: SettingsPageProps) {
</section> </section>
<section className="card"> <section className="card">
<div className="settings-twofactor-grid">
<div className="settings-subcard">
<h3>TOTP</h3> <h3>TOTP</h3>
{totpLocked && <div className="status-ok">TOTP is enabled for this account.</div>} {totpLocked && <div className="status-ok">TOTP is enabled for this account.</div>}
<div className="totp-grid"> <div className="totp-grid">
@@ -135,7 +147,15 @@ export default function SettingsPage(props: SettingsPageProps) {
<RefreshCw size={14} className="btn-icon" /> <RefreshCw size={14} className="btn-icon" />
Regenerate Regenerate
</button> </button>
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => navigator.clipboard.writeText(secret)}> <button
type="button"
className="btn btn-secondary"
disabled={totpLocked}
onClick={() => {
void navigator.clipboard.writeText(secret);
props.onNotify?.('success', 'Secret copied');
}}
>
<Clipboard size={14} className="btn-icon" /> <Clipboard size={14} className="btn-icon" />
Copy Secret Copy Secret
</button> </button>
@@ -143,10 +163,49 @@ export default function SettingsPage(props: SettingsPageProps) {
</div> </div>
</div> </div>
</div> </div>
<button type="button" className="btn btn-danger" onClick={props.onOpenDisableTotp}> <button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
<ShieldOff size={14} className="btn-icon" /> <ShieldOff size={14} className="btn-icon" />
Disable TOTP Disable TOTP
</button> </button>
</div>
<div className="settings-subcard">
<h3>Recovery Code</h3>
<p className="muted-inline" style={{ marginBottom: 8 }}>
This is a one-time code. After it is used, a new code is generated automatically.
</p>
<label className="field">
<span>Master Password</span>
<input
className="input"
type="password"
value={recoveryMasterPassword}
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}>
View Recovery Code
</button>
<button
type="button"
className="btn btn-secondary"
disabled={!recoveryCode}
onClick={() => {
void navigator.clipboard.writeText(recoveryCode);
props.onNotify?.('success', 'Recovery code copied');
}}
>
Copy Code
</button>
</div>
{recoveryCode && (
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}>
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div>
</div>
)}
</div>
</div>
</section> </section>
</div> </div>
); );
+137 -3
View File
@@ -1,5 +1,6 @@
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, hkdfExpand, pbkdf2 } from './crypto'; import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, hkdfExpand, pbkdf2 } from './crypto';
import type { import type {
AuthorizedDevice,
AdminInvite, AdminInvite,
AdminUser, AdminUser,
Cipher, Cipher,
@@ -18,6 +19,8 @@ import type {
} from './types'; } from './types';
const SESSION_KEY = 'nodewarden.web.session.v4'; const SESSION_KEY = 'nodewarden.web.session.v4';
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
type SessionSetter = (next: SessionState | null) => void; type SessionSetter = (next: SessionState | null) => void;
@@ -75,6 +78,42 @@ export interface PreloginResult {
kdfIterations: number; kdfIterations: number;
} }
function randomHex(length: number): string {
const bytes = crypto.getRandomValues(new Uint8Array(Math.max(1, Math.ceil(length / 2))));
return Array.from(bytes).map((b) => b.toString(16).padStart(2, '0')).join('').slice(0, length);
}
function getOrCreateDeviceIdentifier(): string {
const current = (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
if (current) return current;
const next = `${randomHex(8)}-${randomHex(4)}-${randomHex(4)}-${randomHex(4)}-${randomHex(12)}`;
localStorage.setItem(DEVICE_IDENTIFIER_KEY, next);
return next;
}
function guessDeviceName(): string {
const ua = (typeof navigator !== 'undefined' ? navigator.userAgent : '').toLowerCase();
const platform = (typeof navigator !== 'undefined' ? navigator.platform : '').trim();
const browser = ua.includes('edg/') ? 'Edge' : ua.includes('chrome/') ? 'Chrome' : ua.includes('firefox/') ? 'Firefox' : ua.includes('safari/') ? 'Safari' : 'Browser';
const os = ua.includes('windows') ? 'Windows' : ua.includes('mac os') ? 'macOS' : ua.includes('linux') ? 'Linux' : ua.includes('android') ? 'Android' : ua.includes('iphone') || ua.includes('ipad') ? 'iOS' : platform || 'Unknown OS';
return `${browser} on ${os}`.slice(0, 128);
}
function getRememberTwoFactorToken(): string | null {
const token = (localStorage.getItem(TOTP_REMEMBER_TOKEN_KEY) || '').trim();
return token || null;
}
function saveRememberTwoFactorToken(token: string | undefined): void {
const normalized = String(token || '').trim();
if (!normalized) return;
localStorage.setItem(TOTP_REMEMBER_TOKEN_KEY, normalized);
}
function clearRememberTwoFactorToken(): void {
localStorage.removeItem(TOTP_REMEMBER_TOKEN_KEY);
}
export async function deriveLoginHash(email: string, password: string, fallbackIterations: number): Promise<PreloginResult> { export async function deriveLoginHash(email: string, password: string, fallbackIterations: number): Promise<PreloginResult> {
const pre = await fetch('/identity/accounts/prelogin', { const pre = await fetch('/identity/accounts/prelogin', {
method: 'POST', method: 'POST',
@@ -89,15 +128,34 @@ export async function deriveLoginHash(email: string, password: string, fallbackI
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations }; return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
} }
export async function loginWithPassword(email: string, passwordHash: string, totpCode?: string): Promise<TokenSuccess | TokenError> { export async function loginWithPassword(
email: string,
passwordHash: string,
options?: {
totpCode?: string;
rememberDevice?: boolean;
useRememberToken?: boolean;
}
): Promise<TokenSuccess | TokenError> {
const body = new URLSearchParams(); const body = new URLSearchParams();
body.set('grant_type', 'password'); body.set('grant_type', 'password');
body.set('username', email.toLowerCase()); body.set('username', email.toLowerCase());
body.set('password', passwordHash); body.set('password', passwordHash);
body.set('scope', 'api offline_access'); body.set('scope', 'api offline_access');
if (totpCode) { body.set('deviceIdentifier', getOrCreateDeviceIdentifier());
body.set('deviceName', guessDeviceName());
body.set('deviceType', '14');
const rememberedToken = options?.useRememberToken ? getRememberTwoFactorToken() : null;
if (rememberedToken) {
body.set('twoFactorProvider', '5');
body.set('twoFactorToken', rememberedToken);
} else if (options?.totpCode) {
body.set('twoFactorProvider', '0'); body.set('twoFactorProvider', '0');
body.set('twoFactorToken', totpCode); body.set('twoFactorToken', options.totpCode);
if (options.rememberDevice) {
body.set('twoFactorRemember', '1');
}
} }
const resp = await fetch('/identity/connect/token', { const resp = await fetch('/identity/connect/token', {
method: 'POST', method: 'POST',
@@ -105,6 +163,12 @@ export async function loginWithPassword(email: string, passwordHash: string, tot
body: body.toString(), body: body.toString(),
}); });
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {}; const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
if (resp.ok) {
saveRememberTwoFactorToken((json as TokenSuccess).TwoFactorToken);
} else if (rememberedToken) {
// Remember-token login failed; force the next attempt to use real TOTP.
clearRememberTwoFactorToken();
}
if (!resp.ok) return json; if (!resp.ok) return json;
return json; return json;
} }
@@ -352,6 +416,76 @@ export async function getTotpStatus(
return { enabled: !!body.enabled }; return { enabled: !!body.enabled };
} }
export async function getTotpRecoveryCode(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
masterPasswordHash: string
): Promise<string> {
const resp = await authedFetch('/api/accounts/totp/recovery-code', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ masterPasswordHash }),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(body?.error_description || body?.error || 'Failed to get recovery code');
}
const body = (await parseJson<{ code?: string }>(resp)) || {};
return String(body.code || '');
}
export async function recoverTwoFactor(
email: string,
masterPasswordHash: string,
recoveryCode: string
): Promise<{ newRecoveryCode?: string }> {
const resp = await fetch('/identity/accounts/recover-2fa', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
email: email.toLowerCase().trim(),
masterPasswordHash,
recoveryCode,
}),
});
if (!resp.ok) {
const body = await parseJson<TokenError>(resp);
throw new Error(body?.error_description || body?.error || 'Recover 2FA failed');
}
return (await parseJson<{ newRecoveryCode?: string }>(resp)) || {};
}
export async function getAuthorizedDevices(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
): Promise<AuthorizedDevice[]> {
const resp = await authedFetch('/api/devices/authorized');
if (!resp.ok) throw new Error('Failed to load authorized devices');
const body = await parseJson<ListResponse<AuthorizedDevice>>(resp);
return body?.data || [];
}
export async function revokeAuthorizedDeviceTrust(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
deviceIdentifier: string
): Promise<void> {
const resp = await authedFetch(`/api/devices/authorized/${encodeURIComponent(deviceIdentifier)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Failed to revoke device authorization');
}
export async function revokeAllAuthorizedDeviceTrust(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>
): Promise<void> {
const resp = await authedFetch('/api/devices/authorized', { method: 'DELETE' });
if (!resp.ok) throw new Error('Failed to revoke all authorized devices');
}
export async function deleteAuthorizedDevice(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
deviceIdentifier: string
): Promise<void> {
const resp = await authedFetch(`/api/devices/${encodeURIComponent(deviceIdentifier)}`, { method: 'DELETE' });
if (!resp.ok) throw new Error('Failed to remove device');
}
export async function listAdminUsers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<AdminUser[]> { export async function listAdminUsers(authedFetch: (input: string, init?: RequestInit) => Promise<Response>): Promise<AdminUser[]> {
const resp = await authedFetch('/api/admin/users'); const resp = await authedFetch('/api/admin/users');
if (!resp.ok) throw new Error('Failed to load users'); if (!resp.ok) throw new Error('Failed to load users');
+13
View File
@@ -242,6 +242,7 @@ export interface WebConfigResponse {
export interface TokenSuccess { export interface TokenSuccess {
access_token: string; access_token: string;
refresh_token: string; refresh_token: string;
TwoFactorToken?: string;
} }
export interface TokenError { export interface TokenError {
@@ -270,3 +271,15 @@ export interface AdminInvite {
status: string; status: string;
expiresAt?: string; expiresAt?: string;
} }
export interface AuthorizedDevice {
id: string;
name: string;
identifier: string;
type: number;
creationDate: string | null;
revisionDate: string | null;
trusted: boolean;
trustedTokenCount: number;
trustedUntil: string | null;
}
+38
View File
@@ -1050,6 +1050,12 @@ input[type='file'].input::file-selector-button:hover {
font-weight: 600; font-weight: 600;
} }
.trusted-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.dialog-mask { .dialog-mask {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -1096,6 +1102,34 @@ input[type='file'].input::file-selector-button:hover {
margin-top: 8px; margin-top: 8px;
} }
.dialog-extra {
margin-top: 8px;
}
.dialog-divider {
height: 1px;
background: var(--line);
margin: 8px 0 10px;
}
.settings-twofactor-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.settings-subcard {
border: 1px solid var(--line);
border-radius: 12px;
padding: 12px;
background: #fff;
}
.settings-subcard h3 {
margin-top: 0;
margin-bottom: 10px;
}
.toast-stack { .toast-stack {
position: fixed; position: fixed;
top: 16px; top: 16px;
@@ -1209,4 +1243,8 @@ input[type='file'].input::file-selector-button:hover {
.uri-row { .uri-row {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.settings-twofactor-grid {
grid-template-columns: 1fr;
}
} }