feat: implement device login approval system

Add a complete device authentication approval flow that allows users to approve login requests from new devices on their already-authenticated devices.

Core features:
- Create authentication requests when logging in from new devices
- Display pending requests with device info, IP address, and fingerprint phrases
- Approve or deny requests from web interface with real-time notifications
- Support multiple auth request types (authenticate & unlock, unlock only)
- Automatic expiration and cleanup of stale requests

Backend changes:
- Add auth_requests table with proper indexes for efficient queries
- Implement full CRUD API for authentication requests
- Add notification hub integration for real-time updates
- Add device fingerprint phrase generation for security verification

Frontend changes:
- Add AuthRequestApprovalDialog component for approving/denying requests
- Add PendingAuthRequestsPanel component to display and manage pending requests
- Integrate panels into Security and Settings pages
- Add fingerprint wordlist for generating human-readable verification phrases
- Update i18n translations for all supported languages

Security considerations:
- Access code verification to prevent unauthorized access
- Device fingerprint validation for additional security layer
- IP address and country tracking for audit purposes
- Automatic expiration of old requests (15 minutes)
- Only most recent request per device can be approved

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
shuaiplus
2026-06-12 13:12:11 +08:00
parent e9aef72df7
commit c652cc1533
27 changed files with 9187 additions and 92 deletions
@@ -47,6 +47,9 @@ function isAdminProfile(profile: Profile | null): boolean {
return String(profile?.role || '').toLowerCase() === 'admin';
}
const DEVICE_MANAGEMENT_ROUTE = '/settings/security/device-management';
const LEGACY_DEVICE_MANAGEMENT_ROUTE = '/security/devices';
export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps) {
const routeAnimationKey = props.isImportRoute ? props.importRoute : props.location;
const isDomainRulesRoute = props.location === '/settings/domain-rules';
@@ -55,7 +58,8 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
const vaultActive = props.location === '/vault' || props.location === '/vault/totp';
const settingsActive = props.location === props.settingsAccountRoute || props.location === '/settings/domain-rules';
const dataActive = props.location === '/backup' || props.isImportRoute;
const managementActive = props.location === '/admin' || props.location === '/security/devices' || props.location === '/logs';
const deviceManagementActive = props.location === DEVICE_MANAGEMENT_ROUTE || props.location === LEGACY_DEVICE_MANAGEMENT_ROUTE;
const managementActive = props.location === '/admin' || deviceManagementActive || props.location === '/logs';
const [navLayoutMode, setNavLayoutMode] = useState<NavLayoutMode>(readNavLayoutMode);
const [navLayoutPickerOpen, setNavLayoutPickerOpen] = useState(false);
const navLayoutPickerRef = useRef<HTMLDivElement | null>(null);
@@ -177,7 +181,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
{renderSideLink(props.importRoute, props.isImportRoute, <ArrowUpDown size={16} />, t('nav_import_export'))}
{isAdmin && renderSideLink('/admin', props.location === '/admin', <Users size={16} />, t('nav_admin_panel'))}
{isAdmin && renderSideLink('/logs', props.location === '/logs', <FileClock size={16} />, t('nav_log_center'))}
{renderSideLink('/security/devices', props.location === '/security/devices', <MonitorSmartphone size={16} />, t('nav_device_management'))}
{renderSideLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, <MonitorSmartphone size={16} />, t('nav_device_management'))}
</>
);
@@ -222,7 +226,7 @@ export default function AppAuthenticatedShell(props: AppAuthenticatedShellProps)
<>
{isAdmin && renderSubLink('/admin', props.location === '/admin', t('nav_admin_panel'))}
{isAdmin && renderSubLink('/logs', props.location === '/logs', t('nav_log_center'))}
{renderSubLink('/security/devices', props.location === '/security/devices', t('nav_device_management'))}
{renderSubLink(DEVICE_MANAGEMENT_ROUTE, deviceManagementActive, t('nav_device_management'))}
</>
)}
</>
+46 -28
View File
@@ -8,7 +8,7 @@ import type { AdminBackupImportResponse, AdminBackupRunResponse, AdminBackupSett
import type { AuditLogFilters } from '@/lib/api/admin';
import type { CiphersImportPayload } from '@/lib/api/vault';
import { t } from '@/lib/i18n';
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
import type { AccountPasskeyCredential, AdminInvite, AdminUser, AuditLogListResult, AuditLogSettings, AuthRequest, AuthorizedDevice, Cipher, CustomEquivalentDomain, DomainRules, Folder as VaultFolder, Profile, Send, SendDraft, SessionState, VaultDraft } from '@/lib/types';
import type { ExportRequest } from '@/lib/export-formats';
const VaultPage = lazy(() => import('@/components/VaultPage'));
@@ -116,6 +116,11 @@ export interface AppMainRoutesProps {
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onRefreshAuthorizedDevices: () => Promise<void>;
@@ -153,6 +158,7 @@ export interface AppMainRoutesProps {
export default function AppMainRoutes(props: AppMainRoutesProps) {
const importRoutePaths = [props.importRoute, '/tools/import', '/tools/import-export', '/tools/import-data', '/import', '/import-export'] as const;
const deviceManagementRoutePaths = ['/security/devices', '/settings/security/device-management'] as const;
const isAdmin = String(props.profile?.role || '').toLowerCase() === 'admin';
const importPageContent = (
<Suspense fallback={<RouteContentFallback />}>
@@ -269,6 +275,11 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
onCreateAccountPasskey={props.onCreateAccountPasskey}
onEnableAccountPasskeyDirectUnlock={props.onEnableAccountPasskeyDirectUnlock}
onDeleteAccountPasskey={props.onDeleteAccountPasskey}
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
onLockTimeoutChange={props.onLockTimeoutChange}
onSessionTimeoutActionChange={props.onSessionTimeoutActionChange}
onNotify={props.onNotify}
@@ -287,7 +298,7 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<SettingsIcon size={18} />
<span>{t('nav_account_settings')}</span>
</Link>
<Link href="/security/devices" className="mobile-settings-link">
<Link href="/settings/security/device-management" className="mobile-settings-link">
<Shield size={18} />
<span>{t('nav_device_management')}</span>
</Link>
@@ -327,32 +338,39 @@ export default function AppMainRoutes(props: AppMainRoutesProps) {
<LoadingState card lines={4} />
) : null}
</Route>
<Route path="/security/devices">
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
)}
<Suspense fallback={<RouteContentFallback />}>
<SecurityDevicesPage
devices={props.authorizedDevices}
loading={props.authorizedDevicesLoading}
error={props.authorizedDevicesError}
onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRenameDevice={props.onRenameAuthorizedDevice}
onRevokeTrust={props.onRevokeDeviceTrust}
onTrustPermanently={props.onTrustDevicePermanently}
onRemoveDevice={props.onRemoveDevice}
onRevokeAll={props.onRevokeAllDeviceTrust}
onRemoveAll={props.onRemoveAllDevices}
/>
</Suspense>
</div>
</Route>
{deviceManagementRoutePaths.map((path) => (
<Route key={path} path={path}>
<div className="stack">
{props.mobileLayout && (
<div className="mobile-settings-subhead">
<button type="button" className="btn btn-secondary small mobile-settings-back" onClick={() => props.onNavigate(props.settingsHomeRoute)}>
<span className="btn-icon" aria-hidden="true">{"<"}</span>
{t('txt_back')}
</button>
</div>
)}
<Suspense fallback={<RouteContentFallback />}>
<SecurityDevicesPage
devices={props.authorizedDevices}
loading={props.authorizedDevicesLoading}
error={props.authorizedDevicesError}
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefresh={() => void props.onRefreshAuthorizedDevices()}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
onRenameDevice={props.onRenameAuthorizedDevice}
onRevokeTrust={props.onRevokeDeviceTrust}
onTrustPermanently={props.onTrustDevicePermanently}
onRemoveDevice={props.onRemoveDevice}
onRevokeAll={props.onRevokeAllDeviceTrust}
onRemoveAll={props.onRemoveAllDevices}
/>
</Suspense>
</div>
</Route>
))}
<Route path="/settings/domain-rules">
<div className="stack domain-rules-route">
{props.mobileLayout && (
@@ -0,0 +1,74 @@
import { ShieldCheck, ShieldX } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import { t } from '@/lib/i18n';
import type { AuthRequest } from '@/lib/types';
interface AuthRequestApprovalDialogProps {
open: boolean;
authRequest: AuthRequest | null;
submitting: boolean;
onApprove: () => void;
onDeny: () => void;
onClose: () => void;
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString();
}
export default function AuthRequestApprovalDialog(props: AuthRequestApprovalDialogProps) {
const authRequest = props.authRequest;
return (
<ConfirmDialog
open={props.open && !!authRequest}
title={t('txt_approve_device_login')}
message={t('txt_auth_request_approve_message')}
confirmText={props.submitting ? t('txt_approving') : t('txt_approve')}
cancelText={t('txt_later')}
confirmDisabled={props.submitting || !authRequest}
cancelDisabled={props.submitting}
onConfirm={props.onApprove}
onCancel={props.onClose}
afterActions={(
<button
type="button"
className="btn btn-danger dialog-btn"
disabled={props.submitting || !authRequest}
onClick={props.onDeny}
>
<ShieldX size={14} className="btn-icon" />
{t('txt_deny')}
</button>
)}
>
{authRequest && (
<div className="auth-request-details">
<div className="auth-request-device">
<ShieldCheck size={18} />
<div>
<strong>{authRequest.requestDeviceType || t('txt_unknown_device')}</strong>
<small>{authRequest.requestDeviceIdentifier}</small>
</div>
</div>
<div className="auth-request-kv">
<span>{t('txt_created')}</span>
<strong>{formatDateTime(authRequest.creationDate)}</strong>
</div>
{authRequest.requestIpAddress && (
<div className="auth-request-kv">
<span>{t('txt_ip_address')}</span>
<strong>{authRequest.requestIpAddress}</strong>
</div>
)}
<div className="auth-request-fingerprint">
<span>{t('txt_fingerprint_phrase')}</span>
<strong>{authRequest.fingerprintPhrase || t('txt_dash')}</strong>
</div>
</div>
)}
</ConfirmDialog>
);
}
@@ -0,0 +1,112 @@
import { useState } from 'preact/hooks';
import { RefreshCw, ShieldCheck, ShieldX } from 'lucide-preact';
import LoadingState from '@/components/LoadingState';
import type { AuthRequest } from '@/lib/types';
import { t } from '@/lib/i18n';
interface PendingAuthRequestsPanelProps {
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
className?: string;
loadingVariant?: 'placeholder' | 'compact';
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const date = new Date(value);
return Number.isNaN(date.getTime()) ? t('txt_dash') : date.toLocaleString();
}
export default function PendingAuthRequestsPanel(props: PendingAuthRequestsPanelProps) {
const [authRequestSubmittingId, setAuthRequestSubmittingId] = useState<string | null>(null);
async function approveAuthRequest(authRequest: AuthRequest): Promise<void> {
if (authRequestSubmittingId) return;
setAuthRequestSubmittingId(authRequest.id);
try {
await props.onApproveAuthRequest(authRequest);
} finally {
setAuthRequestSubmittingId(null);
}
}
async function denyAuthRequest(authRequest: AuthRequest): Promise<void> {
if (authRequestSubmittingId) return;
setAuthRequestSubmittingId(authRequest.id);
try {
await props.onDenyAuthRequest(authRequest);
} finally {
setAuthRequestSubmittingId(null);
}
}
return (
<section className={props.className || 'card settings-module'}>
<div className="settings-module-head">
<h3>{t('txt_pending_device_logins')}</h3>
<button
type="button"
className="btn btn-secondary small"
disabled={props.pendingAuthRequestsLoading}
onClick={() => void props.onRefreshPendingAuthRequests()}
>
<RefreshCw size={14} className="btn-icon" />
{t('txt_refresh')}
</button>
</div>
<div className="account-passkeys-list">
{props.pendingAuthRequestsLoading && props.pendingAuthRequests.length === 0 ? (
props.loadingVariant === 'compact' ? (
<LoadingState lines={2} compact />
) : (
<div className="settings-module-placeholder">
<RefreshCw size={20} />
<span>{t('txt_loading')}</span>
</div>
)
) : props.pendingAuthRequests.length === 0 ? (
<div className="settings-module-placeholder">
<ShieldCheck size={20} />
<span>{t('txt_no_pending_device_logins')}</span>
</div>
) : (
props.pendingAuthRequests.map((authRequest) => (
<div key={authRequest.id} className="account-passkey-row auth-request-row">
<div className="account-passkey-main">
<strong>{authRequest.requestDeviceType || t('txt_unknown_device')}</strong>
<small>{authRequest.requestDeviceIdentifier}</small>
<small>{t('txt_created_value', { value: formatDateTime(authRequest.creationDate) })}</small>
</div>
<span className="auth-request-fingerprint-inline">
{authRequest.fingerprintPhrase || t('txt_dash')}
</span>
<div className="actions account-passkey-actions">
<button
type="button"
className="btn btn-primary small"
disabled={!!authRequestSubmittingId}
onClick={() => void approveAuthRequest(authRequest)}
>
<ShieldCheck size={14} className="btn-icon" />
{authRequestSubmittingId === authRequest.id ? t('txt_approving') : t('txt_approve')}
</button>
<button
type="button"
className="btn btn-danger small"
disabled={!!authRequestSubmittingId}
onClick={() => void denyAuthRequest(authRequest)}
>
<ShieldX size={14} className="btn-icon" />
{t('txt_deny')}
</button>
</div>
</div>
))
)}
</div>
</section>
);
}
+17 -1
View File
@@ -2,14 +2,20 @@ import { useState } from 'preact/hooks';
import { Clock3, Pencil, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import ConfirmDialog from '@/components/ConfirmDialog';
import LoadingState from '@/components/LoadingState';
import type { AuthorizedDevice } from '@/lib/types';
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
import type { AuthRequest, AuthorizedDevice } from '@/lib/types';
import { t } from '@/lib/i18n';
interface SecurityDevicesPageProps {
devices: AuthorizedDevice[];
loading: boolean;
error: string;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefresh: () => void;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
onRenameDevice: (device: AuthorizedDevice, name: string) => Promise<void>;
onRevokeTrust: (device: AuthorizedDevice) => void;
onTrustPermanently: (device: AuthorizedDevice) => void;
@@ -72,6 +78,16 @@ export default function SecurityDevicesPage(props: SecurityDevicesPageProps) {
return (
<>
<div className="stack">
<PendingAuthRequestsPanel
className="card"
loadingVariant="compact"
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
/>
<section className="card">
<div className="section-head">
<div>
+15 -8
View File
@@ -2,9 +2,10 @@ import { useEffect, useMemo, useState } from 'preact/hooks';
import { Clipboard, KeyRound, RefreshCw, ShieldCheck, ShieldOff, Trash2 } from 'lucide-preact';
import { copyTextToClipboard } from '@/lib/clipboard';
import qrcode from 'qrcode-generator';
import type { AccountPasskeyCredential, Profile } from '@/lib/types';
import type { AccountPasskeyCredential, AuthRequest, Profile } from '@/lib/types';
import { AVAILABLE_LOCALES, getLocale, setLocale, t, type Locale } from '@/lib/i18n';
import ConfirmDialog from '@/components/ConfirmDialog';
import PendingAuthRequestsPanel from '@/components/PendingAuthRequestsPanel';
interface SettingsPageProps {
profile: Profile;
@@ -22,6 +23,11 @@ interface SettingsPageProps {
onCreateAccountPasskey: (name: string, masterPassword: string, directUnlock: boolean) => Promise<AccountPasskeyCredential | null>;
onEnableAccountPasskeyDirectUnlock: (id: string, masterPassword: string) => Promise<void>;
onDeleteAccountPasskey: (id: string, masterPassword: string) => Promise<void>;
pendingAuthRequests: AuthRequest[];
pendingAuthRequestsLoading: boolean;
onRefreshPendingAuthRequests: () => Promise<void>;
onApproveAuthRequest: (request: AuthRequest) => Promise<void>;
onDenyAuthRequest: (request: AuthRequest) => Promise<void>;
onLockTimeoutChange: (minutes: 0 | 1 | 5 | 15 | 30) => void;
onSessionTimeoutActionChange: (action: 'lock' | 'logout') => void;
onNotify?: (type: 'success' | 'error' | 'warning', text: string) => void;
@@ -219,13 +225,6 @@ export default function SettingsPage(props: SettingsPageProps) {
return t('txt_prf_not_supported');
}
function formatDateTime(value: string | null | undefined): string {
if (!value) return t('txt_dash');
const parsed = new Date(value);
if (Number.isNaN(parsed.getTime())) return value;
return parsed.toLocaleString();
}
async function changeLocale(next: Locale): Promise<void> {
if (next === getLocale()) return;
setSelectedLocale(next);
@@ -504,6 +503,14 @@ export default function SettingsPage(props: SettingsPageProps) {
</div>
</section>
<PendingAuthRequestsPanel
pendingAuthRequests={props.pendingAuthRequests}
pendingAuthRequestsLoading={props.pendingAuthRequestsLoading}
onRefreshPendingAuthRequests={props.onRefreshPendingAuthRequests}
onApproveAuthRequest={props.onApproveAuthRequest}
onDenyAuthRequest={props.onDenyAuthRequest}
/>
<section className="settings-module sensitive-actions-module">
<div className="sensitive-actions-grid">
<div className="sensitive-action">