mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: add recovery code functionality and device management
This commit is contained in:
+100
-1
@@ -5,6 +5,7 @@ import { jsonResponse, errorResponse } from '../utils/response';
|
||||
import { generateUUID } from '../utils/uuid';
|
||||
import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||
|
||||
function looksLikeEncString(value: string): boolean {
|
||||
if (!value) return false;
|
||||
@@ -20,6 +21,10 @@ function normalizeTotpSecret(input: string): string {
|
||||
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 {
|
||||
const secret = (env.JWT_SECRET || '').trim();
|
||||
if (!secret) return 'missing';
|
||||
@@ -132,6 +137,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
|
||||
role: 'user',
|
||||
status: 'active',
|
||||
totpSecret: null,
|
||||
totpRecoveryCode: null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
@@ -375,10 +381,13 @@ export async function handleSetTotpStatus(request: Request, env: Env, userId: st
|
||||
return errorResponse('Invalid TOTP token', 400);
|
||||
}
|
||||
user.totpSecret = normalizedSecret;
|
||||
if (!user.totpRecoveryCode) {
|
||||
user.totpRecoveryCode = createRecoveryCode();
|
||||
}
|
||||
user.updatedAt = new Date().toISOString();
|
||||
await storage.saveUser(user);
|
||||
await storage.deleteRefreshTokensByUserId(user.id);
|
||||
return jsonResponse({ enabled: true, object: 'twoFactor' });
|
||||
return jsonResponse({ enabled: true, recoveryCode: user.totpRecoveryCode, object: 'twoFactor' });
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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
|
||||
export async function handleGetRevisionDate(request: Request, env: Env, userId: string): Promise<Response> {
|
||||
void request;
|
||||
|
||||
+98
-1
@@ -1,6 +1,6 @@
|
||||
import { Env } from '../types';
|
||||
import { StorageService } from '../services/storage';
|
||||
import { jsonResponse } from '../utils/response';
|
||||
import { errorResponse, jsonResponse } from '../utils/response';
|
||||
import { readKnownDeviceProbe } from '../utils/device';
|
||||
|
||||
// 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
|
||||
// Bitwarden mobile reports push token updates to this endpoint.
|
||||
// NodeWarden does not implement push notifications, so accept and no-op.
|
||||
|
||||
@@ -7,11 +7,13 @@ import { LIMITS } from '../config/limits';
|
||||
import { isTotpEnabled, verifyTotpToken } from '../utils/totp';
|
||||
import { createRefreshToken } from '../utils/jwt';
|
||||
import { readAuthRequestDeviceInfo } from '../utils/device';
|
||||
import { createRecoveryCode, recoveryCodeEquals } from '../utils/recovery-code';
|
||||
import { issueSendAccessToken } from './sends';
|
||||
|
||||
const TWO_FACTOR_REMEMBER_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
const TWO_FACTOR_PROVIDER_AUTHENTICATOR = 0;
|
||||
const TWO_FACTOR_PROVIDER_REMEMBER = 5;
|
||||
const TWO_FACTOR_PROVIDER_RECOVERY_CODE = 8;
|
||||
|
||||
function resolveTotpSecret(userSecret: string | null, envSecret: string | undefined): string | null {
|
||||
if (userSecret && isTotpEnabled(userSecret)) {
|
||||
@@ -23,16 +25,20 @@ function resolveTotpSecret(userSecret: string | null, envSecret: string | undefi
|
||||
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.
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'invalid_grant',
|
||||
error_description: message,
|
||||
TwoFactorProviders: [String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)],
|
||||
TwoFactorProviders2: {
|
||||
[String(TWO_FACTOR_PROVIDER_AUTHENTICATOR)]: null,
|
||||
},
|
||||
TwoFactorProviders: providers,
|
||||
TwoFactorProviders2: providers2,
|
||||
// Required by current Android parser (nullable value is acceptable).
|
||||
SsoEmail2faSessionToken: null,
|
||||
// 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;
|
||||
const effectiveTotpSecret = resolveTotpSecret(user.totpSecret, env.TOTP_SECRET);
|
||||
if (effectiveTotpSecret) {
|
||||
const canUseRecoveryCode = !!user.totpRecoveryCode;
|
||||
const normalizedTwoFactorProvider = String(twoFactorProvider ?? '').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 hasToken = normalizedTwoFactorToken.length > 0;
|
||||
|
||||
// Upstream-compatible behavior: if 2FA is required and either provider or token is missing,
|
||||
// respond with a 2FA challenge payload.
|
||||
if (!hasProvider || !hasToken) {
|
||||
return twoFactorRequiredResponse();
|
||||
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
||||
}
|
||||
|
||||
const parsedProvider = Number.parseInt(normalizedTwoFactorProvider, 10);
|
||||
if (!Number.isFinite(parsedProvider)) {
|
||||
return twoFactorRequiredResponse();
|
||||
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
||||
}
|
||||
|
||||
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.
|
||||
if (!passedByRememberToken) {
|
||||
return twoFactorRequiredResponse();
|
||||
return twoFactorRequiredResponse('Two factor required.', canUseRecoveryCode);
|
||||
}
|
||||
} else if (parsedProvider === TWO_FACTOR_PROVIDER_AUTHENTICATOR) {
|
||||
const totpOk = await verifyTotpToken(effectiveTotpSecret, normalizedTwoFactorToken);
|
||||
if (!totpOk) {
|
||||
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 {
|
||||
// Unsupported provider for this server profile behaves as an invalid 2FA attempt.
|
||||
return recordFailedTwoFactorAndBuildResponse(rateLimit, loginIdentifier);
|
||||
|
||||
+36
-1
@@ -19,6 +19,8 @@ import {
|
||||
handleChangePassword,
|
||||
handleGetTotpStatus,
|
||||
handleSetTotpStatus,
|
||||
handleGetTotpRecoveryCode,
|
||||
handleRecoverTwoFactor,
|
||||
} from './handlers/accounts';
|
||||
|
||||
// Cipher handlers
|
||||
@@ -68,7 +70,15 @@ import { handleSync } from './handlers/sync';
|
||||
|
||||
// Setup handlers
|
||||
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 { handleCiphersImport } from './handlers/import';
|
||||
@@ -310,6 +320,10 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
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)
|
||||
// Bitwarden clients call GET "/config" (relative to the API base URL).
|
||||
// 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 ((path === '/api/accounts/totp/recovery-code' || path === '/api/two-factor/get-recover') && method === 'POST') {
|
||||
return handleGetTotpRecoveryCode(request, env, userId);
|
||||
}
|
||||
|
||||
// Revision date endpoint
|
||||
if (path === '/api/accounts/revision-date' && method === 'GET') {
|
||||
return handleGetRevisionDate(request, env, userId);
|
||||
@@ -666,6 +684,23 @@ export async function handleRequest(request: Request, env: Env): Promise<Respons
|
||||
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
|
||||
if (path === '/api/admin/users' && method === 'GET') {
|
||||
return handleAdminListUsers(request, env, currentUser);
|
||||
|
||||
+57
-10
@@ -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';
|
||||
|
||||
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, ' +
|
||||
'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, 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 status TEXT NOT NULL DEFAULT \'active\'',
|
||||
'ALTER TABLE users ADD COLUMN totp_secret TEXT',
|
||||
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
|
||||
|
||||
'CREATE TABLE IF NOT EXISTS user_revisions (' +
|
||||
'user_id TEXT PRIMARY KEY, revision_date TEXT NOT NULL, ' +
|
||||
@@ -224,6 +225,7 @@ export class StorageService {
|
||||
role: row.role === 'admin' ? 'admin' : 'user',
|
||||
status: row.status === 'banned' ? 'banned' : 'active',
|
||||
totpSecret: row.totp_secret ?? null,
|
||||
totpRecoveryCode: row.totp_recovery_code ?? null,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at,
|
||||
};
|
||||
@@ -232,7 +234,7 @@ export class StorageService {
|
||||
async getUser(email: string): Promise<User | null> {
|
||||
const row = await this.db
|
||||
.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())
|
||||
.first<any>();
|
||||
@@ -243,7 +245,7 @@ export class StorageService {
|
||||
async getUserById(id: string): Promise<User | null> {
|
||||
const row = await this.db
|
||||
.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)
|
||||
.first<any>();
|
||||
@@ -259,7 +261,7 @@ export class StorageService {
|
||||
async getAllUsers(): Promise<User[]> {
|
||||
const res = await this.db
|
||||
.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>();
|
||||
return (res.results || []).map(row => this.mapUserRow(row));
|
||||
@@ -268,11 +270,11 @@ export class StorageService {
|
||||
async saveUser(user: User): Promise<void> {
|
||||
const email = user.email.toLowerCase();
|
||||
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) ' +
|
||||
'VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'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(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ' +
|
||||
'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, ' +
|
||||
'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,
|
||||
user.id,
|
||||
@@ -290,6 +292,7 @@ export class StorageService {
|
||||
user.role,
|
||||
user.status,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
).run();
|
||||
@@ -302,8 +305,8 @@ export class StorageService {
|
||||
async createFirstUser(user: User): Promise<boolean> {
|
||||
const email = user.email.toLowerCase();
|
||||
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) ' +
|
||||
'SELECT ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'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 ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ' +
|
||||
'WHERE NOT EXISTS (SELECT 1 FROM users LIMIT 1)'
|
||||
);
|
||||
const result = await this.safeBind(stmt,
|
||||
@@ -322,6 +325,7 @@ export class StorageService {
|
||||
user.role,
|
||||
user.status,
|
||||
user.totpSecret,
|
||||
user.totpRecoveryCode,
|
||||
user.createdAt,
|
||||
user.updatedAt
|
||||
).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) ---
|
||||
|
||||
async saveTrustedTwoFactorDeviceToken(
|
||||
|
||||
@@ -40,6 +40,7 @@ export interface User {
|
||||
role: UserRole;
|
||||
status: UserStatus;
|
||||
totpSecret: string | null;
|
||||
totpRecoveryCode: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -183,6 +184,12 @@ export interface Device {
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface TrustedDeviceTokenSummary {
|
||||
deviceIdentifier: string;
|
||||
expiresAt: number;
|
||||
tokenCount: number;
|
||||
}
|
||||
|
||||
export enum SendType {
|
||||
Text = 0,
|
||||
File = 1,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user