feat: disable new-device verification and update related logic across services

This commit is contained in:
shuaiplus
2026-07-10 13:00:32 +08:00
parent bb3f866220
commit 14dff8ee6a
6 changed files with 39 additions and 56 deletions
+1 -1
View File
@@ -31,7 +31,7 @@ CREATE TABLE IF NOT EXISTS users (
security_stamp TEXT NOT NULL, security_stamp TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'user', role TEXT NOT NULL DEFAULT 'user',
status TEXT NOT NULL DEFAULT 'active', status TEXT NOT NULL DEFAULT 'active',
verify_devices INTEGER NOT NULL DEFAULT 1, verify_devices INTEGER NOT NULL DEFAULT 0,
totp_secret TEXT, totp_secret TEXT,
totp_recovery_code TEXT, totp_recovery_code TEXT,
api_key TEXT, api_key TEXT,
+31 -50
View File
@@ -352,7 +352,7 @@ export async function handleRegister(request: Request, env: Env): Promise<Respon
securityStamp: generateUUID(), securityStamp: generateUUID(),
role: 'user', role: 'user',
status: 'active', status: 'active',
verifyDevices: true, verifyDevices: false, // new-device verification requires email delivery (not available)
totpSecret: null, totpSecret: null,
totpRecoveryCode: null, totpRecoveryCode: null,
yubikeyKey1: null, yubikeyKey1: null,
@@ -553,51 +553,31 @@ export async function handleUpdateProfile(request: Request, env: Env, userId: st
} }
// PUT/POST /api/accounts/verify-devices // PUT/POST /api/accounts/verify-devices
// New-device verification requires an email delivery channel which NodeWarden
// does not provide. This endpoint always rejects the request so clients receive
// clear feedback that the feature is unavailable rather than silently ignoring
// the user's preference.
export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> { export async function handleSetVerifyDevices(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const auth = new AuthService(env); const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
let body: { // Log the attempt for audit purposes, but do not change state.
secret?: string;
masterPasswordHash?: string;
verifyDevices?: boolean;
VerifyDevices?: boolean;
};
try {
body = await request.json();
} catch {
return errorResponse('Invalid JSON', 400);
}
const verifyDevices = typeof body.verifyDevices === 'boolean' ? body.verifyDevices : body.VerifyDevices;
if (typeof verifyDevices !== 'boolean') {
return errorResponse('verifyDevices must be true or false', 400);
}
const verified = await verifyUserSecret(auth, user, body.secret || body.masterPasswordHash);
if (!verified) {
return errorResponse('User verification failed.', 400);
}
user.verifyDevices = verifyDevices;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: 'account.verify_devices.update', action: 'account.verify_devices.update.rejected',
category: 'security', category: 'security',
level: 'security', level: 'info',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: { metadata: {
verifyDevices: user.verifyDevices, reason: 'new-device verification is not supported (no email delivery channel)',
...auditRequestMetadata(request), ...auditRequestMetadata(request),
}, },
}); });
return new Response(null, { status: 200 }); return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
} }
// GET /api/accounts/keys // GET /api/accounts/keys
@@ -819,13 +799,16 @@ function yubiKeyResponse(user: User): Record<string, unknown> {
}; };
} }
function deviceVerificationSettingsResponse(user: User): Record<string, unknown> { // New-device verification requires an email delivery channel to send OTP
const enabled = user.verifyDevices !== false; // challenges to unknown devices. NodeWarden does not integrate with an email
// provider, so this feature is intentionally unavailable. The settings
// response always reports disabled regardless of any legacy DB value.
function deviceVerificationSettingsResponse(_user: User): Record<string, unknown> {
return { return {
Enabled: enabled, Enabled: false,
enabled, enabled: false,
VerifyDevices: enabled, VerifyDevices: false,
verifyDevices: enabled, verifyDevices: false,
Object: 'deviceVerificationSettings', Object: 'deviceVerificationSettings',
object: 'deviceVerificationSettings', object: 'deviceVerificationSettings',
}; };
@@ -915,9 +898,10 @@ export async function handleGetDeviceVerificationSettings(request: Request, env:
} }
// PUT/POST /api/two-factor/device-verification-settings // PUT/POST /api/two-factor/device-verification-settings
// New-device verification is not supported (no email delivery channel).
// Reject any attempt to enable it; always return disabled state.
export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> { export async function handlePutDeviceVerificationSettings(request: Request, env: Env, userId: string): Promise<Response> {
const storage = new StorageService(env.DB); const storage = new StorageService(env.DB);
const auth = new AuthService(env);
const user = await storage.getUserById(userId); const user = await storage.getUserById(userId);
if (!user) return errorResponse('User not found', 404); if (!user) return errorResponse('User not found', 404);
@@ -929,31 +913,28 @@ export async function handlePutDeviceVerificationSettings(request: Request, env:
} }
const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices; const rawEnabled = body.enabled ?? body.Enabled ?? body.verifyDevices ?? body.VerifyDevices;
if (typeof rawEnabled !== 'boolean') {
return errorResponse('enabled must be true or false', 400);
}
const secret = readBodyString(body, ['masterPasswordHash', 'MasterPasswordHash', 'secret', 'Secret']); // Log the attempt for audit purposes — never change state.
const verified = await verifyUserSecret(auth, user, secret);
if (!verified) return errorResponse('User verification failed.', 400);
user.verifyDevices = rawEnabled;
user.updatedAt = new Date().toISOString();
await storage.saveUser(user);
await writeAuditEvent(storage, { await writeAuditEvent(storage, {
actorUserId: user.id, actorUserId: user.id,
action: 'account.verify_devices.update', action: 'account.verify_devices.update.rejected',
category: 'security', category: 'security',
level: 'security', level: 'info',
targetType: 'user', targetType: 'user',
targetId: user.id, targetId: user.id,
metadata: { metadata: {
verifyDevices: user.verifyDevices, requested: rawEnabled,
reason: 'new-device verification is not supported (no email delivery channel)',
source: 'two-factor.device-verification-settings', source: 'two-factor.device-verification-settings',
...auditRequestMetadata(request), ...auditRequestMetadata(request),
}, },
}); });
if (rawEnabled === true) {
return errorResponse('New device verification is not available on this server. Enable TOTP or WebAuthn two-factor authentication instead.', 400);
}
// Setting to false is the only supported state — return it.
return jsonResponse(deviceVerificationSettingsResponse(user)); return jsonResponse(deviceVerificationSettingsResponse(user));
} }
+1 -1
View File
@@ -301,7 +301,7 @@ async function importPreparedBackupRows(db: D1Database, payload: BackupPayload['
config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []), config: await prepareImportedConfigRows(env, payload.config || [], payload.users || []),
users: cloneRows(payload.users || []).map((row) => ({ users: cloneRows(payload.users || []).map((row) => ({
...row, ...row,
verify_devices: row.verify_devices ?? 1, verify_devices: row.verify_devices ?? 0,
yubikey_nfc: row.yubikey_nfc ?? 0, yubikey_nfc: row.yubikey_nfc ?? 0,
})), })),
domain_settings: cloneRows(payload.domain_settings || []), domain_settings: cloneRows(payload.domain_settings || []),
+2 -2
View File
@@ -14,11 +14,11 @@ const SCHEMA_STATEMENTS: readonly string[] = [
'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' + 'id TEXT PRIMARY KEY, email TEXT NOT NULL UNIQUE, name TEXT, master_password_hint TEXT, master_password_hash TEXT NOT NULL, ' +
'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' + 'key TEXT NOT NULL, private_key TEXT, public_key TEXT, kdf_type INTEGER NOT NULL, ' +
'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' + 'kdf_iterations INTEGER NOT NULL, kdf_memory INTEGER, kdf_parallelism INTEGER, ' +
'security_stamp TEXT NOT NULL, role TEXT NOT NULL DEFAULT \'user\', status TEXT NOT NULL DEFAULT \'active\', verify_devices INTEGER NOT NULL DEFAULT 1, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key 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\', verify_devices INTEGER NOT NULL DEFAULT 0, totp_secret TEXT, totp_recovery_code TEXT, yubikey_key1 TEXT, yubikey_key2 TEXT, yubikey_key3 TEXT, yubikey_key4 TEXT, yubikey_key5 TEXT, yubikey_nfc INTEGER NOT NULL DEFAULT 0, api_key TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)',
'ALTER TABLE users ADD COLUMN master_password_hint TEXT', 'ALTER TABLE users ADD COLUMN master_password_hint TEXT',
'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'', 'ALTER TABLE users ADD COLUMN role TEXT NOT NULL DEFAULT \'user\'',
'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'', 'ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT \'active\'',
'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 1', 'ALTER TABLE users ADD COLUMN verify_devices INTEGER NOT NULL DEFAULT 0',
'ALTER TABLE users ADD COLUMN totp_secret TEXT', 'ALTER TABLE users ADD COLUMN totp_secret TEXT',
'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT', 'ALTER TABLE users ADD COLUMN totp_recovery_code TEXT',
'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT', 'ALTER TABLE users ADD COLUMN yubikey_key1 TEXT',
+1 -1
View File
@@ -23,7 +23,7 @@ function mapUserRow(row: any): User {
securityStamp: row.security_stamp, securityStamp: row.security_stamp,
role: row.role === 'admin' ? 'admin' : 'user', role: row.role === 'admin' ? 'admin' : 'user',
status: row.status === 'banned' ? 'banned' : 'active', status: row.status === 'banned' ? 'banned' : 'active',
verifyDevices: row.verify_devices == null ? true : !!row.verify_devices, verifyDevices: row.verify_devices == null ? false : !!row.verify_devices,
totpSecret: row.totp_secret ?? null, totpSecret: row.totp_secret ?? null,
totpRecoveryCode: row.totp_recovery_code ?? null, totpRecoveryCode: row.totp_recovery_code ?? null,
yubikeyKey1: row.yubikey_key1 ?? null, yubikeyKey1: row.yubikey_key1 ?? null,
+3 -1
View File
@@ -30,7 +30,9 @@ export function buildProfileResponse(user: User, env?: Env): ProfileResponse {
forcePasswordReset: false, forcePasswordReset: false,
avatarColor: null, avatarColor: null,
creationDate: user.createdAt, creationDate: user.createdAt,
verifyDevices: user.verifyDevices !== false, // New-device verification is not supported without an email delivery channel.
// Always report disabled so clients do not present a false security posture.
verifyDevices: false,
role: user.role, role: user.role,
status: user.status, status: user.status,
object: 'profile', object: 'profile',