mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +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);
|
||||
}
|
||||
+169
-7
@@ -8,7 +8,9 @@ import ToastHost from '@/components/ToastHost';
|
||||
import VaultPage from '@/components/VaultPage';
|
||||
import SendsPage from '@/components/SendsPage';
|
||||
import PublicSendPage from '@/components/PublicSendPage';
|
||||
import RecoverTwoFactorPage from '@/components/RecoverTwoFactorPage';
|
||||
import SettingsPage from '@/components/SettingsPage';
|
||||
import SecurityDevicesPage from '@/components/SecurityDevicesPage';
|
||||
import AdminPage from '@/components/AdminPage';
|
||||
import HelpPage from '@/components/HelpPage';
|
||||
import {
|
||||
@@ -27,19 +29,25 @@ import {
|
||||
getCiphers,
|
||||
getFolders,
|
||||
getProfile,
|
||||
getAuthorizedDevices,
|
||||
getSetupStatus,
|
||||
getSends,
|
||||
getTotpStatus,
|
||||
getTotpRecoveryCode,
|
||||
getWebConfig,
|
||||
listAdminInvites,
|
||||
listAdminUsers,
|
||||
loadSession,
|
||||
loginWithPassword,
|
||||
registerAccount,
|
||||
recoverTwoFactor,
|
||||
revokeInvite,
|
||||
revokeAuthorizedDeviceTrust,
|
||||
revokeAllAuthorizedDeviceTrust,
|
||||
saveSession,
|
||||
setTotp,
|
||||
setUserStatus,
|
||||
deleteAuthorizedDevice,
|
||||
updateCipher,
|
||||
updateSend,
|
||||
buildSendShareKey,
|
||||
@@ -48,7 +56,7 @@ import {
|
||||
verifyMasterPassword,
|
||||
} from '@/lib/api';
|
||||
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 {
|
||||
email: string;
|
||||
@@ -90,9 +98,11 @@ export default function App() {
|
||||
const [unlockPassword, setUnlockPassword] = useState('');
|
||||
const [pendingTotp, setPendingTotp] = useState<PendingTotp | null>(null);
|
||||
const [totpCode, setTotpCode] = useState('');
|
||||
const [rememberDevice, setRememberDevice] = useState(true);
|
||||
|
||||
const [disableTotpOpen, setDisableTotpOpen] = useState(false);
|
||||
const [disableTotpPassword, setDisableTotpPassword] = useState('');
|
||||
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
|
||||
|
||||
const [confirm, setConfirm] = useState<{
|
||||
title: string;
|
||||
@@ -201,7 +211,7 @@ export default function App() {
|
||||
}
|
||||
try {
|
||||
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) {
|
||||
await finalizeLogin(token.access_token, token.refresh_token, loginValues.email.toLowerCase(), derived.masterKey);
|
||||
return;
|
||||
@@ -214,6 +224,7 @@ export default function App() {
|
||||
masterKey: derived.masterKey,
|
||||
});
|
||||
setTotpCode('');
|
||||
setRememberDevice(true);
|
||||
return;
|
||||
}
|
||||
pushToast('error', tokenError.error_description || tokenError.error || 'Login failed');
|
||||
@@ -228,7 +239,10 @@ export default function App() {
|
||||
pushToast('error', 'Please input TOTP code');
|
||||
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) {
|
||||
await finalizeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey);
|
||||
return;
|
||||
@@ -237,6 +251,34 @@ export default function App() {
|
||||
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() {
|
||||
if (!registerValues.email || !registerValues.password) {
|
||||
pushToast('error', 'Please input email and password');
|
||||
@@ -345,6 +387,11 @@ export default function App() {
|
||||
queryFn: () => getTotpStatus(authedFetch),
|
||||
enabled: phase === 'app' && !!session?.accessToken,
|
||||
});
|
||||
const authorizedDevicesQuery = useQuery({
|
||||
queryKey: ['authorized-devices', session?.accessToken],
|
||||
queryFn: () => getAuthorizedDevices(authedFetch),
|
||||
enabled: phase === 'app' && !!session?.accessToken,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!session?.symEncKey || !session?.symMacKey) {
|
||||
@@ -592,6 +639,28 @@ export default function App() {
|
||||
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) {
|
||||
if (!session) return;
|
||||
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) {
|
||||
if (!session) return;
|
||||
try {
|
||||
@@ -732,8 +811,9 @@ export default function App() {
|
||||
|
||||
const hashPathRaw = typeof window !== 'undefined' ? window.location.hash || '' : '';
|
||||
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 isRecoverTwoFactorRoute = effectiveLocation === '/recover-2fa';
|
||||
const isPublicSendRoute = !!publicSendMatch;
|
||||
|
||||
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') {
|
||||
return (
|
||||
<>
|
||||
@@ -790,12 +887,34 @@ export default function App() {
|
||||
onCancel={() => {
|
||||
setPendingTotp(null);
|
||||
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">
|
||||
<span>TOTP Code</span>
|
||||
<input className="input" value={totpCode} onInput={(e) => setTotpCode((e.currentTarget as HTMLInputElement).value)} />
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
@@ -815,9 +934,6 @@ export default function App() {
|
||||
<ShieldUser size={16} />
|
||||
<span>{profile?.email}</span>
|
||||
</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}>
|
||||
<LogOut size={14} className="btn-icon" /> Sign Out
|
||||
</button>
|
||||
@@ -844,6 +960,10 @@ export default function App() {
|
||||
<SettingsIcon size={16} />
|
||||
<span>System Settings</span>
|
||||
</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' : ''}`}>
|
||||
<CircleHelp size={16} />
|
||||
<span>Support Center</span>
|
||||
@@ -892,9 +1012,51 @@ export default function App() {
|
||||
await totpStatusQuery.refetch();
|
||||
}}
|
||||
onOpenDisableTotp={() => setDisableTotpOpen(true)}
|
||||
onGetRecoveryCode={getRecoveryCodeAction}
|
||||
onNotify={pushToast}
|
||||
/>
|
||||
)}
|
||||
</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">
|
||||
<AdminPage
|
||||
currentUserId={profile?.id || ''}
|
||||
|
||||
@@ -11,6 +11,7 @@ interface ConfirmDialogProps {
|
||||
onConfirm: () => void;
|
||||
onCancel: () => void;
|
||||
children?: ComponentChildren;
|
||||
afterActions?: ComponentChildren;
|
||||
}
|
||||
|
||||
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}>
|
||||
{props.cancelText || 'No'}
|
||||
</button>
|
||||
{props.afterActions}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -10,6 +10,8 @@ interface SettingsPageProps {
|
||||
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
|
||||
onEnableTotp: (secret: string, token: string) => Promise<void>;
|
||||
onOpenDisableTotp: () => void;
|
||||
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
|
||||
onNotify?: (type: 'success' | 'error', text: string) => void;
|
||||
}
|
||||
|
||||
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 [token, setToken] = useState('');
|
||||
const [totpLocked, setTotpLocked] = useState(props.totpEnabled);
|
||||
const [recoveryMasterPassword, setRecoveryMasterPassword] = useState('');
|
||||
const [recoveryCode, setRecoveryCode] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (!props.totpEnabled) {
|
||||
@@ -57,6 +61,12 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
setTotpLocked(true);
|
||||
}
|
||||
|
||||
async function loadRecoveryCode(): Promise<void> {
|
||||
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
|
||||
setRecoveryCode(code);
|
||||
props.onNotify?.('success', 'Recovery code loaded');
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="stack">
|
||||
<section className="card">
|
||||
@@ -112,41 +122,90 @@ export default function SettingsPage(props: SettingsPageProps) {
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h3>TOTP</h3>
|
||||
{totpLocked && <div className="status-ok">TOTP is enabled for this account.</div>}
|
||||
<div className="totp-grid">
|
||||
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
|
||||
<div>
|
||||
<div>
|
||||
<label className="field">
|
||||
<span>Authenticator Key</span>
|
||||
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Verification Code</span>
|
||||
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{totpLocked ? 'Enabled' : 'Enable TOTP'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
Regenerate
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => navigator.clipboard.writeText(secret)}>
|
||||
<Clipboard size={14} className="btn-icon" />
|
||||
Copy Secret
|
||||
</button>
|
||||
<div className="settings-twofactor-grid">
|
||||
<div className="settings-subcard">
|
||||
<h3>TOTP</h3>
|
||||
{totpLocked && <div className="status-ok">TOTP is enabled for this account.</div>}
|
||||
<div className="totp-grid">
|
||||
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
|
||||
<div>
|
||||
<div>
|
||||
<label className="field">
|
||||
<span>Authenticator Key</span>
|
||||
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
|
||||
</label>
|
||||
<label className="field">
|
||||
<span>Verification Code</span>
|
||||
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
|
||||
</label>
|
||||
<div className="actions">
|
||||
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
|
||||
<ShieldCheck size={14} className="btn-icon" />
|
||||
{totpLocked ? 'Enabled' : 'Enable TOTP'}
|
||||
</button>
|
||||
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
|
||||
<RefreshCw size={14} className="btn-icon" />
|
||||
Regenerate
|
||||
</button>
|
||||
<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" />
|
||||
Copy Secret
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
Disable TOTP
|
||||
</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>
|
||||
<button type="button" className="btn btn-danger" onClick={props.onOpenDisableTotp}>
|
||||
<ShieldOff size={14} className="btn-icon" />
|
||||
Disable TOTP
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
|
||||
+137
-3
@@ -1,5 +1,6 @@
|
||||
import { base64ToBytes, bytesToBase64, decryptBw, decryptBwFileData, decryptStr, encryptBw, encryptBwFileData, hkdf, hkdfExpand, pbkdf2 } from './crypto';
|
||||
import type {
|
||||
AuthorizedDevice,
|
||||
AdminInvite,
|
||||
AdminUser,
|
||||
Cipher,
|
||||
@@ -18,6 +19,8 @@ import type {
|
||||
} from './types';
|
||||
|
||||
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;
|
||||
|
||||
@@ -75,6 +78,42 @@ export interface PreloginResult {
|
||||
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> {
|
||||
const pre = await fetch('/identity/accounts/prelogin', {
|
||||
method: 'POST',
|
||||
@@ -89,15 +128,34 @@ export async function deriveLoginHash(email: string, password: string, fallbackI
|
||||
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();
|
||||
body.set('grant_type', 'password');
|
||||
body.set('username', email.toLowerCase());
|
||||
body.set('password', passwordHash);
|
||||
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('twoFactorToken', totpCode);
|
||||
body.set('twoFactorToken', options.totpCode);
|
||||
if (options.rememberDevice) {
|
||||
body.set('twoFactorRemember', '1');
|
||||
}
|
||||
}
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
@@ -105,6 +163,12 @@ export async function loginWithPassword(email: string, passwordHash: string, tot
|
||||
body: body.toString(),
|
||||
});
|
||||
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;
|
||||
return json;
|
||||
}
|
||||
@@ -352,6 +416,76 @@ export async function getTotpStatus(
|
||||
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[]> {
|
||||
const resp = await authedFetch('/api/admin/users');
|
||||
if (!resp.ok) throw new Error('Failed to load users');
|
||||
|
||||
@@ -242,6 +242,7 @@ export interface WebConfigResponse {
|
||||
export interface TokenSuccess {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
TwoFactorToken?: string;
|
||||
}
|
||||
|
||||
export interface TokenError {
|
||||
@@ -270,3 +271,15 @@ export interface AdminInvite {
|
||||
status: 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;
|
||||
}
|
||||
|
||||
@@ -1050,6 +1050,12 @@ input[type='file'].input::file-selector-button:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.trusted-cell {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.dialog-mask {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
@@ -1096,6 +1102,34 @@ input[type='file'].input::file-selector-button:hover {
|
||||
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 {
|
||||
position: fixed;
|
||||
top: 16px;
|
||||
@@ -1209,4 +1243,8 @@ input[type='file'].input::file-selector-button:hover {
|
||||
.uri-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.settings-twofactor-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user