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 }}退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。设置功能将在后续版本开放