fix: require master password for admin and wipe-device actions

Gate invite management, user ban/delete, and delete-all-devices behind
masterPasswordHash verification, matching backup step-up auth. The web UI
prompts for the master password in the shared confirm dialog.
This commit is contained in:
shuaiplus
2026-07-12 20:43:27 +08:00
parent 3c581d1fb1
commit fa611dc843
8 changed files with 221 additions and 64 deletions
+2
View File
@@ -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,
+33 -4
View File
@@ -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 (
<>
<ConfirmDialog
@@ -86,9 +94,30 @@ export default function AppGlobalOverlays(props: AppGlobalOverlaysProps) {
confirmText={props.confirm?.confirmText}
cancelText={props.confirm?.cancelText}
hideCancel={props.confirm?.hideCancel}
onConfirm={() => 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 && (
<label className="field">
<span>{t('txt_master_password')}</span>
<input
className="input"
type="password"
autoComplete="current-password"
value={confirmPassword}
onInput={(e) => setConfirmPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
)}
</ConfirmDialog>
<ConfirmDialog
open={props.pendingTotpOpen}
@@ -561,13 +561,18 @@ export default function useAccountSecurityActions(options: UseAccountSecurityAct
openRemoveAllDevices() {
onSetConfirm({
title: t('txt_remove_all_devices'),
message: t('txt_remove_all_devices_and_sign_out_all_sessions'),
message: `${t('txt_remove_all_devices_and_sign_out_all_sessions')}\n${t('txt_enter_master_password_to_continue')}`,
danger: true,
onConfirm: () => {
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) {
+80 -28
View File
@@ -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<unknown>;
@@ -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<string> {
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]
);
}
+29 -12
View File
@@ -15,45 +15,62 @@ export async function listAdminInvites(authedFetch: AuthedFetch): Promise<AdminI
return body?.data || [];
}
export async function createInvite(authedFetch: AuthedFetch, hours: number): Promise<void> {
export async function createInvite(authedFetch: AuthedFetch, hours: number, masterPasswordHash: string): Promise<void> {
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<void> {
const resp = await authedFetch(`/api/admin/invites/${encodeURIComponent(code)}`, { method: 'DELETE' });
export async function deleteInvite(authedFetch: AuthedFetch, code: string, masterPasswordHash: string): Promise<void> {
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<void> {
const resp = await authedFetch('/api/admin/invites?scope=invalid', { method: 'DELETE' });
export async function deleteInvalidInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
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<void> {
const resp = await authedFetch('/api/admin/invites', { method: 'DELETE' });
export async function deleteAllInvites(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
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<void> {
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<void> {
const resp = await authedFetch(`/api/admin/users/${encodeURIComponent(userId)}`, { method: 'DELETE' });
export async function deleteUser(authedFetch: AuthedFetch, userId: string, masterPasswordHash: string): Promise<void> {
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');
}
+6 -2
View File
@@ -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<void> {
const resp = await authedFetch('/api/devices', { method: 'DELETE' });
export async function deleteAllAuthorizedDevices(authedFetch: AuthedFetch, masterPasswordHash: string): Promise<void> {
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'));
}