feat: enhance login handling by introducing local hash derivation and updating session management

This commit is contained in:
shuaiplus
2026-03-17 08:50:47 +08:00
parent 0ba85229a9
commit 3791f89a5c
4 changed files with 118 additions and 19 deletions
+33 -8
View File
@@ -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([]);
+12
View File
@@ -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
View File
@@ -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) {
+16
View File
@@ -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 {