mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 21:00:41 +00:00
feat: add PWA offline unlock support
This commit is contained in:
@@ -54,6 +54,7 @@ import { useToastManager } from '@/hooks/useToastManager';
|
||||
import { t } from '@/lib/i18n';
|
||||
import { APP_NOTIFY_EVENT, type AppNotifyDetail } from '@/lib/app-notify';
|
||||
import { dispatchBackupProgress, type BackupProgressDetail } from '@/lib/backup-restore-progress';
|
||||
import { clearOfflineUnlockRecord } from '@/lib/offline-auth';
|
||||
import { decryptSends, decryptVaultCore } from '@/lib/vault-decrypt';
|
||||
import { decryptSendsInWorker, decryptVaultCoreInWorker } from '@/lib/vault-worker';
|
||||
import {
|
||||
@@ -746,6 +747,7 @@ export default function App() {
|
||||
setConfirm(null);
|
||||
setSession(null);
|
||||
clearProfileSnapshot();
|
||||
clearOfflineUnlockRecord();
|
||||
setProfile(null);
|
||||
setUnlockPreparing(false);
|
||||
setPendingTotp(null);
|
||||
|
||||
@@ -95,6 +95,12 @@ export function loadSession(): SessionState | null {
|
||||
authMode: 'web-cookie',
|
||||
};
|
||||
}
|
||||
if (parsed.authMode === 'token' && parsed.email && !parsed.accessToken && !parsed.refreshToken) {
|
||||
return {
|
||||
email: parsed.email,
|
||||
authMode: 'token',
|
||||
};
|
||||
}
|
||||
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
|
||||
return {
|
||||
accessToken: parsed.accessToken,
|
||||
@@ -233,6 +239,7 @@ export async function loginWithPassword(
|
||||
totpCode?: string;
|
||||
rememberDevice?: boolean;
|
||||
useRememberToken?: boolean;
|
||||
signal?: AbortSignal;
|
||||
}
|
||||
): Promise<TokenSuccess | TokenError> {
|
||||
const body = new URLSearchParams();
|
||||
@@ -262,6 +269,7 @@ export async function loginWithPassword(
|
||||
[WEB_SESSION_HEADER]: '1',
|
||||
},
|
||||
body: body.toString(),
|
||||
signal: options?.signal,
|
||||
});
|
||||
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
|
||||
if (resp.ok) {
|
||||
|
||||
@@ -12,6 +12,14 @@ import {
|
||||
} from '@/lib/api/auth';
|
||||
import { readInviteCodeFromUrl } from '@/lib/app-support';
|
||||
import { t, translateServerError } from '@/lib/i18n';
|
||||
import {
|
||||
getOfflineUnlockKdfIterations,
|
||||
hasOfflineUnlockRecord,
|
||||
kdfIterationsFromLogin,
|
||||
loadOfflineProfileSnapshot,
|
||||
saveOfflineUnlockRecord,
|
||||
unlockOfflineVaultWithMasterKey,
|
||||
} from '@/lib/offline-auth';
|
||||
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||
|
||||
export interface PendingTotp {
|
||||
@@ -93,6 +101,20 @@ async function maybeRefreshSession(session: SessionState): Promise<SessionState
|
||||
};
|
||||
}
|
||||
|
||||
function browserReportsOffline(): boolean {
|
||||
return typeof navigator !== 'undefined' && navigator.onLine === false;
|
||||
}
|
||||
|
||||
function createTimeoutAbortController(timeoutMs: number): { controller: AbortController; cancel: () => void } | null {
|
||||
if (typeof AbortController === 'undefined') return null;
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
return {
|
||||
controller,
|
||||
cancel: () => clearTimeout(timer),
|
||||
};
|
||||
}
|
||||
|
||||
function readWindowBootstrap(): WebBootstrapResponse {
|
||||
if (typeof window === 'undefined') return {};
|
||||
const raw = (window as Window & { __NW_BOOT__?: WebBootstrapResponse }).__NW_BOOT__;
|
||||
@@ -248,6 +270,12 @@ export async function hydrateLockedSession(
|
||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession?.accessToken) {
|
||||
if (hasOfflineUnlockRecord(session.email)) {
|
||||
return {
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
};
|
||||
}
|
||||
return { session: null, profile: null };
|
||||
}
|
||||
try {
|
||||
@@ -272,7 +300,8 @@ export async function hydrateLockedSession(
|
||||
export async function completeLogin(
|
||||
token: TokenSuccess,
|
||||
email: string,
|
||||
masterKey: Uint8Array
|
||||
masterKey: Uint8Array,
|
||||
fallbackKdfIterations: number
|
||||
): Promise<CompletedLogin> {
|
||||
const normalizedEmail = email.trim().toLowerCase();
|
||||
const fallbackProfile = loadProfileSnapshot(normalizedEmail);
|
||||
@@ -291,6 +320,12 @@ export async function completeLogin(
|
||||
throw new Error('Missing profile key');
|
||||
}
|
||||
const keys = await unlockVaultKey(profile.key, masterKey);
|
||||
saveOfflineUnlockRecord({
|
||||
email: normalizedEmail,
|
||||
profile,
|
||||
profileKey: profile.key,
|
||||
kdfIterations: kdfIterationsFromLogin(token, fallbackKdfIterations),
|
||||
});
|
||||
return {
|
||||
session: { ...baseSession, ...keys },
|
||||
profile,
|
||||
@@ -310,7 +345,7 @@ export async function performPasswordLogin(
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -342,7 +377,7 @@ export async function performTotpLogin(
|
||||
rememberDevice,
|
||||
});
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey);
|
||||
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey, kdfIterationsFromLogin(token, 600000));
|
||||
}
|
||||
const tokenError = token as { error_description?: string; error?: string };
|
||||
throw new Error(translateServerError(tokenError.error_description || tokenError.error, t('txt_totp_verify_failed')));
|
||||
@@ -361,7 +396,7 @@ export async function performRecoverTwoFactorLogin(
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
newRecoveryCode: recovered.newRecoveryCode || null,
|
||||
};
|
||||
}
|
||||
@@ -397,13 +432,56 @@ export async function performUnlock(
|
||||
fallbackIterations: number
|
||||
): Promise<PasswordLoginResult> {
|
||||
const normalizedEmail = (profile?.email || session.email).trim().toLowerCase();
|
||||
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
|
||||
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
|
||||
const offlineIterations = getOfflineUnlockKdfIterations(normalizedEmail);
|
||||
const hasOfflineUnlock = !!offlineIterations;
|
||||
const kdfIterations = offlineIterations || fallbackIterations;
|
||||
const derived = await deriveLoginHashLocally(normalizedEmail, password, kdfIterations);
|
||||
const unlockOffline = async (): Promise<PasswordLoginResult> => {
|
||||
try {
|
||||
const offline = await unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
|
||||
return {
|
||||
kind: 'success',
|
||||
login: {
|
||||
session: offline.session,
|
||||
profile: offline.profile,
|
||||
profilePromise: Promise.resolve(offline.profile),
|
||||
},
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
kind: 'error',
|
||||
message: t('txt_unlock_failed_master_password_is_incorrect'),
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||
return unlockOffline();
|
||||
}
|
||||
|
||||
let token: TokenSuccess | { TwoFactorProviders?: unknown; error_description?: string; error?: string };
|
||||
const abortable = hasOfflineUnlock ? createTimeoutAbortController(2500) : null;
|
||||
try {
|
||||
token = await loginWithPassword(normalizedEmail, derived.hash, {
|
||||
useRememberToken: true,
|
||||
signal: abortable?.controller.signal,
|
||||
});
|
||||
} catch {
|
||||
if (hasOfflineUnlock) {
|
||||
return unlockOffline();
|
||||
}
|
||||
return {
|
||||
kind: 'error',
|
||||
message: t('txt_unlock_failed_master_password_is_incorrect'),
|
||||
};
|
||||
} finally {
|
||||
abortable?.cancel();
|
||||
}
|
||||
|
||||
if ('access_token' in token && token.access_token) {
|
||||
return {
|
||||
kind: 'success',
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||
login: await completeLogin(token, normalizedEmail, derived.masterKey, derived.kdfIterations),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { deriveLoginHashLocally, unlockVaultKey } from '@/lib/api/auth';
|
||||
import type { Profile, SessionState, TokenSuccess } from '@/lib/types';
|
||||
|
||||
const OFFLINE_UNLOCK_KEY = 'nodewarden.web.offline-unlock.v1';
|
||||
|
||||
interface OfflineUnlockRecord {
|
||||
version: 1;
|
||||
email: string;
|
||||
profile: Profile;
|
||||
profileKey: string;
|
||||
kdfIterations: number;
|
||||
savedAt: number;
|
||||
}
|
||||
|
||||
function normalizeEmail(email: string | null | undefined): string {
|
||||
return String(email || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function stripOfflineProfile(profile: Profile): Profile {
|
||||
return {
|
||||
...profile,
|
||||
email: normalizeEmail(profile.email),
|
||||
key: '',
|
||||
privateKey: null,
|
||||
};
|
||||
}
|
||||
|
||||
function parseRecord(raw: string | null): OfflineUnlockRecord | null {
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as Partial<OfflineUnlockRecord>;
|
||||
const email = normalizeEmail(parsed.email);
|
||||
const profileKey = String(parsed.profileKey || '').trim();
|
||||
const iterations = Number(parsed.kdfIterations || 0);
|
||||
if (parsed.version !== 1 || !email || !profileKey || !Number.isFinite(iterations) || iterations <= 0) {
|
||||
return null;
|
||||
}
|
||||
const profile = parsed.profile && typeof parsed.profile === 'object'
|
||||
? stripOfflineProfile(parsed.profile as Profile)
|
||||
: {
|
||||
id: '',
|
||||
email,
|
||||
name: email,
|
||||
key: '',
|
||||
privateKey: null,
|
||||
role: 'user',
|
||||
};
|
||||
return {
|
||||
version: 1,
|
||||
email,
|
||||
profile,
|
||||
profileKey,
|
||||
kdfIterations: iterations,
|
||||
savedAt: Number(parsed.savedAt || 0) || 0,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function readRecord(): OfflineUnlockRecord | null {
|
||||
if (typeof localStorage === 'undefined') return null;
|
||||
return parseRecord(localStorage.getItem(OFFLINE_UNLOCK_KEY));
|
||||
}
|
||||
|
||||
export function hasOfflineUnlockRecord(email?: string | null): boolean {
|
||||
const record = readRecord();
|
||||
if (!record) return false;
|
||||
const normalized = normalizeEmail(email);
|
||||
return !normalized || record.email === normalized;
|
||||
}
|
||||
|
||||
export function getOfflineUnlockKdfIterations(email?: string | null): number | null {
|
||||
const record = readRecord();
|
||||
if (!record) return null;
|
||||
const normalized = normalizeEmail(email);
|
||||
if (normalized && record.email !== normalized) return null;
|
||||
return record.kdfIterations;
|
||||
}
|
||||
|
||||
export function loadOfflineProfileSnapshot(email?: string | null): Profile | null {
|
||||
const record = readRecord();
|
||||
if (!record) return null;
|
||||
const normalized = normalizeEmail(email);
|
||||
if (normalized && record.email !== normalized) return null;
|
||||
return stripOfflineProfile(record.profile);
|
||||
}
|
||||
|
||||
export function saveOfflineUnlockRecord(args: {
|
||||
email: string;
|
||||
profile: Profile;
|
||||
profileKey: string;
|
||||
kdfIterations: number;
|
||||
}): void {
|
||||
if (typeof localStorage === 'undefined') return;
|
||||
const email = normalizeEmail(args.email || args.profile.email);
|
||||
const profileKey = String(args.profileKey || '').trim();
|
||||
const kdfIterations = Number(args.kdfIterations || 0);
|
||||
if (!email || !profileKey || !Number.isFinite(kdfIterations) || kdfIterations <= 0) return;
|
||||
const record: OfflineUnlockRecord = {
|
||||
version: 1,
|
||||
email,
|
||||
profile: stripOfflineProfile({ ...args.profile, email }),
|
||||
profileKey,
|
||||
kdfIterations,
|
||||
savedAt: Date.now(),
|
||||
};
|
||||
localStorage.setItem(OFFLINE_UNLOCK_KEY, JSON.stringify(record));
|
||||
}
|
||||
|
||||
export function clearOfflineUnlockRecord(): void {
|
||||
try {
|
||||
localStorage.removeItem(OFFLINE_UNLOCK_KEY);
|
||||
} catch {
|
||||
// Ignore storage failures during logout cleanup.
|
||||
}
|
||||
}
|
||||
|
||||
export async function unlockOfflineVault(
|
||||
session: SessionState,
|
||||
profile: Profile | null,
|
||||
password: string
|
||||
): Promise<{ session: SessionState; profile: Profile }> {
|
||||
const record = readRecord();
|
||||
const email = normalizeEmail(profile?.email || session.email);
|
||||
if (!record || record.email !== email) {
|
||||
throw new Error('Offline unlock is not available on this device.');
|
||||
}
|
||||
const derived = await deriveLoginHashLocally(record.email, password, record.kdfIterations);
|
||||
return unlockOfflineVaultWithMasterKey(session, profile, derived.masterKey);
|
||||
}
|
||||
|
||||
export async function unlockOfflineVaultWithMasterKey(
|
||||
session: SessionState,
|
||||
profile: Profile | null,
|
||||
masterKey: Uint8Array
|
||||
): Promise<{ session: SessionState; profile: Profile }> {
|
||||
const record = readRecord();
|
||||
const email = normalizeEmail(profile?.email || session.email);
|
||||
if (!record || record.email !== email) {
|
||||
throw new Error('Offline unlock is not available on this device.');
|
||||
}
|
||||
const keys = await unlockVaultKey(record.profileKey, masterKey);
|
||||
return {
|
||||
session: {
|
||||
...session,
|
||||
email: record.email,
|
||||
...keys,
|
||||
},
|
||||
profile: {
|
||||
...stripOfflineProfile(record.profile),
|
||||
key: record.profileKey,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function kdfIterationsFromLogin(token: TokenSuccess, fallbackIterations: number): number {
|
||||
const value = Number(token.KdfIterations || fallbackIterations || 600000);
|
||||
return Number.isFinite(value) && value > 0 ? value : 600000;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
export function registerNodeWardenServiceWorker(): void {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!('serviceWorker' in navigator)) return;
|
||||
if (import.meta.env.DEV) return;
|
||||
|
||||
window.addEventListener('load', () => {
|
||||
void navigator.serviceWorker.register('/sw.js', { scope: '/' }).catch(() => {
|
||||
// PWA support is progressive enhancement; the vault still works without it.
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { render } from 'preact';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import App from './App';
|
||||
import { initI18n } from './lib/i18n';
|
||||
import { registerNodeWardenServiceWorker } from './lib/pwa';
|
||||
import './tailwind.css';
|
||||
import './styles.css';
|
||||
|
||||
@@ -29,4 +30,5 @@ function renderApp(): void {
|
||||
|
||||
void initI18n().finally(() => {
|
||||
renderApp();
|
||||
registerNodeWardenServiceWorker();
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user