feat: add recovery code functionality and device management

This commit is contained in:
shuaiplus
2026-03-01 08:49:35 +08:00
committed by Shuai
parent 8852127743
commit 8641df3cff
15 changed files with 995 additions and 63 deletions
+169 -7
View File
@@ -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<PendingTotp | null>(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<string> {
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 (
<>
<RecoverTwoFactorPage
values={recoverValues}
onChange={setRecoverValues}
onSubmit={() => void handleRecoverTwoFactorSubmit()}
onCancel={() => {
setRecoverValues({ email: '', password: '', recoveryCode: '' });
navigate('/login');
}}
/>
<ToastHost toasts={toasts} onClose={(id) => 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={(
<div className="dialog-extra">
<div className="dialog-divider" />
<button
type="button"
className="btn btn-secondary dialog-btn"
onClick={() => {
setPendingTotp(null);
setTotpCode('');
setRememberDevice(true);
navigate('/recover-2fa');
}}
>
Use Recovery Code
</button>
</div>
)}
>
<label className="field">
<span>TOTP Code</span>
<input className="input" value={totpCode} onInput={(e) => setTotpCode((e.currentTarget as HTMLInputElement).value)} />
</label>
<label className="check-line" style={{ marginBottom: 0 }}>
<input type="checkbox" checked={rememberDevice} onChange={(e) => setRememberDevice((e.currentTarget as HTMLInputElement).checked)} />
<span>Trust this device for 30 days</span>
</label>
</ConfirmDialog>
</>
);
@@ -815,9 +934,6 @@ export default function App() {
<ShieldUser size={16} />
<span>{profile?.email}</span>
</div>
<button type="button" className="btn btn-secondary small" onClick={() => navigate('/settings')}>
<Shield size={14} className="btn-icon" /> Account Security
</button>
<button type="button" className="btn btn-secondary small" onClick={handleLogout}>
<LogOut size={14} className="btn-icon" /> Sign Out
</button>
@@ -844,6 +960,10 @@ export default function App() {
<SettingsIcon size={16} />
<span>System Settings</span>
</Link>
<Link href="/security/devices" className={`side-link ${location === '/security/devices' ? 'active' : ''}`}>
<Shield size={16} />
<span>Account Security</span>
</Link>
<Link href="/help" className={`side-link ${location === '/help' ? 'active' : ''}`}>
<CircleHelp size={16} />
<span>Support Center</span>
@@ -892,9 +1012,51 @@ export default function App() {
await totpStatusQuery.refetch();
}}
onOpenDisableTotp={() => setDisableTotpOpen(true)}
onGetRecoveryCode={getRecoveryCodeAction}
onNotify={pushToast}
/>
)}
</Route>
<Route path="/security/devices">
<SecurityDevicesPage
devices={authorizedDevicesQuery.data || []}
loading={authorizedDevicesQuery.isFetching}
onRefresh={() => 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();
},
});
}}
/>
</Route>
<Route path="/admin">
<AdminPage
currentUserId={profile?.id || ''}
+2
View File
@@ -11,6 +11,7 @@ interface ConfirmDialogProps {
onConfirm: () => void;
onCancel: () => void;
children?: ComponentChildren;
afterActions?: ComponentChildren;
}
export default function ConfirmDialog(props: ConfirmDialogProps) {
@@ -31,6 +32,7 @@ export default function ConfirmDialog(props: ConfirmDialogProps) {
<button type="button" className="btn btn-secondary dialog-btn" onClick={props.onCancel}>
{props.cancelText || 'No'}
</button>
{props.afterActions}
</div>
</div>
);
@@ -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 (
<div className="auth-page">
<div className="auth-card">
<h1>Recover Two-step Login</h1>
<p className="muted">Sign in with your one-time recovery code to disable two-step verification.</p>
<label className="field">
<span>Email</span>
<input
className="input"
type="email"
value={props.values.email}
onInput={(e) => props.onChange({ ...props.values, email: (e.currentTarget as HTMLInputElement).value })}
/>
</label>
<label className="field">
<span>Master Password</span>
<div className="password-wrap">
<input
className="input"
type={showPassword ? 'text' : 'password'}
value={props.values.password}
onInput={(e) => props.onChange({ ...props.values, password: (e.currentTarget as HTMLInputElement).value })}
/>
<button type="button" className="eye-btn" onClick={() => setShowPassword((v) => !v)}>
{showPassword ? <EyeOff size={16} /> : <Eye size={16} />}
</button>
</div>
</label>
<label className="field">
<span>Recovery Code</span>
<input
className="input"
value={props.values.recoveryCode}
onInput={(e) => props.onChange({ ...props.values, recoveryCode: (e.currentTarget as HTMLInputElement).value.toUpperCase() })}
/>
</label>
<div className="field-grid">
<button type="button" className="btn btn-primary" onClick={props.onSubmit}>
Submit
</button>
<button type="button" className="btn btn-secondary" onClick={props.onCancel}>
Cancel
</button>
</div>
</div>
</div>
);
}
@@ -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 (
<div className="stack">
<section className="card">
<div className="section-head">
<div>
<h3 style={{ margin: 0 }}>Account Security</h3>
<div className="muted-inline" style={{ marginTop: 4 }}>
Manage authorized devices and 30-day TOTP trusted sessions.
</div>
</div>
<div className="actions">
<button type="button" className="btn btn-secondary small" onClick={props.onRefresh}>
<RefreshCw size={14} className="btn-icon" />
Refresh
</button>
<button type="button" className="btn btn-danger small" onClick={props.onRevokeAll}>
<ShieldOff size={14} className="btn-icon" />
Revoke All Trusted
</button>
</div>
</div>
</section>
<section className="card">
<h3 style={{ marginTop: 0 }}>Authorized Devices</h3>
<table className="table">
<thead>
<tr>
<th>Device</th>
<th>Type</th>
<th>Added</th>
<th>Last Seen</th>
<th>Trusted Until</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{props.devices.map((device) => (
<tr key={device.identifier}>
<td>
<div>{device.name || 'Unknown device'}</div>
<div className="muted-inline">{device.identifier}</div>
</td>
<td>{mapDeviceTypeName(device.type)}</td>
<td>{formatDateTime(device.creationDate)}</td>
<td>{formatDateTime(device.revisionDate)}</td>
<td>
{device.trusted ? (
<div className="trusted-cell">
<Clock3 size={13} />
<span>{formatDateTime(device.trustedUntil)}</span>
</div>
) : (
<span className="muted-inline">Not trusted</span>
)}
</td>
<td>
<div className="actions">
<button
type="button"
className="btn btn-secondary small"
disabled={!device.trusted}
onClick={() => props.onRevokeTrust(device)}
>
<ShieldOff size={14} className="btn-icon" />
Revoke Trust
</button>
<button type="button" className="btn btn-danger small" onClick={() => props.onRemoveDevice(device)}>
<Trash2 size={14} className="btn-icon" />
Remove Device
</button>
</div>
</td>
</tr>
))}
{!props.loading && props.devices.length === 0 && (
<tr>
<td colSpan={6}>
<div className="empty" style={{ minHeight: 80 }}>No devices found.</div>
</td>
</tr>
)}
</tbody>
</table>
</section>
</div>
);
}
+90 -31
View File
@@ -10,6 +10,8 @@ interface SettingsPageProps {
onChangePassword: (currentPassword: string, nextPassword: string, nextPassword2: string) => Promise<void>;
onEnableTotp: (secret: string, token: string) => Promise<void>;
onOpenDisableTotp: () => void;
onGetRecoveryCode: (masterPassword: string) => Promise<string>;
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<void> {
const code = await props.onGetRecoveryCode(recoveryMasterPassword);
setRecoveryCode(code);
props.onNotify?.('success', 'Recovery code loaded');
}
return (
<div className="stack">
<section className="card">
@@ -112,41 +122,90 @@ export default function SettingsPage(props: SettingsPageProps) {
</section>
<section className="card">
<h3>TOTP</h3>
{totpLocked && <div className="status-ok">TOTP is enabled for this account.</div>}
<div className="totp-grid">
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
<div>
<div>
<label className="field">
<span>Authenticator Key</span>
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
</label>
<label className="field">
<span>Verification Code</span>
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
</label>
<div className="actions">
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
<ShieldCheck size={14} className="btn-icon" />
{totpLocked ? 'Enabled' : 'Enable TOTP'}
</button>
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
<RefreshCw size={14} className="btn-icon" />
Regenerate
</button>
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => navigator.clipboard.writeText(secret)}>
<Clipboard size={14} className="btn-icon" />
Copy Secret
</button>
<div className="settings-twofactor-grid">
<div className="settings-subcard">
<h3>TOTP</h3>
{totpLocked && <div className="status-ok">TOTP is enabled for this account.</div>}
<div className="totp-grid">
<div className="totp-qr" dangerouslySetInnerHTML={{ __html: qrSvg }} />
<div>
<div>
<label className="field">
<span>Authenticator Key</span>
<input className="input" value={secret} disabled={totpLocked} onInput={(e) => setSecret((e.currentTarget as HTMLInputElement).value.toUpperCase())} />
</label>
<label className="field">
<span>Verification Code</span>
<input className="input" value={token} disabled={totpLocked} onInput={(e) => setToken((e.currentTarget as HTMLInputElement).value)} />
</label>
<div className="actions">
<button type="button" className="btn btn-primary" disabled={totpLocked} onClick={() => void enableTotp()}>
<ShieldCheck size={14} className="btn-icon" />
{totpLocked ? 'Enabled' : 'Enable TOTP'}
</button>
<button type="button" className="btn btn-secondary" disabled={totpLocked} onClick={() => setSecret(randomBase32Secret(32))}>
<RefreshCw size={14} className="btn-icon" />
Regenerate
</button>
<button
type="button"
className="btn btn-secondary"
disabled={totpLocked}
onClick={() => {
void navigator.clipboard.writeText(secret);
props.onNotify?.('success', 'Secret copied');
}}
>
<Clipboard size={14} className="btn-icon" />
Copy Secret
</button>
</div>
</div>
</div>
</div>
<button type="button" className="btn btn-danger" disabled={!totpLocked} onClick={props.onOpenDisableTotp}>
<ShieldOff size={14} className="btn-icon" />
Disable TOTP
</button>
</div>
<div className="settings-subcard">
<h3>Recovery Code</h3>
<p className="muted-inline" style={{ marginBottom: 8 }}>
This is a one-time code. After it is used, a new code is generated automatically.
</p>
<label className="field">
<span>Master Password</span>
<input
className="input"
type="password"
value={recoveryMasterPassword}
onInput={(e) => setRecoveryMasterPassword((e.currentTarget as HTMLInputElement).value)}
/>
</label>
<div className="actions">
<button type="button" className="btn btn-secondary" onClick={() => void loadRecoveryCode()}>
View Recovery Code
</button>
<button
type="button"
className="btn btn-secondary"
disabled={!recoveryCode}
onClick={() => {
void navigator.clipboard.writeText(recoveryCode);
props.onNotify?.('success', 'Recovery code copied');
}}
>
Copy Code
</button>
</div>
{recoveryCode && (
<div className="card" style={{ marginTop: 10, marginBottom: 0 }}>
<div style={{ fontWeight: 800, letterSpacing: '0.08em' }}>{recoveryCode}</div>
</div>
)}
</div>
</div>
<button type="button" className="btn btn-danger" onClick={props.onOpenDisableTotp}>
<ShieldOff size={14} className="btn-icon" />
Disable TOTP
</button>
</section>
</div>
);
+137 -3
View File
@@ -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<PreloginResult> {
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<TokenSuccess | TokenError> {
export async function loginWithPassword(
email: string,
passwordHash: string,
options?: {
totpCode?: string;
rememberDevice?: boolean;
useRememberToken?: boolean;
}
): Promise<TokenSuccess | TokenError> {
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<TokenSuccess & TokenError>(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<Response>,
masterPasswordHash: string
): Promise<string> {
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<TokenError>(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<TokenError>(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<Response>
): Promise<AuthorizedDevice[]> {
const resp = await authedFetch('/api/devices/authorized');
if (!resp.ok) throw new Error('Failed to load authorized devices');
const body = await parseJson<ListResponse<AuthorizedDevice>>(resp);
return body?.data || [];
}
export async function revokeAuthorizedDeviceTrust(
authedFetch: (input: string, init?: RequestInit) => Promise<Response>,
deviceIdentifier: string
): Promise<void> {
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<Response>
): Promise<void> {
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<Response>,
deviceIdentifier: string
): Promise<void> {
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<Response>): Promise<AdminUser[]> {
const resp = await authedFetch('/api/admin/users');
if (!resp.ok) throw new Error('Failed to load users');
+13
View File
@@ -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;
}
+38
View File
@@ -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;
}
}