From 1810e0aa7aa284f24f65db454ffd2a1c952b1e71 Mon Sep 17 00:00:00 2001 From: shuaiplus <2327005759@qq.com> Date: Sun, 1 Mar 2026 08:49:35 +0800 Subject: [PATCH] feat: add recovery code functionality and device management --- src/handlers/accounts.ts | 101 +++++++++- src/handlers/devices.ts | 99 +++++++++- src/handlers/identity.ts | 35 +++- src/router.ts | 37 +++- src/services/storage.ts | 67 ++++++- src/types/index.ts | 7 + src/utils/recovery-code.ts | 28 +++ webapp/src/App.tsx | 176 +++++++++++++++++- webapp/src/components/ConfirmDialog.tsx | 2 + .../src/components/RecoverTwoFactorPage.tsx | 65 +++++++ webapp/src/components/SecurityDevicesPage.tsx | 129 +++++++++++++ webapp/src/components/SettingsPage.tsx | 121 +++++++++--- webapp/src/lib/api.ts | 140 +++++++++++++- webapp/src/lib/types.ts | 13 ++ webapp/src/styles.css | 38 ++++ 15 files changed, 995 insertions(+), 63 deletions(-) create mode 100644 src/utils/recovery-code.ts create mode 100644 webapp/src/components/RecoverTwoFactorPage.tsx create mode 100644 webapp/src/components/SecurityDevicesPage.tsx diff --git a/src/handlers/accounts.ts b/src/handlers/accounts.ts index 6306cc8..ca1845a 100644 --- a/src/handlers/accounts.ts +++ b/src/handlers/accounts.ts @@ -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 { + 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; + 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; + } 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 { + const storage = new StorageService(env.DB); + const auth = new AuthService(env); + + let body: Record; + 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; + } 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 { void request; diff --git a/src/handlers/devices.ts b/src/handlers/devices.ts index 9339044..eea33c3 100644 --- a/src/handlers/devices.ts +++ b/src/handlers/devices.ts @@ -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 { + void request; + const storage = new StorageService(env.DB); + const [devices, trusted] = await Promise.all([ + storage.getDevicesByUserId(userId), + storage.getTrustedDeviceTokenSummariesByUserId(userId), + ]); + + const trustedByIdentifier = new Map(); + for (const row of trusted) { + trustedByIdentifier.set(row.deviceIdentifier, { expiresAt: row.expiresAt, tokenCount: row.tokenCount }); + } + + const knownIdentifiers = new Set(); + 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 { + 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 { + 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 { + 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. diff --git a/src/handlers/identity.ts b/src/handlers/identity.ts index d7fc0e3..9575a3c 100644 --- a/src/handlers/identity.ts +++ b/src/handlers/identity.ts @@ -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 = {}; + 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 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 // 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); diff --git a/src/router.ts b/src/router.ts index 5b44032..e6968fb 100644 --- a/src/router.ts +++ b/src/router.ts @@ -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 { 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(); @@ -243,7 +245,7 @@ export class StorageService { async getUserById(id: string): Promise { 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(); @@ -259,7 +261,7 @@ export class StorageService { async getAllUsers(): Promise { 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(); return (res.results || []).map(row => this.mapUserRow(row)); @@ -268,11 +270,11 @@ export class StorageService { async saveUser(user: User): Promise { 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 { 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 { + 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 { + 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(); + + 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 { + 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 { + 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( diff --git a/src/types/index.ts b/src/types/index.ts index faea882..2e662a6 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -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, diff --git a/src/utils/recovery-code.ts b/src/utils/recovery-code.ts new file mode 100644 index 0000000..017f083 --- /dev/null +++ b/src/utils/recovery-code.ts @@ -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); +} diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 8f16a64..b91aae3 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -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(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 { + 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 ( + <> + void handleRecoverTwoFactorSubmit()} + onCancel={() => { + setRecoverValues({ email: '', password: '', recoveryCode: '' }); + navigate('/login'); + }} + /> + 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={( +
+
+ +
+ )} > + ); @@ -815,9 +934,6 @@ export default function App() { {profile?.email}
- @@ -844,6 +960,10 @@ export default function App() { System Settings + + + Account Security + Support Center @@ -892,9 +1012,51 @@ export default function App() { await totpStatusQuery.refetch(); }} onOpenDisableTotp={() => setDisableTotpOpen(true)} + onGetRecoveryCode={getRecoveryCodeAction} + onNotify={pushToast} /> )} + + 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(); + }, + }); + }} + /> + void; onCancel: () => void; children?: ComponentChildren; + afterActions?: ComponentChildren; } export default function ConfirmDialog(props: ConfirmDialogProps) { @@ -31,6 +32,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) { + {props.afterActions} ); diff --git a/webapp/src/components/RecoverTwoFactorPage.tsx b/webapp/src/components/RecoverTwoFactorPage.tsx new file mode 100644 index 0000000..2506c1f --- /dev/null +++ b/webapp/src/components/RecoverTwoFactorPage.tsx @@ -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 ( +
+
+

Recover Two-step Login

+

Sign in with your one-time recovery code to disable two-step verification.

+ + + + + + + +
+ + +
+
+
+ ); +} diff --git a/webapp/src/components/SecurityDevicesPage.tsx b/webapp/src/components/SecurityDevicesPage.tsx new file mode 100644 index 0000000..025cb70 --- /dev/null +++ b/webapp/src/components/SecurityDevicesPage.tsx @@ -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 ( +
+
+
+
+

Account Security

+
+ Manage authorized devices and 30-day TOTP trusted sessions. +
+
+
+ + +
+
+
+ +
+

Authorized Devices

+ + + + + + + + + + + + + {props.devices.map((device) => ( + + + + + + + + + ))} + {!props.loading && props.devices.length === 0 && ( + + + + )} + +
DeviceTypeAddedLast SeenTrusted UntilActions
+
{device.name || 'Unknown device'}
+
{device.identifier}
+
{mapDeviceTypeName(device.type)}{formatDateTime(device.creationDate)}{formatDateTime(device.revisionDate)} + {device.trusted ? ( +
+ + {formatDateTime(device.trustedUntil)} +
+ ) : ( + Not trusted + )} +
+
+ + +
+
+
No devices found.
+
+
+
+ ); +} diff --git a/webapp/src/components/SettingsPage.tsx b/webapp/src/components/SettingsPage.tsx index 4b2c2f7..b8f600d 100644 --- a/webapp/src/components/SettingsPage.tsx +++ b/webapp/src/components/SettingsPage.tsx @@ -10,6 +10,8 @@ interface SettingsPageProps { onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise; onEnableTotp: (secret: string, token: string) => Promise; onOpenDisableTotp: () => void; + onGetRecoveryCode: (masterPassword: string) => Promise; + 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 { + const code = await props.onGetRecoveryCode(recoveryMasterPassword); + setRecoveryCode(code); + props.onNotify?.('success', 'Recovery code loaded'); + } + return (
@@ -112,41 +122,90 @@ export default function SettingsPage(props: SettingsPageProps) {
-

TOTP

- {totpLocked &&
TOTP is enabled for this account.
} -
-
-
-
- - -
- - - +
+
+

TOTP

+ {totpLocked &&
TOTP is enabled for this account.
} +
+
+
+
+ + +
+ + + +
+
+ +
+ +
+

Recovery Code

+

+ This is a one-time code. After it is used, a new code is generated automatically. +

+ +
+ + +
+ {recoveryCode && ( +
+
{recoveryCode}
+
+ )}
-
); diff --git a/webapp/src/lib/api.ts b/webapp/src/lib/api.ts index f962c08..82c4adb 100644 --- a/webapp/src/lib/api.ts +++ b/webapp/src/lib/api.ts @@ -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 { 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 { +export async function loginWithPassword( + email: string, + passwordHash: string, + options?: { + totpCode?: string; + rememberDevice?: boolean; + useRememberToken?: boolean; + } +): Promise { 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(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, + masterPasswordHash: string +): Promise { + 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(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(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 +): Promise { + const resp = await authedFetch('/api/devices/authorized'); + if (!resp.ok) throw new Error('Failed to load authorized devices'); + const body = await parseJson>(resp); + return body?.data || []; +} + +export async function revokeAuthorizedDeviceTrust( + authedFetch: (input: string, init?: RequestInit) => Promise, + deviceIdentifier: string +): Promise { + 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 +): Promise { + 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, + deviceIdentifier: string +): Promise { + 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): Promise { const resp = await authedFetch('/api/admin/users'); if (!resp.ok) throw new Error('Failed to load users'); diff --git a/webapp/src/lib/types.ts b/webapp/src/lib/types.ts index 0af75b1..9f1ec84 100644 --- a/webapp/src/lib/types.ts +++ b/webapp/src/lib/types.ts @@ -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; +} diff --git a/webapp/src/styles.css b/webapp/src/styles.css index a6a461f..29f3130 100644 --- a/webapp/src/styles.css +++ b/webapp/src/styles.css @@ -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; + } }