From b247b0cbb4b01d736daef788e254b3f52a12290a Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Sun, 4 Oct 2026 21:28:53 +0800 Subject: [PATCH] fix(auth): revoke overseas PDA session on user logout --- src/api/auth.js | 22 ++++++- src/api/logout-session.js | 53 ++++++++++++++++ src/pages/profile/profile.vue | 19 +++++- tests/user-logout.test.mjs | 116 ++++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 6 deletions(-) create mode 100644 src/api/logout-session.js create mode 100644 tests/user-logout.test.mjs diff --git a/src/api/auth.js b/src/api/auth.js index 966d4e7..da78f63 100644 --- a/src/api/auth.js +++ b/src/api/auth.js @@ -1,6 +1,8 @@ import { API_ROOT } from '../config/runtime.js' -function authRequest(path, data) { +const pendingRefreshes = new Map() + +function authRequest(path, data, accessToken = '') { return new Promise((resolve, reject) => { const url = `${API_ROOT}${path}` console.info('[Overseas PDA auth] request', { method: 'POST', url }) @@ -8,6 +10,8 @@ function authRequest(path, data) { url, method: 'POST', data, + header: { Accept: 'application/json', 'Content-Type': 'application/json', + ...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}) }, success(response) { const body = response.data || {} console.info('[Overseas PDA auth] response', { url, statusCode: response.statusCode, code: body.code, @@ -15,9 +19,10 @@ function authRequest(path, data) { contentType: response.header?.['Content-Type'] || response.header?.['content-type'], requestId: body.requestId || response.header?.['X-Request-Id'] || response.header?.['x-request-id'], traceId: body.traceId || response.header?.['X-Trace-Id'] || response.header?.['x-trace-id'] }) - if (String(body.code ?? response.statusCode) !== '200') { + if (response.statusCode !== 200 || String(body.code) !== '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) @@ -40,5 +45,16 @@ export function login(loginId, password) { } 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(accessToken, refreshToken) { + return authRequest('/auth/logout', { refreshToken }, accessToken) } diff --git a/src/api/logout-session.js b/src/api/logout-session.js new file mode 100644 index 0000000..35500f2 --- /dev/null +++ b/src/api/logout-session.js @@ -0,0 +1,53 @@ +import { logout, refresh, getPendingRefresh } from './auth.js' +import { session, signOut, getSessionRevision, decodeAccessToken } from '../stores/session.js' + +function replacementForCapturedSession(captured, tokens) { + if (!tokens?.accessToken || typeof tokens.refreshToken !== 'string' || !tokens.refreshToken.trim()) { + throw new Error('退出刷新响应缺少会话凭据') + } + const previous = decodeAccessToken(captured.accessToken) + const replacement = decodeAccessToken(tokens.accessToken) + if (!Number.isFinite(Number(replacement?.exp)) || Number(replacement.exp) * 1000 <= Date.now()) { + throw new Error('退出刷新凭据无效或已过期') + } + for (const name of ['user_id', 'membership_id', 'tenant_id', 'app_code', 'overseas_warehouse_id']) { + if (previous[name] == null || String(previous[name]) !== String(replacement[name])) { + throw new Error('退出刷新返回了不同作业主体,无法确认原会话撤销') + } + } + return { accessToken: tokens.accessToken, refreshToken: tokens.refreshToken } +} + +// Active user logout only. Ordinary authentication failures continue to use local signOut. +export function logoutCurrentSession() { + const captured = { + accessToken: session.token, refreshToken: session.refreshToken, + pendingRefresh: getPendingRefresh(session.refreshToken) + } + signOut() + const clearedRevision = getSessionRevision() + const revoke = async () => { + if (!captured.accessToken || !captured.refreshToken) return { revoked: false, reason: 'NO_CAPTURED_SESSION' } + let credentials = captured + let refreshed = false + if (captured.pendingRefresh) { + // Auth rotates access/refresh credentials. Revoke the captured refresh's result, + // while the business refresh wrapper's revision guard prevents local restoration. + refreshed = true + credentials = replacementForCapturedSession(captured, await captured.pendingRefresh) + } + try { + await logout(credentials.accessToken, credentials.refreshToken) + } catch (error) { + if (error.network || error.statusCode !== 401 || refreshed) throw error + // Only an explicit expired-access HTTP 401 permits one refresh and one revoke. + credentials = replacementForCapturedSession(captured, await refresh(captured.refreshToken)) + await logout(credentials.accessToken, credentials.refreshToken) + } + return { revoked: true } + } + return revoke().then(result => ({ ...result, clearedRevision }), error => { + error.logoutRevision = clearedRevision + throw error + }) +} diff --git a/src/pages/profile/profile.vue b/src/pages/profile/profile.vue index d9c39a4..500a025 100644 --- a/src/pages/profile/profile.vue +++ b/src/pages/profile/profile.vue @@ -13,7 +13,7 @@ - 退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。 + 退出会撤销当前登录会话并清空本地作业草稿;未提交作业数据不得跨账号保留。 设置功能将在后续版本开放 @@ -22,7 +22,8 @@ import PopupHeader from '@/components/PopupHeader.vue' import { getCurrentWarehouseProfile } from '@/api/inbound.js' import { currentLocale, LOCALE_OPTIONS, setLocale } from '@/i18n/index.js' - import { applyWarehouseProfile, session, requireSession, signOut } from '@/stores/session.js' + import { applyWarehouseProfile, session, requireSession, getSessionRevision } from '@/stores/session.js' + import { logoutCurrentSession } from '@/api/logout-session.js' import { checkForAppUpdate } from '@/utils/app-update.js' export default { components: { PopupHeader, PortalHeader }, @@ -66,7 +67,19 @@ async loadWarehouseProfile() { try { this.user = applyWarehouseProfile(await getCurrentWarehouseProfile()) } catch (_) { this.user = session.currentUser } }, openLogout() { this.logoutOpen = true; this.$refs.logoutPopup.open() }, closeLogout() { this.logoutOpen = false; this.$refs.logoutPopup.close() }, handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) }, showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) }, - logout() { signOut(); uni.reLaunch({ url: '/pages/login/login' }) } + logout() { + if (this._logoutPromise) return this._logoutPromise + this.closeLogout() + const operation = logoutCurrentSession() + this.user = null + uni.reLaunch({ url: '/pages/login/login' }) + this._logoutPromise = operation.catch(error => { + if (getSessionRevision() === error.logoutRevision) { + uni.showToast({ title: '已清除本地登录,服务端退出未确认', icon: 'none' }) + } + }) + return this._logoutPromise + } } } diff --git a/tests/user-logout.test.mjs b/tests/user-logout.test.mjs new file mode 100644 index 0000000..26c8430 --- /dev/null +++ b/tests/user-logout.test.mjs @@ -0,0 +1,116 @@ +import test from 'node:test' +import assert from 'node:assert/strict' +import fs from 'node:fs' +import { PDA_ROLE, PDA_AUTHORITY, session, signInWithTokens, signOut, getSessionRevision } from '../src/stores/session.js' +import { receiving } from '../src/stores/receiving.js' +import { refresh, getPendingRefresh } from '../src/api/auth.js' +import { overseasRequest } from '../src/api/overseas.js' + +const helperUrl = new URL('../src/api/logout-session.js', import.meta.url) +const logoutHelper = fs.existsSync(helperUrl) ? await import(helperUrl) : {} +const jwt = claims => [Buffer.from('{"alg":"none"}').toString('base64url'), Buffer.from(JSON.stringify(claims)).toString('base64url'), 'signature'].join('.') +function tokens(version='old', identity={}) { return {accessToken:jwt({user_id:42,membership_id:84,tenant_id:0,app_code:'PLATFORM',overseas_warehouse_id:900,exp:Math.floor(Date.now()/1000)+3600,version,...identity}),refreshToken:`refresh-${version}`,roles:[PDA_ROLE],authorities:[PDA_AUTHORITY]} } +const ok = data => ({statusCode:200,data:{code:'200',data},header:{}}) +function prepare(){const values=new Map(),calls=[],launches=[],toasts=[];globalThis.uni={setStorageSync:(k,v)=>values.set(k,JSON.parse(JSON.stringify(v))),getStorageSync:k=>values.get(k),removeStorageSync:k=>values.delete(k),reLaunch:o=>launches.push(o.url),showToast:o=>toasts.push(o.title)};signOut();const original=tokens();signInWithTokens(original);receiving.current={containerNo:'ORIGINAL'};return {values,calls,launches,toasts,original}} +function page(){const source=fs.readFileSync(new URL('../src/pages/profile/profile.vue',import.meta.url),'utf8').match(/