mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-06-20 13:00:39 +00:00
feat: enhance login handling by introducing local hash derivation and updating session management
This commit is contained in:
+33
-8
@@ -32,6 +32,7 @@ import {
|
|||||||
} from '@/lib/app-support';
|
} from '@/lib/app-support';
|
||||||
import {
|
import {
|
||||||
bootstrapAppSession,
|
bootstrapAppSession,
|
||||||
|
type CompletedLogin,
|
||||||
readInitialAppBootstrapState,
|
readInitialAppBootstrapState,
|
||||||
performPasswordLogin,
|
performPasswordLogin,
|
||||||
performRecoverTwoFactorLogin,
|
performRecoverTwoFactorLogin,
|
||||||
@@ -94,9 +95,11 @@ export default function App() {
|
|||||||
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
|
const [decryptedFolders, setDecryptedFolders] = useState<VaultFolder[]>([]);
|
||||||
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
const [decryptedCiphers, setDecryptedCiphers] = useState<Cipher[]>([]);
|
||||||
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
const [decryptedSends, setDecryptedSends] = useState<Send[]>([]);
|
||||||
|
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
||||||
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
|
const migratedPlainFolderIdsRef = useRef<Set<string>>(new Set());
|
||||||
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
||||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||||
|
const repairAttemptRef = useRef<string>('');
|
||||||
const { toasts, pushToast, removeToast } = useToastManager();
|
const { toasts, pushToast, removeToast } = useToastManager();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -153,6 +156,7 @@ export default function App() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
function setSession(next: SessionState | null) {
|
function setSession(next: SessionState | null) {
|
||||||
|
sessionRef.current = next;
|
||||||
setSessionState(next);
|
setSessionState(next);
|
||||||
saveSession(next);
|
saveSession(next);
|
||||||
}
|
}
|
||||||
@@ -210,10 +214,9 @@ export default function App() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
async function finalizeLogin(nextSession: SessionState, nextProfile: Profile) {
|
async function finalizeLogin(login: CompletedLogin) {
|
||||||
setSession(nextSession);
|
setSession(login.session);
|
||||||
setProfile(nextProfile);
|
setProfile(login.profile);
|
||||||
await silentlyRepairBackupSettingsIfNeeded(nextSession, nextProfile);
|
|
||||||
setPendingTotp(null);
|
setPendingTotp(null);
|
||||||
setTotpCode('');
|
setTotpCode('');
|
||||||
setPhase('app');
|
setPhase('app');
|
||||||
@@ -221,6 +224,15 @@ export default function App() {
|
|||||||
navigate('/vault');
|
navigate('/vault');
|
||||||
}
|
}
|
||||||
pushToast('success', t('txt_login_success'));
|
pushToast('success', t('txt_login_success'));
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const hydratedProfile = await login.profilePromise;
|
||||||
|
if (sessionRef.current?.accessToken !== login.session.accessToken) return;
|
||||||
|
setProfile(hydratedProfile);
|
||||||
|
} catch {
|
||||||
|
// Keep the in-memory transient profile for the current session.
|
||||||
|
}
|
||||||
|
})();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleLogin() {
|
async function handleLogin() {
|
||||||
@@ -233,7 +245,7 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const result = await performPasswordLogin(loginValues.email, loginValues.password, defaultKdfIterations);
|
const result = await performPasswordLogin(loginValues.email, loginValues.password, defaultKdfIterations);
|
||||||
if (result.kind === 'success') {
|
if (result.kind === 'success') {
|
||||||
await finalizeLogin(result.login.session, result.login.profile);
|
await finalizeLogin(result.login);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (result.kind === 'totp') {
|
if (result.kind === 'totp') {
|
||||||
@@ -258,7 +270,7 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
const login = await performTotpLogin(pendingTotp, totpCode, rememberDevice);
|
||||||
await finalizeLogin(login.session, login.profile);
|
await finalizeLogin(login);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
pushToast('error', error instanceof Error ? error.message : t('txt_totp_verify_failed'));
|
||||||
}
|
}
|
||||||
@@ -275,7 +287,7 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const recovered = await performRecoverTwoFactorLogin(email, password, recoveryCode, defaultKdfIterations);
|
const recovered = await performRecoverTwoFactorLogin(email, password, recoveryCode, defaultKdfIterations);
|
||||||
if (recovered.login) {
|
if (recovered.login) {
|
||||||
await finalizeLogin(recovered.login.session, recovered.login.profile);
|
await finalizeLogin(recovered.login);
|
||||||
if (recovered.newRecoveryCode) {
|
if (recovered.newRecoveryCode) {
|
||||||
pushToast('success', t('txt_text_2fa_recovered_new_recovery_code_code', { code: recovered.newRecoveryCode }));
|
pushToast('success', t('txt_text_2fa_recovered_new_recovery_code_code', { code: recovered.newRecoveryCode }));
|
||||||
} else {
|
} else {
|
||||||
@@ -337,7 +349,6 @@ export default function App() {
|
|||||||
try {
|
try {
|
||||||
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
|
||||||
setSession(nextSession);
|
setSession(nextSession);
|
||||||
await silentlyRepairBackupSettingsIfNeeded(nextSession, profile);
|
|
||||||
setUnlockPassword('');
|
setUnlockPassword('');
|
||||||
setPhase('app');
|
setPhase('app');
|
||||||
if (location === '/' || location === '/lock') navigate('/vault');
|
if (location === '/' || location === '/lock') navigate('/vault');
|
||||||
@@ -439,6 +450,20 @@ export default function App() {
|
|||||||
enabled: phase === 'app' && !!session?.accessToken,
|
enabled: phase === 'app' && !!session?.accessToken,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (phase !== 'app' || !session?.accessToken || !session?.symEncKey || !session?.symMacKey) return;
|
||||||
|
if (!profile?.role || profile.role !== 'admin') return;
|
||||||
|
if (repairAttemptRef.current === session.accessToken) return;
|
||||||
|
|
||||||
|
repairAttemptRef.current = session.accessToken;
|
||||||
|
void silentlyRepairBackupSettingsIfNeeded(session, profile);
|
||||||
|
}, [phase, session?.accessToken, session?.symEncKey, session?.symMacKey, profile]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (session?.accessToken) return;
|
||||||
|
repairAttemptRef.current = '';
|
||||||
|
}, [session?.accessToken]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!session?.symEncKey || !session?.symMacKey) {
|
if (!session?.symEncKey || !session?.symMacKey) {
|
||||||
setDecryptedFolders([]);
|
setDecryptedFolders([]);
|
||||||
|
|||||||
@@ -109,6 +109,18 @@ export async function deriveLoginHash(email: string, password: string, fallbackI
|
|||||||
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
|
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function deriveLoginHashLocally(
|
||||||
|
email: string,
|
||||||
|
password: string,
|
||||||
|
fallbackIterations: number
|
||||||
|
): Promise<PreloginResult> {
|
||||||
|
const normalizedEmail = String(email || '').trim().toLowerCase();
|
||||||
|
const iterations = Number(fallbackIterations || 600000);
|
||||||
|
const masterKey = await pbkdf2(password, normalizedEmail, iterations, 32);
|
||||||
|
const hash = await pbkdf2(masterKey, password, 1, 32);
|
||||||
|
return { hash: bytesToBase64(hash), masterKey, kdfIterations: iterations };
|
||||||
|
}
|
||||||
|
|
||||||
export async function getPreloginKdfConfig(email: string, fallbackIterations: number): Promise<PreloginKdfConfig> {
|
export async function getPreloginKdfConfig(email: string, fallbackIterations: number): Promise<PreloginKdfConfig> {
|
||||||
const normalized = String(email || '').trim().toLowerCase();
|
const normalized = String(email || '').trim().toLowerCase();
|
||||||
if (!normalized) throw new Error('Email is required');
|
if (!normalized) throw new Error('Email is required');
|
||||||
|
|||||||
+57
-11
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
createAuthedFetch,
|
createAuthedFetch,
|
||||||
deriveLoginHash,
|
deriveLoginHash,
|
||||||
|
deriveLoginHashLocally,
|
||||||
getProfile,
|
getProfile,
|
||||||
loadSession,
|
loadSession,
|
||||||
loginWithPassword,
|
loginWithPassword,
|
||||||
@@ -10,7 +11,7 @@ import {
|
|||||||
unlockVaultKey,
|
unlockVaultKey,
|
||||||
} from '@/lib/api/auth';
|
} from '@/lib/api/auth';
|
||||||
import { readInviteCodeFromUrl } from '@/lib/app-support';
|
import { readInviteCodeFromUrl } from '@/lib/app-support';
|
||||||
import type { AppPhase, Profile, SessionState, WebBootstrapResponse } from '@/lib/types';
|
import type { AppPhase, Profile, SessionState, TokenSuccess, WebBootstrapResponse } from '@/lib/types';
|
||||||
|
|
||||||
export interface PendingTotp {
|
export interface PendingTotp {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -38,6 +39,7 @@ export interface InitialAppBootstrapState {
|
|||||||
export interface CompletedLogin {
|
export interface CompletedLogin {
|
||||||
session: SessionState;
|
session: SessionState;
|
||||||
profile: Profile;
|
profile: Profile;
|
||||||
|
profilePromise: Promise<Profile>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type PasswordLoginResult =
|
export type PasswordLoginResult =
|
||||||
@@ -91,6 +93,42 @@ function readWindowBootstrap(): WebBootstrapResponse {
|
|||||||
return raw && typeof raw === 'object' ? raw : {};
|
return raw && typeof raw === 'object' ? raw : {};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface AccessTokenClaims {
|
||||||
|
sub?: string;
|
||||||
|
email?: string;
|
||||||
|
name?: string | null;
|
||||||
|
premium?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeAccessTokenClaims(accessToken: string): AccessTokenClaims {
|
||||||
|
try {
|
||||||
|
const parts = accessToken.split('.');
|
||||||
|
if (parts.length < 2) return {};
|
||||||
|
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
|
||||||
|
const padded = payload.padEnd(Math.ceil(payload.length / 4) * 4, '=');
|
||||||
|
return (JSON.parse(atob(padded)) as AccessTokenClaims) || {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTransientProfile(token: TokenSuccess, email: string): Profile {
|
||||||
|
const claims = decodeAccessTokenClaims(token.access_token);
|
||||||
|
const normalizedEmail = String(claims.email || email || '').trim().toLowerCase();
|
||||||
|
const accountKeys = token.accountKeys ?? token.AccountKeys ?? null;
|
||||||
|
return {
|
||||||
|
id: String(claims.sub || ''),
|
||||||
|
email: normalizedEmail,
|
||||||
|
name: String(claims.name || normalizedEmail || ''),
|
||||||
|
key: String(token.Key || ''),
|
||||||
|
privateKey: token.PrivateKey ?? null,
|
||||||
|
role: 'user',
|
||||||
|
premium: !!claims.premium,
|
||||||
|
accountKeys,
|
||||||
|
object: 'profile',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
export function readInitialAppBootstrapState(): InitialAppBootstrapState {
|
||||||
const boot = readWindowBootstrap();
|
const boot = readWindowBootstrap();
|
||||||
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
|
const defaultKdfIterations = Number(boot.defaultKdfIterations || 600000);
|
||||||
@@ -168,21 +206,29 @@ export async function bootstrapAppSession(): Promise<BootstrapAppResult> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function completeLogin(
|
export async function completeLogin(
|
||||||
tokenAccess: string,
|
token: TokenSuccess,
|
||||||
tokenRefresh: string,
|
|
||||||
email: string,
|
email: string,
|
||||||
masterKey: Uint8Array
|
masterKey: Uint8Array
|
||||||
): Promise<CompletedLogin> {
|
): Promise<CompletedLogin> {
|
||||||
const baseSession: SessionState = { accessToken: tokenAccess, refreshToken: tokenRefresh, email };
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
|
const baseSession: SessionState = {
|
||||||
|
accessToken: token.access_token,
|
||||||
|
refreshToken: token.refresh_token,
|
||||||
|
email: normalizedEmail,
|
||||||
|
};
|
||||||
const tempFetch = createAuthedFetch(
|
const tempFetch = createAuthedFetch(
|
||||||
() => baseSession,
|
() => baseSession,
|
||||||
() => {}
|
() => {}
|
||||||
);
|
);
|
||||||
const profile = await getProfile(tempFetch);
|
const profile = buildTransientProfile(token, normalizedEmail);
|
||||||
|
if (!profile.key) {
|
||||||
|
throw new Error('Missing profile key');
|
||||||
|
}
|
||||||
const keys = await unlockVaultKey(profile.key, masterKey);
|
const keys = await unlockVaultKey(profile.key, masterKey);
|
||||||
return {
|
return {
|
||||||
session: { ...baseSession, ...keys },
|
session: { ...baseSession, ...keys },
|
||||||
profile,
|
profile,
|
||||||
|
profilePromise: getProfile(tempFetch),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,13 +238,13 @@ export async function performPasswordLogin(
|
|||||||
fallbackIterations: number
|
fallbackIterations: number
|
||||||
): Promise<PasswordLoginResult> {
|
): Promise<PasswordLoginResult> {
|
||||||
const normalizedEmail = email.trim().toLowerCase();
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
const derived = await deriveLoginHash(normalizedEmail, password, fallbackIterations);
|
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
|
||||||
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
|
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: true });
|
||||||
|
|
||||||
if ('access_token' in token && token.access_token) {
|
if ('access_token' in token && token.access_token) {
|
||||||
return {
|
return {
|
||||||
kind: 'success',
|
kind: 'success',
|
||||||
login: await completeLogin(token.access_token, token.refresh_token, normalizedEmail, derived.masterKey),
|
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -230,7 +276,7 @@ export async function performTotpLogin(
|
|||||||
rememberDevice,
|
rememberDevice,
|
||||||
});
|
});
|
||||||
if ('access_token' in token && token.access_token) {
|
if ('access_token' in token && token.access_token) {
|
||||||
return completeLogin(token.access_token, token.refresh_token, pendingTotp.email, pendingTotp.masterKey);
|
return completeLogin(token, pendingTotp.email, pendingTotp.masterKey);
|
||||||
}
|
}
|
||||||
const tokenError = token as { error_description?: string; error?: string };
|
const tokenError = token as { error_description?: string; error?: string };
|
||||||
throw new Error(tokenError.error_description || tokenError.error || 'TOTP verify failed');
|
throw new Error(tokenError.error_description || tokenError.error || 'TOTP verify failed');
|
||||||
@@ -243,13 +289,13 @@ export async function performRecoverTwoFactorLogin(
|
|||||||
fallbackIterations: number
|
fallbackIterations: number
|
||||||
): Promise<RecoverTwoFactorResult> {
|
): Promise<RecoverTwoFactorResult> {
|
||||||
const normalizedEmail = email.trim().toLowerCase();
|
const normalizedEmail = email.trim().toLowerCase();
|
||||||
const derived = await deriveLoginHash(normalizedEmail, password, fallbackIterations);
|
const derived = await deriveLoginHashLocally(normalizedEmail, password, fallbackIterations);
|
||||||
const recovered = await recoverTwoFactor(normalizedEmail, derived.hash, recoveryCode.trim());
|
const recovered = await recoverTwoFactor(normalizedEmail, derived.hash, recoveryCode.trim());
|
||||||
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: false });
|
const token = await loginWithPassword(normalizedEmail, derived.hash, { useRememberToken: false });
|
||||||
|
|
||||||
if ('access_token' in token && token.access_token) {
|
if ('access_token' in token && token.access_token) {
|
||||||
return {
|
return {
|
||||||
login: await completeLogin(token.access_token, token.refresh_token, normalizedEmail, derived.masterKey),
|
login: await completeLogin(token, normalizedEmail, derived.masterKey),
|
||||||
newRecoveryCode: recovered.newRecoveryCode || null,
|
newRecoveryCode: recovered.newRecoveryCode || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -282,7 +328,7 @@ export async function performUnlock(
|
|||||||
password: string,
|
password: string,
|
||||||
fallbackIterations: number
|
fallbackIterations: number
|
||||||
): Promise<SessionState> {
|
): Promise<SessionState> {
|
||||||
const derived = await deriveLoginHash(profile.email || session.email, password, fallbackIterations);
|
const derived = await deriveLoginHashLocally(profile.email || session.email, password, fallbackIterations);
|
||||||
const keys = await unlockVaultKey(profile.key, derived.masterKey);
|
const keys = await unlockVaultKey(profile.key, derived.masterKey);
|
||||||
const refreshedSession = await maybeRefreshSession(session);
|
const refreshedSession = await maybeRefreshSession(session);
|
||||||
if (!refreshedSession) {
|
if (!refreshedSession) {
|
||||||
|
|||||||
@@ -265,7 +265,23 @@ export interface WebBootstrapResponse {
|
|||||||
export interface TokenSuccess {
|
export interface TokenSuccess {
|
||||||
access_token: string;
|
access_token: string;
|
||||||
refresh_token: string;
|
refresh_token: string;
|
||||||
|
expires_in?: number;
|
||||||
|
token_type?: string;
|
||||||
TwoFactorToken?: string;
|
TwoFactorToken?: string;
|
||||||
|
Key?: string;
|
||||||
|
PrivateKey?: string | null;
|
||||||
|
AccountKeys?: unknown | null;
|
||||||
|
accountKeys?: unknown | null;
|
||||||
|
Kdf?: number;
|
||||||
|
KdfIterations?: number;
|
||||||
|
KdfMemory?: number | null;
|
||||||
|
KdfParallelism?: number | null;
|
||||||
|
ForcePasswordReset?: boolean;
|
||||||
|
ResetMasterPassword?: boolean;
|
||||||
|
scope?: string;
|
||||||
|
unofficialServer?: boolean;
|
||||||
|
UserDecryptionOptions?: unknown;
|
||||||
|
userDecryptionOptions?: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TokenError {
|
export interface TokenError {
|
||||||
|
|||||||
Reference in New Issue
Block a user