mirror of
https://github.com/shuaiplus/nodewarden.git
synced 2026-08-04 22:40:11 +00:00
fix(auth): prevent unexpected session logout
This commit is contained in:
+47
-2
@@ -253,6 +253,8 @@ export default function App() {
|
||||
const [lockTimeoutMinutes, setLockTimeoutMinutesState] = useState<LockTimeoutMinutes>(() => readLockTimeoutMinutes());
|
||||
const [sessionTimeoutAction, setSessionTimeoutActionState] = useState<SessionTimeoutAction>(() => readSessionTimeoutAction());
|
||||
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialBootstrap.session?.email);
|
||||
const [lockedSessionRefreshError, setLockedSessionRefreshError] = useState('');
|
||||
const [lockedSessionRetryKey, setLockedSessionRetryKey] = useState(0);
|
||||
|
||||
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
|
||||
const [mobileLayout, setMobileLayout] = useState(false);
|
||||
@@ -269,6 +271,7 @@ export default function App() {
|
||||
const [vaultDecryptError, setVaultDecryptError] = useState('');
|
||||
const [sendsDecryptDone, setSendsDecryptDone] = useState(false);
|
||||
const sessionRef = useRef<SessionState | null>(initialBootstrap.session);
|
||||
const lockedSessionRetryAttemptRef = useRef(0);
|
||||
const silentRefreshVaultRef = useRef<() => Promise<void>>(async () => {});
|
||||
const refreshAuthorizedDevicesRef = useRef<() => Promise<void>>(async () => {});
|
||||
const refreshPendingAuthRequestsRef = useRef<() => Promise<void>>(async () => {});
|
||||
@@ -503,13 +506,15 @@ export default function App() {
|
||||
if (phase !== 'locked' || !session) return;
|
||||
if (IS_DEMO_MODE) return;
|
||||
let cancelled = false;
|
||||
let retryTimerId: number | null = null;
|
||||
void (async () => {
|
||||
const result = await hydrateLockedSession(session, profile);
|
||||
if (cancelled) return;
|
||||
if (!result.session) {
|
||||
if (result.kind === 'expired') {
|
||||
setSession(null);
|
||||
setProfile(null);
|
||||
setUnlockPreparing(false);
|
||||
setLockedSessionRefreshError('');
|
||||
setPhase('login');
|
||||
if (location !== '/login') navigate('/login');
|
||||
return;
|
||||
@@ -518,11 +523,43 @@ export default function App() {
|
||||
if (result.profile) {
|
||||
setProfile(stripProfileSecrets(result.profile));
|
||||
}
|
||||
if (result.kind === 'transient') {
|
||||
setUnlockPreparing(false);
|
||||
setLockedSessionRefreshError(result.message || t('txt_session_refresh_temporarily_unavailable'));
|
||||
const retrySchedule = [2_000, 5_000, 15_000, 30_000, 60_000];
|
||||
const scheduledDelay = retrySchedule[Math.min(lockedSessionRetryAttemptRef.current, retrySchedule.length - 1)];
|
||||
lockedSessionRetryAttemptRef.current += 1;
|
||||
const retryAfterMs = Math.min(60_000, Math.max(scheduledDelay, result.retryAfterMs || 0));
|
||||
retryTimerId = window.setTimeout(() => {
|
||||
setLockedSessionRetryKey((value) => value + 1);
|
||||
}, retryAfterMs);
|
||||
return;
|
||||
}
|
||||
lockedSessionRetryAttemptRef.current = 0;
|
||||
setLockedSessionRefreshError('');
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (retryTimerId !== null) window.clearTimeout(retryTimerId);
|
||||
};
|
||||
}, [phase, session?.email, location, navigate]);
|
||||
}, [phase, session?.email, location, navigate, lockedSessionRetryKey]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!lockedSessionRefreshError || phase !== 'locked') return;
|
||||
const retryNow = () => {
|
||||
lockedSessionRetryAttemptRef.current = 0;
|
||||
setLockedSessionRetryKey((value) => value + 1);
|
||||
};
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'visible') retryNow();
|
||||
};
|
||||
window.addEventListener('online', retryNow);
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
return () => {
|
||||
window.removeEventListener('online', retryNow);
|
||||
document.removeEventListener('visibilitychange', handleVisibility);
|
||||
};
|
||||
}, [lockedSessionRefreshError, phase]);
|
||||
|
||||
async function finalizeLogin(login: CompletedLogin) {
|
||||
loginScopedBackupRepairAuthRef.current =
|
||||
@@ -536,6 +573,7 @@ export default function App() {
|
||||
setSession(login.session);
|
||||
setProfile(login.profile);
|
||||
setUnlockPreparing(false);
|
||||
setLockedSessionRefreshError('');
|
||||
setPendingTotp(null);
|
||||
setPendingTotpMode(null);
|
||||
setPendingPasskeyPassword(null);
|
||||
@@ -884,6 +922,7 @@ export default function App() {
|
||||
setPendingTotpMode(null);
|
||||
setTotpCode('');
|
||||
setUnlockPreparing(false);
|
||||
setLockedSessionRefreshError('');
|
||||
setPhase('locked');
|
||||
navigate('/lock');
|
||||
}
|
||||
@@ -2221,6 +2260,7 @@ export default function App() {
|
||||
unlockPlaceholder={IS_DEMO_MODE ? t('txt_demo_unlock_placeholder') : undefined}
|
||||
unlockReady={!!session?.email}
|
||||
unlockPreparing={unlockPreparing}
|
||||
sessionRefreshError={lockedSessionRefreshError}
|
||||
loginValues={loginValues}
|
||||
pendingPasskeyPasswordEmail={pendingPasskeyPassword?.email || null}
|
||||
passkeyPassword={passkeyPassword}
|
||||
@@ -2261,6 +2301,11 @@ export default function App() {
|
||||
onLogout={logoutNow}
|
||||
onTogglePasswordHint={() => void handleTogglePasswordHint()}
|
||||
onShowLockedPasswordHint={handleShowLockedPasswordHint}
|
||||
onRetrySessionRefresh={() => {
|
||||
lockedSessionRetryAttemptRef.current = 0;
|
||||
setLockedSessionRefreshError('');
|
||||
setLockedSessionRetryKey((value) => value + 1);
|
||||
}}
|
||||
/>
|
||||
<AppGlobalOverlays
|
||||
toasts={toasts}
|
||||
|
||||
@@ -27,6 +27,7 @@ interface AuthViewsProps {
|
||||
pendingAction: 'login' | 'passkey' | 'register' | 'unlock' | null;
|
||||
unlockReady: boolean;
|
||||
unlockPreparing: boolean;
|
||||
sessionRefreshError?: string;
|
||||
loginValues: LoginValues;
|
||||
pendingPasskeyPasswordEmail?: string | null;
|
||||
passkeyPassword: string;
|
||||
@@ -50,6 +51,7 @@ interface AuthViewsProps {
|
||||
onLogout: () => void;
|
||||
onTogglePasswordHint: () => void;
|
||||
onShowLockedPasswordHint: () => void;
|
||||
onRetrySessionRefresh: () => void;
|
||||
}
|
||||
|
||||
function PasswordField(props: {
|
||||
@@ -155,6 +157,19 @@ export default function AuthViews(props: AuthViewsProps) {
|
||||
{props.unlockPreparing ? (
|
||||
<p className="muted standalone-muted">{t('txt_loading')}</p>
|
||||
) : null}
|
||||
{props.sessionRefreshError ? (
|
||||
<div className="offline-mode-notice" role="alert" aria-live="polite">
|
||||
<AlertTriangle size={18} />
|
||||
<div>
|
||||
<strong>{props.sessionRefreshError}</strong>
|
||||
<div>
|
||||
<button type="button" className="auth-link-btn" onClick={props.onRetrySessionRefresh}>
|
||||
{t('txt_refresh')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || passkeyBusy || props.unlockPreparing || !props.unlockReady}>
|
||||
<Unlock size={16} className="btn-icon" />
|
||||
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
|
||||
|
||||
@@ -42,6 +42,7 @@ interface RefreshFailure {
|
||||
ok: false;
|
||||
transient: boolean;
|
||||
error: string;
|
||||
retryAfterMs?: number;
|
||||
}
|
||||
|
||||
interface RefreshSuccess {
|
||||
@@ -333,8 +334,8 @@ export async function loginWithAccountPasskeyAssertion(assertion: AccountPasskey
|
||||
return json;
|
||||
}
|
||||
|
||||
function isTransientRefreshStatus(status: number): boolean {
|
||||
return status === 0 || status === 429 || status >= 500;
|
||||
function isPermanentRefreshFailure(status: number, errorCode: string | undefined): boolean {
|
||||
return status === 400 && (errorCode === 'invalid_grant' || errorCode === 'invalid_request');
|
||||
}
|
||||
|
||||
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
|
||||
@@ -346,6 +347,8 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
|
||||
try {
|
||||
const resp = await fetch('/identity/connect/token', {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
|
||||
@@ -354,15 +357,19 @@ export async function refreshAccessToken(session: SessionState): Promise<Refresh
|
||||
});
|
||||
if (!resp.ok) {
|
||||
const json = await parseJson<TokenError>(resp);
|
||||
const retryAfterSeconds = Number(resp.headers.get('Retry-After') || 0);
|
||||
return {
|
||||
ok: false,
|
||||
transient: isTransientRefreshStatus(resp.status),
|
||||
error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_failed')),
|
||||
transient: !isPermanentRefreshFailure(resp.status, json?.error),
|
||||
error: translateServerError(json?.error_description || json?.error, t('txt_session_refresh_temporarily_unavailable')),
|
||||
...(Number.isFinite(retryAfterSeconds) && retryAfterSeconds > 0
|
||||
? { retryAfterMs: retryAfterSeconds * 1000 }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
const json = await parseJson<TokenSuccess>(resp);
|
||||
if (!json?.access_token) {
|
||||
return { ok: false, transient: false, error: t('txt_session_refresh_failed') };
|
||||
return { ok: false, transient: true, error: t('txt_session_refresh_temporarily_unavailable') };
|
||||
}
|
||||
return { ok: true, token: json };
|
||||
} catch (error) {
|
||||
@@ -400,6 +407,8 @@ export async function revokeCurrentSession(session: SessionState | null): Promis
|
||||
}
|
||||
await fetch('/identity/connect/revocation', {
|
||||
method: 'POST',
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
...(session?.accessToken ? { Authorization: `Bearer ${session.accessToken}` } : {}),
|
||||
|
||||
+48
-13
@@ -199,26 +199,43 @@ function decodeJwtExp(accessToken: string | undefined): number | null {
|
||||
}
|
||||
}
|
||||
|
||||
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> {
|
||||
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null;
|
||||
type SessionRefreshOutcome =
|
||||
| { kind: 'success'; session: SessionState }
|
||||
| { kind: 'transient'; session: SessionState; message: string; retryAfterMs?: number }
|
||||
| { kind: 'expired' };
|
||||
|
||||
async function maybeRefreshSession(session: SessionState): Promise<SessionRefreshOutcome> {
|
||||
if (!session.refreshToken && session.authMode !== 'web-cookie') {
|
||||
return session.accessToken ? { kind: 'success', session } : { kind: 'expired' };
|
||||
}
|
||||
const exp = decodeJwtExp(session.accessToken);
|
||||
const nowSeconds = Math.floor(Date.now() / 1000);
|
||||
|
||||
if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
|
||||
return session;
|
||||
return { kind: 'success', session };
|
||||
}
|
||||
|
||||
const refreshed = await refreshAccessToken(session);
|
||||
if (!refreshed.ok) {
|
||||
if (refreshed.transient) return session;
|
||||
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
|
||||
if (refreshed.transient) {
|
||||
return {
|
||||
kind: 'transient',
|
||||
session,
|
||||
message: refreshed.error || t('txt_session_refresh_temporarily_unavailable'),
|
||||
retryAfterMs: refreshed.retryAfterMs,
|
||||
};
|
||||
}
|
||||
return { kind: 'expired' };
|
||||
}
|
||||
|
||||
return {
|
||||
...session,
|
||||
accessToken: refreshed.token.access_token,
|
||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||
kind: 'success',
|
||||
session: {
|
||||
...session,
|
||||
accessToken: refreshed.token.access_token,
|
||||
refreshToken: refreshed.token.refresh_token || session.refreshToken,
|
||||
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -388,25 +405,41 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
|
||||
export async function hydrateLockedSession(
|
||||
session: SessionState,
|
||||
fallbackProfile: Profile | null = null
|
||||
): Promise<{ session: SessionState | null; profile: Profile | null }> {
|
||||
): Promise<
|
||||
| { kind: 'ready'; session: SessionState; profile: Profile | null }
|
||||
| { kind: 'transient'; session: SessionState; profile: Profile | null; message: string; retryAfterMs?: number }
|
||||
| { kind: 'expired'; session: null; profile: null }
|
||||
> {
|
||||
const hasOfflineUnlock = hasOfflineUnlockRecord(session.email);
|
||||
if (hasOfflineUnlock && browserReportsOffline()) {
|
||||
return {
|
||||
kind: 'ready',
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
};
|
||||
}
|
||||
|
||||
const refreshedSession = await maybeRefreshSession(session);
|
||||
if (!refreshedSession?.accessToken) {
|
||||
const refreshOutcome = await maybeRefreshSession(session);
|
||||
if (refreshOutcome.kind === 'expired') {
|
||||
return { kind: 'expired', session: null, profile: null };
|
||||
}
|
||||
if (refreshOutcome.kind === 'transient') {
|
||||
if (hasOfflineUnlock && (browserReportsOffline() || !(await probeNodeWardenService()))) {
|
||||
return {
|
||||
kind: 'ready',
|
||||
session,
|
||||
profile: fallbackProfile || loadOfflineProfileSnapshot(session.email),
|
||||
};
|
||||
}
|
||||
return { session: null, profile: null };
|
||||
return {
|
||||
kind: 'transient',
|
||||
session,
|
||||
profile: fallbackProfile,
|
||||
message: refreshOutcome.message,
|
||||
retryAfterMs: refreshOutcome.retryAfterMs,
|
||||
};
|
||||
}
|
||||
const refreshedSession = refreshOutcome.session;
|
||||
try {
|
||||
const profile = await getProfile(
|
||||
createAuthedFetch(
|
||||
@@ -415,11 +448,13 @@ export async function hydrateLockedSession(
|
||||
)
|
||||
);
|
||||
return {
|
||||
kind: 'ready',
|
||||
session: refreshedSession,
|
||||
profile,
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
kind: 'ready',
|
||||
session: refreshedSession,
|
||||
profile: fallbackProfile,
|
||||
};
|
||||
|
||||
@@ -981,6 +981,7 @@ const de: Record<string, string> = {
|
||||
"txt_save_profile_failed": "Fehler beim Speichern des Profils",
|
||||
"txt_search_sends": "Sendungen suchen...",
|
||||
"txt_session_refresh_failed": "Sitzungsaktualisierung fehlgeschlagen. Bitte melden Sie sich erneut an.",
|
||||
"txt_session_refresh_temporarily_unavailable": "Die Sitzung kann vorübergehend nicht geprüft werden. Die Anmeldung bleibt erhalten und wird erneut versucht.",
|
||||
"txt_search_your_secure_vault": "Ihren sicheren Tresor durchsuchen...",
|
||||
"txt_search_items_count": "In {count} Einträgen suchen...",
|
||||
"txt_clear_search": "Suche löschen",
|
||||
|
||||
@@ -1004,6 +1004,7 @@ const en: Record<string, string> = {
|
||||
"txt_save_profile_failed": "Save profile failed",
|
||||
"txt_search_sends": "Search sends...",
|
||||
"txt_session_refresh_failed": "Session refresh failed. Please sign in again.",
|
||||
"txt_session_refresh_temporarily_unavailable": "Session verification is temporarily unavailable. Your login is preserved and will retry.",
|
||||
"txt_search_your_secure_vault": "Search your secure vault...",
|
||||
"txt_search_items_count": "Search within {count} items...",
|
||||
"txt_clear_search": "Clear search",
|
||||
|
||||
@@ -981,6 +981,7 @@ const es: Record<string, string> = {
|
||||
"txt_save_profile_failed": "Error al guardar perfil",
|
||||
"txt_search_sends": "Buscar envíos...",
|
||||
"txt_session_refresh_failed": "Error al actualizar la sesión. Inicia sesión de nuevo.",
|
||||
"txt_session_refresh_temporarily_unavailable": "La sesión no se puede verificar temporalmente. Tu inicio de sesión se conserva y se volverá a intentar.",
|
||||
"txt_search_your_secure_vault": "Buscar en su bóveda segura...",
|
||||
"txt_search_items_count": "Buscar entre {count} elementos...",
|
||||
"txt_clear_search": "Limpiar búsqueda",
|
||||
|
||||
@@ -981,6 +981,7 @@ const fi: Record<string, string> = {
|
||||
"txt_save_profile_failed": "Profiilin tallennus epäonnistui",
|
||||
"txt_search_sends": "Hae lähetyksiä...",
|
||||
"txt_session_refresh_failed": "Istunnon päivitys epäonnistui. Kirjaudu sisään uudelleen.",
|
||||
"txt_session_refresh_temporarily_unavailable": "Istuntoa ei voida tarkistaa juuri nyt. Kirjautuminen säilytetään ja tarkistusta yritetään uudelleen.",
|
||||
"txt_search_your_secure_vault": "Hae turvallisesta holvistasi...",
|
||||
"txt_search_items_count": "Hae {count} nimikkeen joukosta...",
|
||||
"txt_clear_search": "Tyhjennä haku",
|
||||
|
||||
@@ -981,6 +981,7 @@ const fr: Record<string, string> = {
|
||||
"txt_save_profile_failed": "L'enregistrement du profil a échoué",
|
||||
"txt_search_sends": "Rechercher des envois...",
|
||||
"txt_session_refresh_failed": "L'actualisation de la session a échoué. Veuillez vous reconnecter.",
|
||||
"txt_session_refresh_temporarily_unavailable": "La session ne peut pas être vérifiée temporairement. Votre connexion est conservée et une nouvelle tentative sera effectuée.",
|
||||
"txt_search_your_secure_vault": "Recherchez dans votre coffre-fort sécurisé...",
|
||||
"txt_search_items_count": "Rechercher parmi {count} éléments...",
|
||||
"txt_clear_search": "Effacer la recherche",
|
||||
|
||||
@@ -981,6 +981,7 @@ const it: Record<string, string> = {
|
||||
"txt_save_profile_failed": "Salvataggio Profilo fallito",
|
||||
"txt_search_sends": "Cerca invii...",
|
||||
"txt_session_refresh_failed": "Aggiornamento della sessione fallito. Per favore, accedi di nuovo.",
|
||||
"txt_session_refresh_temporarily_unavailable": "La sessione non può essere verificata temporaneamente. L'accesso viene mantenuto e verrà effettuato un nuovo tentativo.",
|
||||
"txt_search_your_secure_vault": "Cerca nella tua cassaforte sicura...",
|
||||
"txt_search_items_count": "Cerca in {count} elementi...",
|
||||
"txt_clear_search": "Cancella Ricerca",
|
||||
|
||||
@@ -981,6 +981,7 @@ const ru: Record<string, string> = {
|
||||
"txt_save_profile_failed": "Сохранить профиль не удалось",
|
||||
"txt_search_sends": "Поиск отправляет...",
|
||||
"txt_session_refresh_failed": "Не удалось обновить сеанс. Войдите снова.",
|
||||
"txt_session_refresh_temporarily_unavailable": "Сеанс временно не удаётся проверить. Вход сохранён, проверка будет повторена.",
|
||||
"txt_search_your_secure_vault": "Найдите свое безопасное хранилище...",
|
||||
"txt_search_items_count": "Поиск по {count} элементам...",
|
||||
"txt_clear_search": "Очистить поиск",
|
||||
|
||||
@@ -981,6 +981,7 @@ const sv: Record<string, string> = {
|
||||
"txt_save_profile_failed": "Misslyckades med att spara profil",
|
||||
"txt_search_sends": "Sök sändningar...",
|
||||
"txt_session_refresh_failed": "Sessionsuppdatering misslyckades. Vänligen logga in igen.",
|
||||
"txt_session_refresh_temporarily_unavailable": "Sessionen kan inte verifieras tillfälligt. Inloggningen bevaras och ett nytt försök görs.",
|
||||
"txt_search_your_secure_vault": "Sök i ditt säkra valv...",
|
||||
"txt_search_items_count": "Sök bland {count} objekt...",
|
||||
"txt_clear_search": "Rensa sökning",
|
||||
|
||||
@@ -984,6 +984,7 @@ const zhCN: Record<string, string> = {
|
||||
"txt_save_profile_failed": "保存资料失败",
|
||||
"txt_search_sends": "搜索 Send...",
|
||||
"txt_session_refresh_failed": "会话刷新失败,请重新登录",
|
||||
"txt_session_refresh_temporarily_unavailable": "暂时无法验证会话,登录状态已保留,稍后会自动重试",
|
||||
"txt_search_your_secure_vault": "搜索你的密码库...",
|
||||
"txt_search_items_count": "共 {count} 项中搜索...",
|
||||
"txt_clear_search": "清空搜索",
|
||||
|
||||
@@ -984,6 +984,7 @@ const zhTW: Record<string, string> = {
|
||||
"txt_save_profile_failed": "保存資料失敗",
|
||||
"txt_search_sends": "搜索 Send...",
|
||||
"txt_session_refresh_failed": "會話刷新失敗,請重新登入",
|
||||
"txt_session_refresh_temporarily_unavailable": "暫時無法驗證會話,登入狀態已保留,稍後會自動重試",
|
||||
"txt_search_your_secure_vault": "搜索你的密碼庫...",
|
||||
"txt_search_items_count": "在共 {count} 項中搜索...",
|
||||
"txt_clear_search": "清空搜索",
|
||||
|
||||
Reference in New Issue
Block a user