diff --git a/src/handlers/admin.ts b/src/handlers/admin.ts index 8fd752f..8725c61 100644 --- a/src/handlers/admin.ts +++ b/src/handlers/admin.ts @@ -9,6 +9,34 @@ function isAdmin(user: User): boolean { return user.role === 'admin' && user.status === 'active'; } +async function requireMasterPasswordHash( + env: Env, + actorUser: User, + masterPasswordHash: unknown +): Promise { + const normalized = String(masterPasswordHash || '').trim(); + if (!normalized) { + return errorResponse('masterPasswordHash is required', 400); + } + const auth = new AuthService(env); + const valid = await auth.verifyPassword(normalized, actorUser.masterPasswordHash, actorUser.email); + if (!valid) { + return errorResponse('Invalid password', 400); + } + return null; +} + +async function readJsonBody(request: Request): Promise> { + try { + const body = await request.json(); + return body && typeof body === 'object' && !Array.isArray(body) + ? body as Record + : {}; + } catch { + return {}; + } +} + function randomHex(bytes: number): string { const data = crypto.getRandomValues(new Uint8Array(bytes)); return Array.from(data).map(v => v.toString(16).padStart(2, '0')).join(''); @@ -204,14 +232,11 @@ export async function handleAdminCreateInvite( } const storage = new StorageService(env.DB); - let body: { expiresInHours?: number } = {}; - try { - body = await request.json(); - } catch { - body = {}; - } + const body = await readJsonBody(request); + const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash); + if (passwordError) return passwordError; - const expiresInHours = Number.isFinite(body.expiresInHours) + const expiresInHours = Number.isFinite(Number(body.expiresInHours)) ? Math.max(1, Math.min(24 * 30, Math.floor(Number(body.expiresInHours)))) : 24 * 7; const now = new Date(); @@ -266,6 +291,10 @@ export async function handleAdminDeleteInvite( return errorResponse('Forbidden', 403); } + const body = await readJsonBody(request); + const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash); + if (passwordError) return passwordError; + const storage = new StorageService(env.DB); const deleted = await storage.deleteInvite(code); if (!deleted) { @@ -288,6 +317,10 @@ export async function handleAdminDeleteAllInvites( return errorResponse('Forbidden', 403); } + const body = await readJsonBody(request); + const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash); + if (passwordError) return passwordError; + const storage = new StorageService(env.DB); const url = new URL(request.url); if (url.searchParams.get('scope') === 'invalid') { @@ -318,12 +351,9 @@ export async function handleAdminSetUserStatus( return errorResponse('Forbidden', 403); } - let body: { status?: string }; - try { - body = await request.json(); - } catch { - return errorResponse('Invalid JSON', 400); - } + const body = await readJsonBody(request); + const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash); + if (passwordError) return passwordError; const nextStatus = body.status === 'banned' ? 'banned' : body.status === 'active' ? 'active' : null; if (!nextStatus) { @@ -366,7 +396,6 @@ export async function handleAdminDeleteUser( actorUser: User, targetUserId: string ): Promise { - void request; if (!isAdmin(actorUser)) { return errorResponse('Forbidden', 403); } @@ -374,6 +403,10 @@ export async function handleAdminDeleteUser( return errorResponse('You cannot delete yourself', 400); } + const body = await readJsonBody(request); + const passwordError = await requireMasterPasswordHash(env, actorUser, body.masterPasswordHash); + if (passwordError) return passwordError; + const storage = new StorageService(env.DB); const target = await storage.getUserById(targetUserId); if (!target) { diff --git a/src/handlers/devices.ts b/src/handlers/devices.ts index 5eb38d9..23bb044 100644 --- a/src/handlers/devices.ts +++ b/src/handlers/devices.ts @@ -464,11 +464,26 @@ export async function handleUpdateDeviceName( // DELETE /api/devices export async function handleDeleteAllDevices(request: Request, env: Env, userId: string): Promise { - void request; const storage = new StorageService(env.DB); const user = await storage.getUserById(userId); if (!user) return errorResponse('User not found', 404); + let masterPasswordHash = ''; + try { + const body = await request.json() as { masterPasswordHash?: string }; + masterPasswordHash = String(body?.masterPasswordHash || '').trim(); + } catch { + masterPasswordHash = ''; + } + if (!masterPasswordHash) { + return errorResponse('masterPasswordHash is required', 400); + } + const auth = new AuthService(env); + const passwordValid = await auth.verifyPassword(masterPasswordHash, user.masterPasswordHash, user.email); + if (!passwordValid) { + return errorResponse('Invalid password', 400); + } + const [removedTrusted, removedSessions, removedDevices] = await Promise.all([ storage.deleteTrustedTwoFactorTokensByUserId(userId), storage.deleteRefreshTokensByUserId(userId), diff --git a/webapp/src/App.tsx b/webapp/src/App.tsx index 8073712..0d00f89 100644 --- a/webapp/src/App.tsx +++ b/webapp/src/App.tsx @@ -1856,6 +1856,8 @@ export default function App() { }); const adminActions = useAdminActions({ authedFetch, + email: String(profile?.email || session?.email || ''), + defaultKdfIterations, onNotify: pushToast, onSetConfirm: setConfirm, refetchUsers: usersQuery.refetch, diff --git a/webapp/src/components/AppGlobalOverlays.tsx b/webapp/src/components/AppGlobalOverlays.tsx index 0d33d68..bbc7807 100644 --- a/webapp/src/components/AppGlobalOverlays.tsx +++ b/webapp/src/components/AppGlobalOverlays.tsx @@ -12,7 +12,9 @@ export interface AppConfirmState { confirmText?: string; cancelText?: string; hideCancel?: boolean; - onConfirm: () => void; + /** When true, dialog shows a master-password field and passes it to onConfirm. */ + requireMasterPassword?: boolean; + onConfirm: (masterPassword?: string) => void; onCancel?: () => void; } @@ -63,6 +65,7 @@ function twoFactorProviderLabel(providerType: number): string { export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { const [methodChooserOpen, setMethodChooserOpen] = useState(false); + const [confirmPassword, setConfirmPassword] = useState(''); const availableProviders = useMemo( () => uniqueSupportedProviders(props.pendingTotpAvailableProviders), [props.pendingTotpAvailableProviders] @@ -70,11 +73,16 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) { const alternateProviders = availableProviders.filter((provider) => provider !== props.pendingTotpProviderType); const isYubiKeyOtp = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_YUBIKEY; const isWebAuthn = props.pendingTotpProviderType === TWO_FACTOR_PROVIDER_WEBAUTHN; + const requireMasterPassword = !!props.confirm?.requireMasterPassword; useEffect(() => { setMethodChooserOpen(false); }, [props.pendingTotpOpen, props.pendingTotpProviderType]); + useEffect(() => { + setConfirmPassword(''); + }, [props.confirm?.title, props.confirm?.message, requireMasterPassword]); + return ( <> props.confirm?.onConfirm()} - onCancel={props.confirm?.onCancel || props.onCancelConfirm} - /> + confirmDisabled={requireMasterPassword && !confirmPassword.trim()} + onConfirm={() => { + if (requireMasterPassword && !confirmPassword.trim()) return; + props.confirm?.onConfirm(requireMasterPassword ? confirmPassword : undefined); + setConfirmPassword(''); + }} + onCancel={() => { + setConfirmPassword(''); + (props.confirm?.onCancel || props.onCancelConfirm)(); + }} + > + {requireMasterPassword && ( + + )} + { + requireMasterPassword: true, + onConfirm: (masterPassword) => { onSetConfirm(null); void (async () => { try { - await deleteAllAuthorizedDevices(authedFetch); + if (!profile) throw new Error(t('txt_profile_unavailable')); + const normalizedPassword = String(masterPassword || ''); + if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required')); + const derived = await deriveLoginHash(profile.email, normalizedPassword, defaultKdfIterations); + await deleteAllAuthorizedDevices(authedFetch, derived.hash); onNotify('success', t('txt_all_devices_removed')); onLogoutNow(); } catch (error) { diff --git a/webapp/src/hooks/useAdminActions.ts b/webapp/src/hooks/useAdminActions.ts index 8104601..83b15f5 100644 --- a/webapp/src/hooks/useAdminActions.ts +++ b/webapp/src/hooks/useAdminActions.ts @@ -1,5 +1,6 @@ import { useMemo } from 'preact/hooks'; import { createInvite, deleteAllInvites, deleteInvalidInvites, deleteInvite, deleteUser, setUserStatus } from '@/lib/api/admin'; +import { deriveLoginHash } from '@/lib/api/auth'; import { t } from '@/lib/i18n'; import type { AppConfirmState } from '@/components/AppGlobalOverlays'; import type { AuthedFetch } from '@/lib/api/shared'; @@ -8,6 +9,8 @@ type Notify = (type: 'success' | 'error' | 'warning', text: string) => void; interface UseAdminActionsOptions { authedFetch: AuthedFetch; + email: string; + defaultKdfIterations: number; onNotify: Notify; onSetConfirm: (next: AppConfirmState | null) => void; refetchUsers: () => Promise; @@ -15,7 +18,24 @@ interface UseAdminActionsOptions { } export default function useAdminActions(options: UseAdminActionsOptions) { - const { authedFetch, onNotify, onSetConfirm, refetchUsers, refetchInvites } = options; + const { + authedFetch, + email, + defaultKdfIterations, + onNotify, + onSetConfirm, + refetchUsers, + refetchInvites, + } = options; + + async function withMasterPasswordHash(masterPassword: string | undefined): Promise { + const normalizedEmail = String(email || '').trim().toLowerCase(); + const normalizedPassword = String(masterPassword || ''); + if (!normalizedEmail) throw new Error(t('txt_profile_unavailable')); + if (!normalizedPassword.trim()) throw new Error(t('txt_master_password_is_required')); + const derived = await deriveLoginHash(normalizedEmail, normalizedPassword, defaultKdfIterations); + return derived.hash; + } return useMemo( () => ({ @@ -26,35 +46,61 @@ export default function useAdminActions(options: UseAdminActionsOptions) { }, async createInvite(hours: number) { - try { - await createInvite(authedFetch, hours); - await refetchInvites(); - onNotify('success', t('txt_invite_created')); - } catch (error) { - onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed')); - } + onSetConfirm({ + title: t('txt_create_timed_invite'), + message: t('txt_enter_master_password_to_continue'), + requireMasterPassword: true, + onConfirm: (masterPassword) => { + onSetConfirm(null); + void (async () => { + try { + const hash = await withMasterPasswordHash(masterPassword); + await createInvite(authedFetch, hours, hash); + await refetchInvites(); + onNotify('success', t('txt_invite_created')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_create_invite_failed')); + } + })(); + }, + }); }, async toggleUserStatus(userId: string, status: 'active' | 'banned') { - try { - await setUserStatus(authedFetch, userId, status === 'active' ? 'banned' : 'active'); - await refetchUsers(); - onNotify('success', t('txt_user_status_updated')); - } catch (error) { - onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed')); - } + const nextStatus = status === 'active' ? 'banned' : 'active'; + onSetConfirm({ + title: nextStatus === 'banned' ? t('txt_ban') : t('txt_unban'), + message: t('txt_enter_master_password_to_continue'), + danger: nextStatus === 'banned', + requireMasterPassword: true, + onConfirm: (masterPassword) => { + onSetConfirm(null); + void (async () => { + try { + const hash = await withMasterPasswordHash(masterPassword); + await setUserStatus(authedFetch, userId, nextStatus, hash); + await refetchUsers(); + onNotify('success', t('txt_user_status_updated')); + } catch (error) { + onNotify('error', error instanceof Error ? error.message : t('txt_update_user_status_failed')); + } + })(); + }, + }); }, async deleteInvite(code: string) { onSetConfirm({ title: t('txt_delete_invite'), - message: t('txt_delete_invite_confirm_message'), + message: `${t('txt_delete_invite_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`, danger: true, - onConfirm: () => { + requireMasterPassword: true, + onConfirm: (masterPassword) => { onSetConfirm(null); void (async () => { try { - await deleteInvite(authedFetch, code); + const hash = await withMasterPasswordHash(masterPassword); + await deleteInvite(authedFetch, code, hash); await refetchInvites(); onNotify('success', t('txt_invite_deleted')); } catch (error) { @@ -68,13 +114,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) { async deleteInvalidInvites() { onSetConfirm({ title: t('txt_delete_invalid_invites'), - message: t('txt_delete_invalid_invites_confirm_message'), + message: `${t('txt_delete_invalid_invites_confirm_message')}\n${t('txt_enter_master_password_to_continue')}`, danger: true, - onConfirm: () => { + requireMasterPassword: true, + onConfirm: (masterPassword) => { onSetConfirm(null); void (async () => { try { - await deleteInvalidInvites(authedFetch); + const hash = await withMasterPasswordHash(masterPassword); + await deleteInvalidInvites(authedFetch, hash); await refetchInvites(); onNotify('success', t('txt_invalid_invites_deleted')); } catch (error) { @@ -88,13 +136,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) { async deleteAllInvites() { onSetConfirm({ title: t('txt_delete_all_invites'), - message: t('txt_delete_all_invite_codes_active_inactive'), + message: `${t('txt_delete_all_invite_codes_active_inactive')}\n${t('txt_enter_master_password_to_continue')}`, danger: true, - onConfirm: () => { + requireMasterPassword: true, + onConfirm: (masterPassword) => { onSetConfirm(null); void (async () => { try { - await deleteAllInvites(authedFetch); + const hash = await withMasterPasswordHash(masterPassword); + await deleteAllInvites(authedFetch, hash); await refetchInvites(); onNotify('success', t('txt_all_invites_deleted')); } catch (error) { @@ -108,13 +158,15 @@ export default function useAdminActions(options: UseAdminActionsOptions) { async deleteUser(userId: string) { onSetConfirm({ title: t('txt_delete_user'), - message: t('txt_delete_this_user_and_all_user_data'), + message: `${t('txt_delete_this_user_and_all_user_data')}\n${t('txt_enter_master_password_to_continue')}`, danger: true, - onConfirm: () => { + requireMasterPassword: true, + onConfirm: (masterPassword) => { onSetConfirm(null); void (async () => { try { - await deleteUser(authedFetch, userId); + const hash = await withMasterPasswordHash(masterPassword); + await deleteUser(authedFetch, userId, hash); await refetchUsers(); onNotify('success', t('txt_user_deleted')); } catch (error) { @@ -125,6 +177,6 @@ export default function useAdminActions(options: UseAdminActionsOptions) { }); }, }), - [authedFetch, onNotify, onSetConfirm, refetchInvites, refetchUsers] + [authedFetch, defaultKdfIterations, email, onNotify, onSetConfirm, refetchInvites, refetchUsers] ); } diff --git a/webapp/src/lib/api/admin.ts b/webapp/src/lib/api/admin.ts index 8e9b07d..c7506ed 100644 --- a/webapp/src/lib/api/admin.ts +++ b/webapp/src/lib/api/admin.ts @@ -15,45 +15,62 @@ export async function listAdminInvites(authedFetch: AuthedFetch): Promise { +export async function createInvite(authedFetch: AuthedFetch, hours: number, masterPasswordHash: string): Promise { const resp = await authedFetch('/api/admin/invites', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ expiresInHours: hours }), + body: JSON.stringify({ expiresInHours: hours, masterPasswordHash }), }); if (!resp.ok) throw new Error('Create invite failed'); } -export async function deleteInvite(authedFetch: AuthedFetch, code: string): Promise { - const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' }); +export async function deleteInvite(authedFetch: AuthedFetch, code: string, masterPasswordHash: string): Promise { + const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash }), + }); if (!resp.ok) throw new Error('Delete invite failed'); } -export async function deleteInvalidInvites(authedFetch: AuthedFetch): Promise { - const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' }); +export async function deleteInvalidInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { + const resp = await authedFetch('/api/admin/invites?scope=invalid', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash }), + }); if (!resp.ok) throw new Error('Delete invalid invites failed'); } -export async function deleteAllInvites(authedFetch: AuthedFetch): Promise { - const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' }); +export async function deleteAllInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { + const resp = await authedFetch('/api/admin/invites', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash }), + }); if (!resp.ok) throw new Error('Delete all invites failed'); } export async function setUserStatus( authedFetch: AuthedFetch, userId: string, - status: 'active' | 'banned' + status: 'active' | 'banned', + masterPasswordHash: string ): Promise { const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}/status`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status }), + body: JSON.stringify({ status, masterPasswordHash }), }); if (!resp.ok) throw new Error('Update user status failed'); } -export async function deleteUser(authedFetch: AuthedFetch, userId: string): Promise { - const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' }); +export async function deleteUser(authedFetch: AuthedFetch, userId: string, masterPasswordHash: string): Promise { + const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash }), + }); if (!resp.ok) throw new Error('Delete user failed'); } diff --git a/webapp/src/lib/api/auth.ts b/webapp/src/lib/api/auth.ts index 81a9e3f..303c2a2 100644 --- a/webapp/src/lib/api/auth.ts +++ b/webapp/src/lib/api/auth.ts @@ -1140,8 +1140,12 @@ export async function updateAuthorizedDeviceName( if (!resp.ok) throw new Error(t('txt_update_device_note_failed')); } -export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch): Promise { - const resp = await authedFetch('/api/devices', { method: 'DELETE' }); +export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch, masterPasswordHash: string): Promise { + const resp = await authedFetch('/api/devices', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ masterPasswordHash }), + }); if (!resp.ok) throw new Error(t('txt_remove_all_devices_failed')); }