From e699e9d9bacb9088cae7502570e62d36b1d46a9b Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Mon, 5 Oct 2026 13:41:34 +0800 Subject: [PATCH] fix(pda): revoke expired sessions before completing logout --- api/auth.js | 13 ++++- api/http.js | 23 +++++++-- api/logout-session.js | 33 ++++++++++++ pages/profile/profile.vue | 23 ++++++--- stores/session.js | 6 +++ tests/auth-session.test.mjs | 13 +++-- tests/page-contract.test.mjs | 2 +- tests/user-logout.test.mjs | 97 ++++++++++++++++++++++++++++++++++++ 8 files changed, 192 insertions(+), 18 deletions(-) create mode 100644 api/logout-session.js create mode 100644 tests/user-logout.test.mjs diff --git a/api/auth.js b/api/auth.js index 3e34e93..784ac4a 100644 --- a/api/auth.js +++ b/api/auth.js @@ -10,9 +10,10 @@ function authRequest(path, data, options = {}) { timeout: options.timeout || 15000, success(response) { const body = response.data || {} - if (String(body.code ?? response.statusCode) !== '200') { + if (response.statusCode !== 200 || String(body.code ?? response.statusCode) !== '200') { const error = new Error(body.message || '认证服务请求失败') error.code = body.code || response.statusCode + error.statusCode = response.statusCode error.requestId = body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'] error.traceId = body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] reject(error) @@ -33,9 +34,17 @@ export function login(loginId, password) { return authRequest('/auth/login', { loginId, password }) } +const pendingRefreshes = new Map() export function refresh(refreshToken) { - return authRequest('/auth/refresh', { refreshToken }) + if (pendingRefreshes.has(refreshToken)) return pendingRefreshes.get(refreshToken) + const pending = authRequest('/auth/refresh', { refreshToken }).finally(() => { + if (pendingRefreshes.get(refreshToken) === pending) pendingRefreshes.delete(refreshToken) + }) + pendingRefreshes.set(refreshToken, pending) + return pending } +export function getPendingRefresh(refreshToken) { return pendingRefreshes.get(refreshToken) || null } + export function logout(refreshToken, accessToken) { return authRequest('/auth/logout', { refreshToken }, { diff --git a/api/http.js b/api/http.js index eb27260..d2c56d0 100644 --- a/api/http.js +++ b/api/http.js @@ -1,8 +1,9 @@ import { refresh as refreshRequest } from '@/api/auth.js' -import { isAccessTokenExpiring, session, signOut, updateTokenPair } from '@/stores/session.js' +import { isAccessTokenExpiring, session, signOut, updateTokenPair, getSessionRevision } from '@/stores/session.js' import { requireOnline } from '@/utils/network.js' let refreshPromise = null +let refreshRevision = null let redirectingToLogin = false function responseCode(response) { @@ -66,14 +67,21 @@ function rawRequest(options, accessToken) { }) } +function requireSameSession(revision) { + if (getSessionRevision() !== revision) throw new Error('登录会话已变更,请重新操作') +} + async function refreshAccessToken() { + const revision = getSessionRevision() const observedRefreshToken = session.refreshToken if (!observedRefreshToken) return expireSession(null, observedRefreshToken) - if (!refreshPromise) { + if (!refreshPromise || refreshRevision !== revision) { + refreshRevision = revision refreshPromise = (async () => { if (session.refreshToken !== observedRefreshToken && session.token) return session.token try { const tokens = await refreshRequest(observedRefreshToken) + requireSameSession(revision) if (session.refreshToken !== observedRefreshToken) { if (session.token) return session.token throw sessionExpiredError() @@ -82,10 +90,14 @@ async function refreshAccessToken() { redirectingToLogin = false return session.token } catch (error) { + requireSameSession(revision) if (isUnauthorized(error)) return expireSession(error, observedRefreshToken) throw error } - })().finally(() => { refreshPromise = null }) + })() + const pending = refreshPromise + const release = () => { if (refreshPromise === pending) { refreshPromise = null; refreshRevision = null } } + pending.then(release, release) // Original pending promise still propagates failures to callers. } return refreshPromise } @@ -101,12 +113,17 @@ export function ensureAccessToken() { async function runAuthenticated(execute) { requireOnline() + const revision = getSessionRevision() let accessToken = await validAccessToken() + requireSameSession(revision) let response = await execute(accessToken) + requireSameSession(revision) if (!isUnauthorized(response)) return response accessToken = session.token && session.token !== accessToken ? session.token : await refreshAccessToken() + requireSameSession(revision) const activeRefreshToken = session.refreshToken response = await execute(accessToken) + requireSameSession(revision) if (isUnauthorized(response)) expireSession(response, activeRefreshToken) return response } diff --git a/api/logout-session.js b/api/logout-session.js new file mode 100644 index 0000000..fdc8a6f --- /dev/null +++ b/api/logout-session.js @@ -0,0 +1,33 @@ +import { logout, refresh, getPendingRefresh } from './auth.js' +import { session, signOut, getSessionRevision, decodeAccessToken } from '../stores/session.js' + +function replacement(captured, tokens) { + if (!tokens?.accessToken || typeof tokens.refreshToken !== 'string' || !tokens.refreshToken.trim()) throw new Error('退出刷新响应缺少会话凭据') + const previous = decodeAccessToken(captured.accessToken), next = decodeAccessToken(tokens.accessToken) + if (!Number.isFinite(Number(next.exp)) || Number(next.exp) * 1000 <= Date.now()) throw new Error('退出刷新凭据无效或已过期') + for (const field of ['user_id', 'membership_id', 'tenant_id', 'app_code']) { + if (previous[field] == null || String(previous[field]) !== String(next[field])) throw new Error('退出刷新返回了不同作业主体,无法确认原会话撤销') + } + if (String(previous.domestic_warehouse_id ?? '') !== String(next.domestic_warehouse_id ?? '')) throw new Error('退出刷新返回了不同作业仓库,无法确认原会话撤销') + return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken } +} +export function logoutCurrentSession() { + const captured = { accessToken: session.token, refreshToken: session.refreshToken, pendingRefresh: getPendingRefresh(session.refreshToken) } + // Clear sensitive data and invalidate business requests while revocation is pending. + signOut() + const clearedRevision = getSessionRevision() + const revoke = async () => { + if (!captured.accessToken && !captured.refreshToken) return { revoked: false, reason: 'NO_CAPTURED_SESSION' } + if (!captured.accessToken || !captured.refreshToken) throw new Error('原会话凭据不完整,服务端退出未确认') + let credentials = captured, refreshed = false + if (captured.pendingRefresh) { refreshed = true; credentials = replacement(captured, await captured.pendingRefresh) } + try { await logout(credentials.refreshToken, credentials.accessToken) } + catch (error) { + if (error.network || error.statusCode !== 401 || refreshed) throw error + credentials = replacement(captured, await refresh(captured.refreshToken)) + await logout(credentials.refreshToken, credentials.accessToken) + } + return { revoked: true } + } + return revoke().then(result => ({ ...result, clearedRevision }), error => { error.logoutRevision = clearedRevision; throw error }) +} diff --git a/pages/profile/profile.vue b/pages/profile/profile.vue index 239e84e..ac100c1 100644 --- a/pages/profile/profile.vue +++ b/pages/profile/profile.vue @@ -2,10 +2,10 @@ {{ user.name.slice(0, 1) }}{{ user.name }}国内仓作业员作业仓库{{ user.warehouse }}当前班次08:00–17:00应用版本{{ appVersion }}退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。设置功能将在后续版本开放