feat: implement web session handling and enhance token management

This commit is contained in:
shuaiplus
2026-04-07 22:14:26 +08:00
parent 53231a4878
commit c516194d54
10 changed files with 349 additions and 67 deletions
+54 -3
View File
@@ -10,8 +10,12 @@ import JwtWarningPage from '@/components/JwtWarningPage';
import {
createAuthedFetch,
getAuthorizedDevices,
clearProfileSnapshot,
getCurrentDeviceIdentifier,
getPasswordHint,
loadProfileSnapshot,
saveProfileSnapshot,
revokeCurrentSession,
getTotpStatus,
saveSession,
} from '@/lib/api/auth';
@@ -39,6 +43,7 @@ import {
performRecoverTwoFactorLogin,
performRegistration,
performTotpLogin,
hydrateLockedSession,
performUnlock,
type JwtUnsafeReason,
type PendingTotp,
@@ -135,11 +140,12 @@ function resolveSystemTheme(): 'light' | 'dark' {
export default function App() {
const initialBootstrap = useMemo(() => readInitialAppBootstrapState(), []);
const initialInviteCode = useMemo(() => readInviteCodeFromUrl(), []);
const initialProfileSnapshot = useMemo(() => loadProfileSnapshot(initialBootstrap.session?.email), [initialBootstrap]);
const [pendingAuthAction, setPendingAuthAction] = useState<'login' | 'register' | 'unlock' | null>(null);
const [location, navigate] = useLocation();
const [phase, setPhase] = useState<AppPhase>(initialBootstrap.phase);
const [session, setSessionState] = useState<SessionState | null>(initialBootstrap.session);
const [profile, setProfile] = useState<Profile | null>(null);
const [profile, setProfile] = useState<Profile | null>(initialProfileSnapshot);
const [defaultKdfIterations, setDefaultKdfIterations] = useState(initialBootstrap.defaultKdfIterations);
const [jwtWarning, setJwtWarning] = useState<{ reason: JwtUnsafeReason; minLength: number } | null>(initialBootstrap.jwtWarning);
@@ -172,6 +178,7 @@ export default function App() {
const [recoverValues, setRecoverValues] = useState({ email: '', password: '', recoveryCode: '' });
const [themePreference, setThemePreference] = useState<ThemePreference>(() => readThemePreference());
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(() => resolveSystemTheme());
const [unlockPreparing, setUnlockPreparing] = useState(() => initialBootstrap.phase === 'locked' && !initialProfileSnapshot?.key);
const [confirm, setConfirm] = useState<AppConfirmState | null>(null);
const [mobileLayout, setMobileLayout] = useState(false);
@@ -273,6 +280,16 @@ export default function App() {
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
}, [themePreference]);
useEffect(() => {
saveProfileSnapshot(profile);
}, [profile]);
useEffect(() => {
if (phase === 'locked' && profile?.key && session) {
setUnlockPreparing(false);
}
}, [phase, profile, session]);
useEffect(() => installMagneticUiFeedback(), []);
function handleToggleTheme() {
@@ -334,6 +351,7 @@ export default function App() {
setSession(boot.session);
setProfile(boot.profile);
setPhase(boot.phase);
setUnlockPreparing(boot.phase === 'locked' && !boot.profile?.key);
})();
return () => {
@@ -341,9 +359,34 @@ export default function App() {
};
}, [initialBootstrap]);
useEffect(() => {
if (phase !== 'locked' || !session) return;
let cancelled = false;
void (async () => {
const result = await hydrateLockedSession(session, profile);
if (cancelled) return;
if (!result.session) {
setSession(null);
setProfile(null);
setUnlockPreparing(false);
setPhase('login');
if (location !== '/login') navigate('/login');
return;
}
setSession(result.session);
if (result.profile) {
setProfile(result.profile);
}
})();
return () => {
cancelled = true;
};
}, [phase, session?.email, location, navigate]);
async function finalizeLogin(login: CompletedLogin) {
setSession(login.session);
setProfile(login.profile);
setUnlockPreparing(false);
setPendingTotp(null);
setTotpCode('');
setPhase('app');
@@ -528,6 +571,7 @@ export default function App() {
const nextSession = await performUnlock(session, profile, unlockPassword, defaultKdfIterations);
setSession(nextSession);
setUnlockPassword('');
setUnlockPreparing(false);
setPhase('app');
if (location === '/' || location === '/lock') navigate('/vault');
pushToast('success', t('txt_unlocked'));
@@ -544,14 +588,18 @@ export default function App() {
delete nextSession.symEncKey;
delete nextSession.symMacKey;
setSession(nextSession);
setUnlockPreparing(false);
setPhase('locked');
navigate('/lock');
}
function logoutNow() {
void revokeCurrentSession(sessionRef.current);
setConfirm(null);
setSession(null);
clearProfileSnapshot();
setProfile(null);
setUnlockPreparing(false);
setPendingTotp(null);
setPhase('login');
navigate('/login');
@@ -882,9 +930,11 @@ export default function App() {
const connect = () => {
if (disposed) return;
const accessToken = session.accessToken;
if (!accessToken) return;
try {
const hubUrl = new URL('/notifications/hub', window.location.origin);
hubUrl.searchParams.set('access_token', session.accessToken);
hubUrl.searchParams.set('access_token', accessToken);
hubUrl.protocol = hubUrl.protocol === 'https:' ? 'wss:' : 'ws:';
socket = new WebSocket(hubUrl.toString());
} catch {
@@ -1198,7 +1248,8 @@ export default function App() {
<AuthViews
mode={phase}
pendingAction={pendingAuthAction}
unlockReady={!!profile}
unlockReady={!!profile?.key && !!session}
unlockPreparing={unlockPreparing}
loginValues={loginValues}
registerValues={registerValues}
unlockPassword={unlockPassword}
+7 -3
View File
@@ -21,6 +21,7 @@ interface AuthViewsProps {
mode: 'login' | 'register' | 'locked';
pendingAction: 'login' | 'register' | 'unlock' | null;
unlockReady: boolean;
unlockPreparing: boolean;
loginValues: LoginValues;
registerValues: RegisterValues;
unlockPassword: string;
@@ -97,14 +98,17 @@ export default function AuthViews(props: AuthViewsProps) {
type="button"
className="auth-link-btn"
onClick={props.onShowLockedPasswordHint}
disabled={unlockBusy}
disabled={unlockBusy || props.unlockPreparing}
>
{t('txt_show_password_hint')}
</button>
</div>
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || !props.unlockReady}>
{props.unlockPreparing ? (
<p className="muted standalone-muted">{t('txt_loading')}</p>
) : null}
<button type="submit" className="btn btn-primary full" disabled={unlockBusy || props.unlockPreparing || !props.unlockReady}>
<Unlock size={16} className="btn-icon" />
{unlockBusy ? t('txt_unlocking') : t('txt_unlock')}
{unlockBusy ? t('txt_unlocking') : props.unlockPreparing ? t('txt_loading') : t('txt_unlock')}
</button>
<div className="or">{t('txt_or')}</div>
<button type="button" className="btn btn-secondary full" onClick={props.onLogout} disabled={unlockBusy}>
+116 -19
View File
@@ -10,8 +10,10 @@ import type {
import { parseJson, type AuthedFetch, type SessionSetter } from './shared';
const SESSION_KEY = 'nodewarden.web.session.v4';
const PROFILE_SNAPSHOT_KEY = 'nodewarden.web.profile-snapshot.v1';
const DEVICE_IDENTIFIER_KEY = 'nodewarden.web.device.identifier.v1';
const TOTP_REMEMBER_TOKEN_KEY = 'nodewarden.web.totp.remember-token.v1';
const WEB_SESSION_HEADER = 'X-NodeWarden-Web-Session';
export interface PreloginResult {
hash: string;
@@ -26,6 +28,24 @@ export interface PreloginKdfConfig {
kdfParallelism: number | null;
}
interface PersistedSessionState {
email: string;
authMode: 'token' | 'web-cookie';
}
interface RefreshFailure {
ok: false;
transient: boolean;
error: string;
}
interface RefreshSuccess {
ok: true;
token: TokenSuccess;
}
type RefreshResult = RefreshFailure | RefreshSuccess;
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);
@@ -66,12 +86,19 @@ export function loadSession(): SessionState | null {
try {
const raw = localStorage.getItem(SESSION_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as SessionState;
if (!parsed.accessToken || !parsed.refreshToken) return null;
const parsed = JSON.parse(raw) as Partial<SessionState> & Partial<PersistedSessionState>;
if (parsed.authMode === 'web-cookie' && parsed.email) {
return {
email: parsed.email,
authMode: 'web-cookie',
};
}
if (!parsed.accessToken || !parsed.refreshToken || !parsed.email) return null;
return {
accessToken: parsed.accessToken,
refreshToken: parsed.refreshToken,
email: parsed.email,
authMode: 'token',
};
} catch {
return null;
@@ -83,14 +110,35 @@ export function saveSession(session: SessionState | null): void {
localStorage.removeItem(SESSION_KEY);
return;
}
const persisted: SessionState = {
accessToken: session.accessToken,
refreshToken: session.refreshToken,
const persisted: PersistedSessionState = {
email: session.email,
authMode: session.authMode === 'token' ? 'token' : 'web-cookie',
};
localStorage.setItem(SESSION_KEY, JSON.stringify(persisted));
}
export function loadProfileSnapshot(email?: string | null): Profile | null {
try {
const raw = localStorage.getItem(PROFILE_SNAPSHOT_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as Profile;
if (!parsed?.email || !parsed?.key) return null;
if (email && parsed.email !== email) return null;
return parsed;
} catch {
return null;
}
}
export function saveProfileSnapshot(profile: Profile | null): void {
if (!profile) return;
localStorage.setItem(PROFILE_SNAPSHOT_KEY, JSON.stringify(profile));
}
export function clearProfileSnapshot(): void {
localStorage.removeItem(PROFILE_SNAPSHOT_KEY);
}
export function getCurrentDeviceIdentifier(): string {
return (localStorage.getItem(DEVICE_IDENTIFIER_KEY) || '').trim();
}
@@ -170,7 +218,10 @@ export async function loginWithPassword(
}
const resp = await fetch('/identity/connect/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
[WEB_SESSION_HEADER]: '1',
},
body: body.toString(),
});
const json = (await parseJson<TokenSuccess & TokenError>(resp)) || {};
@@ -183,18 +234,60 @@ export async function loginWithPassword(
return json;
}
export async function refreshAccessToken(refreshToken: string): Promise<TokenSuccess | null> {
function isTransientRefreshStatus(status: number): boolean {
return status === 0 || status === 429 || status >= 500;
}
export async function refreshAccessToken(session: SessionState): Promise<RefreshResult> {
const body = new URLSearchParams();
body.set('grant_type', 'refresh_token');
body.set('refresh_token', refreshToken);
const resp = await fetch('/identity/connect/token', {
if (session.authMode !== 'web-cookie' && session.refreshToken) {
body.set('refresh_token', session.refreshToken);
}
try {
const resp = await fetch('/identity/connect/token', {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(session.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
},
body: body.toString(),
});
if (!resp.ok) {
const json = await parseJson<TokenError>(resp);
return {
ok: false,
transient: isTransientRefreshStatus(resp.status),
error: json?.error_description || json?.error || 'Session refresh failed',
};
}
const json = await parseJson<TokenSuccess>(resp);
if (!json?.access_token) {
return { ok: false, transient: false, error: 'Session refresh failed' };
}
return { ok: true, token: json };
} catch (error) {
return {
ok: false,
transient: true,
error: error instanceof Error ? error.message : 'Network error',
};
}
}
export async function revokeCurrentSession(session: SessionState | null): Promise<void> {
const body = new URLSearchParams();
if (session?.authMode !== 'web-cookie' && session?.refreshToken) {
body.set('token', session.refreshToken);
}
await fetch('/identity/connect/revocation', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
...(session?.authMode === 'web-cookie' ? { [WEB_SESSION_HEADER]: '1' } : {}),
},
body: body.toString(),
});
if (!resp.ok) return null;
const json = await parseJson<TokenSuccess>(resp);
return json || null;
}).catch(() => undefined);
}
export async function registerAccount(args: {
@@ -279,18 +372,22 @@ export function createAuthedFetch(getSession: () => SessionState | null, setSess
headers.set('Authorization', `Bearer ${session.accessToken}`);
let resp = await fetch(input, { ...init, headers });
if (resp.status !== 401 || !session.refreshToken) return resp;
if (resp.status !== 401 || (!session.refreshToken && session.authMode !== 'web-cookie')) return resp;
const refreshed = await refreshAccessToken(session.refreshToken);
if (!refreshed?.access_token) {
const refreshed = await refreshAccessToken(session);
if (!refreshed.ok) {
if (refreshed.transient) {
throw new Error(refreshed.error || 'Session refresh temporarily unavailable');
}
setSession(null);
throw new Error('Session expired');
}
const nextSession: SessionState = {
...session,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token || session.refreshToken,
accessToken: refreshed.token.access_token,
refreshToken: refreshed.token.refresh_token || session.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
};
setSession(nextSession);
saveSession(nextSession);
+1
View File
@@ -152,6 +152,7 @@ export async function createSend(
const uploadInfo = await parseJson<{ url?: string; sendResponse?: Send; fileUploadType?: number }>(fileResp);
const uploadUrl = uploadInfo?.url;
if (!uploadUrl) throw new Error('Create file send failed: missing upload URL');
if (!session.accessToken) throw new Error('Unauthorized');
const payload = new ArrayBuffer(encryptedFileBytes.byteLength);
new Uint8Array(payload).set(encryptedFileBytes);
const uploadResp = await uploadDirectEncryptedPayload({
+1
View File
@@ -240,6 +240,7 @@ export async function uploadCipherAttachment(
const attachmentId = String(meta.attachmentId || '').trim();
const uploadUrl = String(meta.url || '').trim();
if (!attachmentId || !uploadUrl) throw new Error('Create attachment failed');
if (!session.accessToken) throw new Error('Unauthorized');
const payload = new ArrayBuffer(encryptedBytes.byteLength);
new Uint8Array(payload).set(encryptedBytes);
+47 -22
View File
@@ -2,6 +2,7 @@ import {
createAuthedFetch,
deriveLoginHashLocally,
getProfile,
loadProfileSnapshot,
loadSession,
loginWithPassword,
refreshAccessToken,
@@ -26,6 +27,7 @@ export interface BootstrapAppResult {
session: SessionState | null;
profile: Profile | null;
phase: AppPhase;
needsBackgroundHydration?: boolean;
}
export interface InitialAppBootstrapState {
@@ -51,8 +53,9 @@ export interface RecoverTwoFactorResult {
newRecoveryCode: string | null;
}
function decodeJwtExp(accessToken: string): number | null {
function decodeJwtExp(accessToken: string | undefined): number | null {
try {
if (!accessToken) return null;
const parts = accessToken.split('.');
if (parts.length < 2) return null;
const payload = parts[1].replace(/-/g, '+').replace(/_/g, '/');
@@ -66,23 +69,24 @@ function decodeJwtExp(accessToken: string): number | null {
}
async function maybeRefreshSession(session: SessionState): Promise<SessionState | null> {
if (!session.refreshToken) return session;
if (!session.refreshToken && session.authMode !== 'web-cookie') return session.accessToken ? session : null;
const exp = decodeJwtExp(session.accessToken);
const nowSeconds = Math.floor(Date.now() / 1000);
if (exp !== null && exp - nowSeconds > 60) {
if (session.accessToken && exp !== null && exp - nowSeconds > 60) {
return session;
}
const refreshed = await refreshAccessToken(session.refreshToken);
if (!refreshed?.access_token) {
return exp !== null && exp > nowSeconds ? session : null;
const refreshed = await refreshAccessToken(session);
if (!refreshed.ok) {
return session.accessToken && exp !== null && exp > nowSeconds ? session : null;
}
return {
...session,
accessToken: refreshed.access_token,
refreshToken: refreshed.refresh_token || session.refreshToken,
accessToken: refreshed.token.access_token,
refreshToken: refreshed.token.refresh_token || session.refreshToken,
authMode: refreshed.token.web_session ? 'web-cookie' : (session.authMode || 'token'),
};
}
@@ -197,31 +201,51 @@ export async function bootstrapAppSession(initial: InitialAppBootstrapState = re
};
}
const cachedProfile = loadProfileSnapshot(loaded.email);
if (cachedProfile) {
return {
defaultKdfIterations,
jwtWarning: null,
session: loaded,
profile: cachedProfile,
phase: 'locked',
needsBackgroundHydration: true,
};
}
return {
defaultKdfIterations,
jwtWarning: null,
session: loaded,
profile: null,
phase: 'locked',
needsBackgroundHydration: true,
};
}
export async function hydrateLockedSession(
session: SessionState,
fallbackProfile: Profile | null = null
): Promise<{ session: SessionState | null; profile: Profile | null }> {
const refreshedSession = await maybeRefreshSession(session);
if (!refreshedSession?.accessToken) {
return { session: null, profile: null };
}
try {
const session = await maybeRefreshSession(loaded);
if (!session) {
throw new Error('Session expired');
}
const profile = await getProfile(
createAuthedFetch(
() => session,
() => refreshedSession,
() => {}
)
);
return {
defaultKdfIterations,
jwtWarning: null,
session,
session: refreshedSession,
profile,
phase: 'locked',
};
} catch {
return {
defaultKdfIterations,
jwtWarning: null,
session: null,
profile: null,
phase: initial.phase === 'register' ? 'register' : 'login',
session: refreshedSession,
profile: fallbackProfile,
};
}
}
@@ -236,6 +260,7 @@ export async function completeLogin(
accessToken: token.access_token,
refreshToken: token.refresh_token,
email: normalizedEmail,
authMode: token.web_session ? 'web-cookie' : 'token',
};
const tempFetch = createAuthedFetch(
() => baseSession,
+5 -3
View File
@@ -1,9 +1,10 @@
export type AppPhase = 'register' | 'login' | 'locked' | 'app';
export interface SessionState {
accessToken: string;
refreshToken: string;
accessToken?: string;
refreshToken?: string;
email: string;
authMode?: 'token' | 'web-cookie';
symEncKey?: string;
symMacKey?: string;
}
@@ -265,7 +266,8 @@ export interface WebBootstrapResponse {
export interface TokenSuccess {
access_token: string;
refresh_token: string;
refresh_token?: string;
web_session?: boolean;
expires_in?: number;
token_type?: string;
TwoFactorToken?: string;