feat(desktop): implement client token rotation and session management enhancements
This commit is contained in:
@@ -6,6 +6,7 @@ import type {
|
||||
DesktopClientInfo,
|
||||
DesktopClientRegisterRequest,
|
||||
DesktopClientRegisterResponse,
|
||||
DesktopClientRotateResponse,
|
||||
DesktopRuntimeSessionSyncRequest,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
@@ -20,6 +21,7 @@ type SessionMode = 'authenticated' | 'preview'
|
||||
interface DesktopSession {
|
||||
mode: SessionMode
|
||||
accessToken: string | null
|
||||
accessTokenExpiresAt: number | null
|
||||
refreshToken: string | null
|
||||
user: UserInfo | null
|
||||
clientToken: string | null
|
||||
@@ -35,6 +37,9 @@ interface ResolvedDeviceInfo {
|
||||
|
||||
const sessionStorageKey = 'geo.desktop.session.v1'
|
||||
const apiBaseURLStorageKey = 'geo.desktop.api-base-url'
|
||||
const tokenRenewCheckMs = 60_000
|
||||
const accessTokenRenewWindowMs = 5 * 60_000
|
||||
const clientTokenRenewWindowMs = 3 * 24 * 60 * 60_000
|
||||
export const DEFAULT_API_BASE_URL =
|
||||
import.meta.env.VITE_DESKTOP_API_BASE_URL ?? 'https://api.shengxintui.com'
|
||||
const normalizedDefaultApiBaseURL = normalizeDesktopApiBaseURL(DEFAULT_API_BASE_URL)
|
||||
@@ -45,6 +50,8 @@ const pending = shallowRef(false)
|
||||
const error = shallowRef<string | null>(null)
|
||||
let loginPromise: Promise<void> | null = null
|
||||
let logoutPromise: Promise<void> | null = null
|
||||
let renewPromise: Promise<void> | null = null
|
||||
let tokenRenewTimer: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
function readStoredSession(): DesktopSession | null {
|
||||
if (typeof window === 'undefined') {
|
||||
@@ -52,16 +59,49 @@ function readStoredSession(): DesktopSession | null {
|
||||
}
|
||||
|
||||
try {
|
||||
if (shouldDiscardStoredSessionForWindow()) {
|
||||
window.localStorage.removeItem(sessionStorageKey)
|
||||
return null
|
||||
}
|
||||
|
||||
const raw = window.localStorage.getItem(sessionStorageKey)
|
||||
if (!raw) {
|
||||
return null
|
||||
}
|
||||
return JSON.parse(raw) as DesktopSession
|
||||
return normalizeStoredSession(JSON.parse(raw))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function shouldDiscardStoredSessionForWindow(): boolean {
|
||||
return new URLSearchParams(window.location.search).get('window') === 'login'
|
||||
}
|
||||
|
||||
function normalizeStoredSession(value: unknown): DesktopSession | null {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null
|
||||
}
|
||||
|
||||
const candidate = value as Partial<DesktopSession>
|
||||
if (candidate.mode !== 'authenticated' && candidate.mode !== 'preview') {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
mode: candidate.mode,
|
||||
accessToken: typeof candidate.accessToken === 'string' ? candidate.accessToken : null,
|
||||
accessTokenExpiresAt:
|
||||
typeof candidate.accessTokenExpiresAt === 'number' ? candidate.accessTokenExpiresAt : null,
|
||||
refreshToken: typeof candidate.refreshToken === 'string' ? candidate.refreshToken : null,
|
||||
user: candidate.user ?? null,
|
||||
clientToken: typeof candidate.clientToken === 'string' ? candidate.clientToken : null,
|
||||
clientTokenExpiresAt:
|
||||
typeof candidate.clientTokenExpiresAt === 'number' ? candidate.clientTokenExpiresAt : null,
|
||||
desktopClient: candidate.desktopClient ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function persistSession(next: DesktopSession | null): void {
|
||||
session.value = next
|
||||
|
||||
@@ -124,6 +164,7 @@ function createAuthenticatedClient() {
|
||||
persistSession({
|
||||
...session.value,
|
||||
accessToken: tokens.accessToken,
|
||||
accessTokenExpiresAt: tokens.expiresAt ?? session.value.accessTokenExpiresAt,
|
||||
refreshToken: tokens.refreshToken,
|
||||
})
|
||||
},
|
||||
@@ -137,6 +178,7 @@ function createAuthenticatedClient() {
|
||||
return {
|
||||
accessToken: next.access_token,
|
||||
refreshToken: next.refresh_token,
|
||||
expiresAt: next.expires_at,
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -170,12 +212,143 @@ function createSessionBoundAuthenticatedClient(source: DesktopSession) {
|
||||
return {
|
||||
accessToken: next.access_token,
|
||||
refreshToken: next.refresh_token,
|
||||
expiresAt: next.expires_at,
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function expiresAtMs(value: number | null | undefined): number | null {
|
||||
if (!value || !Number.isFinite(value)) {
|
||||
return null
|
||||
}
|
||||
return value < 10_000_000_000 ? value * 1000 : value
|
||||
}
|
||||
|
||||
function isRenewDue(value: number | null | undefined, renewWindowMs: number): boolean {
|
||||
const expiresAt = expiresAtMs(value)
|
||||
if (!expiresAt) {
|
||||
return true
|
||||
}
|
||||
return expiresAt - Date.now() <= renewWindowMs
|
||||
}
|
||||
|
||||
async function refreshLoginTokens(current: DesktopSession): Promise<DesktopSession> {
|
||||
if (!current.refreshToken) {
|
||||
throw new Error('missing_refresh_token')
|
||||
}
|
||||
|
||||
const next = await createPublicClient().post<RefreshResponse, { refresh_token: string }>(
|
||||
'/api/auth/refresh',
|
||||
{ refresh_token: current.refreshToken },
|
||||
)
|
||||
|
||||
return {
|
||||
...current,
|
||||
accessToken: next.access_token,
|
||||
accessTokenExpiresAt: next.expires_at,
|
||||
refreshToken: next.refresh_token,
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateRuntimeClientToken(current: DesktopSession): Promise<DesktopSession> {
|
||||
if (!current.clientToken || !current.desktopClient) {
|
||||
throw new Error('missing_desktop_client_token')
|
||||
}
|
||||
|
||||
const rotated = await window.desktopBridge?.app?.rotateRuntimeClientToken?.()
|
||||
if (!rotated) {
|
||||
throw new Error('desktop_client_rotate_unavailable')
|
||||
}
|
||||
|
||||
return applyRotatedDesktopClient(current, rotated)
|
||||
}
|
||||
|
||||
function applyRotatedDesktopClient(
|
||||
current: DesktopSession,
|
||||
rotated: DesktopClientRotateResponse,
|
||||
): DesktopSession {
|
||||
return {
|
||||
...current,
|
||||
clientToken: rotated.client_token,
|
||||
clientTokenExpiresAt: rotated.expires_at,
|
||||
desktopClient: rotated.client,
|
||||
}
|
||||
}
|
||||
|
||||
async function forceLogoutAfterRenewFailure(err: unknown): Promise<void> {
|
||||
console.warn('[desktop-session] token renewal failed', err)
|
||||
error.value = '登录状态已过期,请重新登录'
|
||||
persistSession(null)
|
||||
try {
|
||||
await window.desktopBridge?.app?.releaseRuntimeSession?.(true)
|
||||
} catch (releaseError) {
|
||||
console.warn('[desktop-session] release after renewal failure failed', releaseError)
|
||||
}
|
||||
await transitionWindowForLogout()
|
||||
}
|
||||
|
||||
async function renewAuthenticatedSession(options: { force?: boolean } = {}): Promise<void> {
|
||||
const current = session.value
|
||||
if (current?.mode !== 'authenticated') {
|
||||
return
|
||||
}
|
||||
|
||||
if (renewPromise) {
|
||||
return renewPromise
|
||||
}
|
||||
|
||||
renewPromise = (async () => {
|
||||
let next = current
|
||||
let expectedSession = current
|
||||
const needsAccessRefresh =
|
||||
options.force || isRenewDue(next.accessTokenExpiresAt, accessTokenRenewWindowMs)
|
||||
const needsClientRotate =
|
||||
options.force || isRenewDue(next.clientTokenExpiresAt, clientTokenRenewWindowMs)
|
||||
|
||||
if (!needsAccessRefresh && !needsClientRotate) {
|
||||
return
|
||||
}
|
||||
|
||||
if (needsAccessRefresh) {
|
||||
next = await refreshLoginTokens(next)
|
||||
if (session.value !== expectedSession) {
|
||||
return
|
||||
}
|
||||
persistSession(next)
|
||||
expectedSession = next
|
||||
}
|
||||
|
||||
if (needsClientRotate) {
|
||||
next = await rotateRuntimeClientToken(next)
|
||||
if (session.value !== expectedSession) {
|
||||
return
|
||||
}
|
||||
persistSession(next)
|
||||
expectedSession = next
|
||||
}
|
||||
})()
|
||||
.catch(async (err) => {
|
||||
await forceLogoutAfterRenewFailure(err)
|
||||
})
|
||||
.finally(() => {
|
||||
renewPromise = null
|
||||
})
|
||||
|
||||
return renewPromise
|
||||
}
|
||||
|
||||
function ensureTokenRenewTimer(): void {
|
||||
if (typeof window === 'undefined' || tokenRenewTimer) {
|
||||
return
|
||||
}
|
||||
|
||||
tokenRenewTimer = setInterval(() => {
|
||||
void renewAuthenticatedSession()
|
||||
}, tokenRenewCheckMs)
|
||||
}
|
||||
|
||||
function resolveFallbackPlatform(): string {
|
||||
const currentNavigator = globalThis.navigator as Navigator & {
|
||||
userAgentData?: { platform?: string }
|
||||
@@ -431,6 +604,7 @@ async function performLogin(payload: LoginRequest): Promise<void> {
|
||||
persistSession({
|
||||
mode: 'authenticated',
|
||||
accessToken: authData.access_token,
|
||||
accessTokenExpiresAt: authData.expires_at,
|
||||
refreshToken: authData.refresh_token,
|
||||
user: authData.user,
|
||||
clientToken: null,
|
||||
@@ -443,6 +617,7 @@ async function performLogin(payload: LoginRequest): Promise<void> {
|
||||
persistSession({
|
||||
mode: 'authenticated',
|
||||
accessToken: authData.access_token,
|
||||
accessTokenExpiresAt: authData.expires_at,
|
||||
refreshToken: authData.refresh_token,
|
||||
user: authData.user,
|
||||
clientToken: registered.client_token,
|
||||
@@ -474,6 +649,7 @@ async function enterPreview(): Promise<void> {
|
||||
persistSession({
|
||||
mode: 'preview',
|
||||
accessToken: null,
|
||||
accessTokenExpiresAt: null,
|
||||
refreshToken: null,
|
||||
user: createPreviewUser(),
|
||||
clientToken: 'preview-client-token',
|
||||
@@ -541,6 +717,8 @@ if (typeof window !== 'undefined') {
|
||||
}
|
||||
})
|
||||
void syncStoredDesktopClientDeviceInfo()
|
||||
ensureTokenRenewTimer()
|
||||
void renewAuthenticatedSession()
|
||||
}
|
||||
|
||||
export function useDesktopSession() {
|
||||
@@ -553,6 +731,7 @@ export function useDesktopSession() {
|
||||
isPreviewMode: computed(() => session.value?.mode === 'preview'),
|
||||
setApiBaseURL: persistApiBaseURL,
|
||||
syncRuntimeSession: syncRuntimeSessionToMain,
|
||||
renewSession: renewAuthenticatedSession,
|
||||
login,
|
||||
logout,
|
||||
enterPreview,
|
||||
|
||||
Reference in New Issue
Block a user