fix(pda): revoke expired sessions before completing logout

This commit is contained in:
2026-10-05 13:41:34 +08:00
parent 7aebbd2a70
commit e699e9d9ba
8 changed files with 192 additions and 18 deletions
+11 -2
View File
@@ -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 }, {
+20 -3
View File
@@ -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
}
+33
View File
@@ -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 })
}
+16 -7
View File
@@ -2,10 +2,10 @@
<view v-if="user" class="page-shell portal-page"><PortalHeader title="个人中心" :user="user" :show-profile="false" /><view class="portal-content profile-content"><view class="profile-card"><view class="profile-avatar">{{ user.name.slice(0, 1) }}</view><view><text class="profile-name">{{ user.name }}</text><text class="profile-role">国内仓作业员</text></view></view><view class="profile-list"><view class="profile-row"><text>作业仓库</text><text class="profile-value">{{ user.warehouse }}</text></view><view class="profile-row"><text>当前班次</text><text class="profile-value">08:00–17:00</text></view><view class="profile-row"><text>应用版本</text><text class="profile-value">{{ appVersion }}</text></view><button class="profile-row profile-row-action" @tap="checkUpdate"><text>检查更新</text><text class="profile-row-arrow">›</text></button><button class="profile-row profile-row-action" @tap="showSettings"><text>系统设置</text><text class="profile-row-arrow">›</text></button></view><button class="logout-action" @tap="openLogout">退出登录</button></view><uni-popup ref="logoutPopup" type="center" :is-mask-click="true" @change="handleLogoutChange"><view class="logout-dialog-card" role="dialog" aria-modal="true" aria-label="确认退出登录"><PopupHeader title="确认退出登录?" tone="danger" @close="closeLogout" /><text class="logout-dialog-content">退出后会清空本次会话中的箱号草稿、扫码记录与作业进度。</text><view class="logout-dialog-actions"><button class="logout-dialog-cancel" @tap="closeLogout">取消</button><button class="logout-dialog-confirm" @tap="logout">确认退出</button></view></view></uni-popup><view v-if="toast" class="app-toast warning" role="status">设置功能将在后续版本开放</view></view>
</template>
<script>
import { logout as logoutRequest } from '@/api/auth.js'
import { logoutCurrentSession } from '@/api/logout-session.js'
import PortalHeader from '@/components/PortalHeader.vue'
import PopupHeader from '@/components/PopupHeader.vue'
import { session, requireSession, signOut } from '@/stores/session.js'
import { session, requireSession, getSessionRevision } from '@/stores/session.js'
import { checkForAppUpdate } from '@/utils/app-update.js'
export default {
@@ -34,11 +34,20 @@
handleLogoutChange(event) { this.logoutOpen = Boolean(event.show) },
showSettings() { clearTimeout(this._toastTimer); this.toast = true; this._toastTimer = setTimeout(() => { this.toast = false }, 2200) },
logout() {
const refreshToken = session.refreshToken
const accessToken = session.token
signOut()
uni.reLaunch({ url: '/pages/login/login' })
if (refreshToken) logoutRequest(refreshToken, accessToken).catch(() => {})
if (this._logoutPromise) return this._logoutPromise
this.closeLogout()
const operation = logoutCurrentSession()
this.user = null
this._logoutPromise = operation.then(result => {
if (getSessionRevision() === result.clearedRevision) uni.reLaunch({ url: '/pages/login/login' })
return result
}, error => {
if (getSessionRevision() === error.logoutRevision) {
uni.reLaunch({ url: '/pages/login/login', complete() { uni.showToast({ title: '已清除本地登录,服务端退出未确认', icon: 'none', duration: 3000 }) } })
}
return { revoked: false, error }
})
return this._logoutPromise
}
}
}
+6
View File
@@ -5,6 +5,9 @@ import { decodeAccessToken } from '@/utils/jwt-claims.js'
export { decodeAccessToken } from '@/utils/jwt-claims.js'
let sessionRevision = 0
export function getSessionRevision() { return sessionRevision }
const SESSION_KEY = 'pda-auth-session'
const LOGIN_PREFERENCE_KEY = 'pda-login-account-preference'
@@ -53,6 +56,7 @@ function persistSession() {
}
export function signIn(account, warehouse, options = {}) {
sessionRevision++
session.currentUser = { id: String(account.id), name: account.name, role: account.role, roles: account.roles || [], warehouse, warehouseId: account.warehouseId || null, domesticWarehouseId: account.domesticWarehouseId || null, warehouseBound: Boolean(account.domesticWarehouseId) }
session.token = options.token || account.token || ''
session.refreshToken = options.refreshToken || account.refreshToken || ''
@@ -144,6 +148,7 @@ export function restoreSession() {
tenantId: claims.tenant_id == null ? (saved.tenantId || '') : String(claims.tenant_id),
apiBase: saved.apiBase || PLATFORM_API_ROOT
})
sessionRevision++
persistSession()
return true
} catch (_) {
@@ -153,6 +158,7 @@ export function restoreSession() {
}
export function signOut() {
sessionRevision++
session.currentUser = null
session.token = ''
session.refreshToken = ''
+8 -5
View File
@@ -57,11 +57,14 @@ test('confirmed session expiry clears sensitive state and redirects only once',
assert.match(http, /if \(isUnauthorized\(error\)\) return expireSession/)
})
test('manual logout revokes the refresh token without delaying local cleanup', async () => {
const [profile, auth] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/auth.js')])
assert.match(auth, /export function logout\(refreshToken, accessToken\)/)
assert.match(profile, /const refreshToken = session\.refreshToken[\s\S]*?const accessToken = session\.token[\s\S]*?signOut\(\)[\s\S]*?uni\.reLaunch/)
assert.match(profile, /logoutRequest\(refreshToken, accessToken\)\.catch/)
test('manual logout waits for captured-session revoke and honestly handles failure', async () => {
const [profile, helper] = await Promise.all([read('../pages/profile/profile.vue'), read('../api/logout-session.js')])
assert.match(profile, /logoutCurrentSession\(\)/)
assert.match(profile, /operation\.then/)
assert.match(profile, /服务端退出未确认/)
assert.match(helper, /signOut\(\)/)
assert.match(helper, /error\.statusCode !== 401/)
assert.doesNotMatch(profile, /catch\(\(\) => \{\}\)/)
})
test('production login starts empty and remembers only the account preference', async () => {
+1 -1
View File
@@ -103,7 +103,7 @@ test('workbench, module, history and profile are independent sources', async ()
assert.match(module, /pages\/operation\/operation\?module=\$\{encodeURIComponent\(this\.moduleKey\)\}&name=/)
assert.match(history, /作业记录/)
assert.match(profile, /个人中心/)
assert.match(profile, /signOut\(\)/)
assert.match(profile, /logoutCurrentSession\(\)/)
assert.match(profile, /class="logout-dialog-card"/)
assert.doesNotMatch([home, module, history, profile].join('\n'), /BottomNav/)
})
+97
View File
@@ -0,0 +1,97 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import fs from 'node:fs'
import { decodeAccessToken } from '../utils/jwt-claims.js'
const source = path => fs.readFileSync(new URL(path, import.meta.url), 'utf8')
const plain = text => text.replace(/^\s*(?:import|export \{)[^\n]*$/gm, '').replace(/export (?=function|const)/g, '')
const pair = (version='old', identity={}) => ({ accessToken: `header.${Buffer.from(JSON.stringify({ user_id:'42', membership_id:'84', tenant_id:'0', app_code:'PLATFORM', domestic_warehouse_id:'900', exp:Math.floor(Date.now()/1000)+3600, version, ...identity })).toString('base64url')}.signature`, refreshToken:`refresh-${version}` })
const ok = data => ({statusCode:200,data:{code:'200',data},header:{}})
const denied = (statusCode=401,code='A0401') => ({statusCode,data:{code,message:'denied',requestId:'req',traceId:'trace'},header:{}})
function harness() {
const values=new Map(),calls=[],launches=[],toasts=[];let drafts={owner:'old'}
globalThis.uni={setStorageSync:(k,v)=>values.set(k,structuredClone(v)),getStorageSync:k=>values.get(k),removeStorageSync:k=>values.delete(k),reLaunch:o=>{launches.push(o.url);o.complete?.()},showToast:o=>toasts.push(o.title),request:o=>calls.push(o)}
const s=new Function('reactive','resetPickingSession','PLATFORM_API_ROOT','decodeAccessToken',plain(source('../stores/session.js'))+';return {session,signInWithTokens,signOut,updateTokenPair,isAccessTokenExpiring,requireSession,getSessionRevision:typeof getSessionRevision === "function" ? getSessionRevision : () => 0}')(x=>x,()=>{drafts=null},'/api/platform/v1',decodeAccessToken)
const auth=new Function('API_ROOT',plain(source('../api/auth.js'))+';return {logout,refresh,getPendingRefresh:typeof getPendingRefresh === "function" ? getPendingRefresh : () => null}')('/api')
const path=new URL('../api/logout-session.js',import.meta.url)
const helper=fs.existsSync(path)?new Function('logout','refresh','getPendingRefresh','session','signOut','getSessionRevision','decodeAccessToken',plain(fs.readFileSync(path,'utf8'))+';return {logoutCurrentSession}')(auth.logout,auth.refresh,auth.getPendingRefresh,s.session,s.signOut,s.getSessionRevision,decodeAccessToken):{}
const script=source('../pages/profile/profile.vue').match(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import[^\n]*$/gm,'').replace('export default','return')
const page=new Function('logoutRequest','PortalHeader','PopupHeader','session','requireSession','signOut','checkForAppUpdate','logoutCurrentSession','getSessionRevision',script)(auth.logout,{},{},s.session,s.requireSession,s.signOut,()=>{},helper.logoutCurrentSession,s.getSessionRevision)
const original=pair();s.signInWithTokens(original)
const http=new Function('refreshRequest','isAccessTokenExpiring','session','signOut','updateTokenPair','requireOnline','getSessionRevision',plain(source('../api/http.js'))+';return {apiRequest}')(auth.refresh,s.isAccessTokenExpiring,s.session,s.signOut,s.updateTokenPair,()=>{},s.getSessionRevision)
return {...s,auth,helper,http,page,context:{user:s.session.currentUser,closeLogout(){}},original,values,calls,launches,toasts,get drafts(){return drafts}}
}
const tick=()=>new Promise(r=>setImmediate(r))
test('profile waits for revoke before navigating and clears drafts immediately',async()=>{
const h=harness(),pending=h.page.methods.logout.call(h.context)
assert.equal(h.calls.length,1);assert.equal(h.session.token,'');assert.equal(h.drafts,null);assert.equal(h.launches.length,0)
h.calls[0].success(ok(null));await pending;assert.deepEqual(h.launches,['/pages/login/login']);assert.equal(h.values.has('pda-auth-session'),false)
})
test('duplicate confirmation shares one pending revoke',async()=>{
const h=harness(),a=h.page.methods.logout.call(h.context),b=h.page.methods.logout.call(h.context)
assert.equal(h.calls.length,1);assert.ok(a && typeof a.then === 'function');assert.equal(a,b);h.calls[0].success(ok(null));await a
})
test('expired access 401 rotates once and revokes new pair without restoring local login',async()=>{
const h=harness(),p=h.helper.logoutCurrentSession();h.calls[0].success(denied());await tick()
assert.match(h.calls[1].url,/\/auth\/refresh$/);const rotated=pair('rotated');h.calls[1].success(ok(rotated));await tick()
assert.deepEqual(h.calls[2].data,{refreshToken:rotated.refreshToken});assert.equal(h.calls[2].header.Authorization,`Bearer ${rotated.accessToken}`)
h.calls[2].success(ok(null));assert.equal((await p).revoked,true);assert.equal(h.session.token,'');assert.equal(h.calls.length,3)
})
test('network unknown, 5xx and business rejection do not trigger blind retries',async()=>{
for(const kind of ['network','server','business']) {
const h=harness(),p=h.helper.logoutCurrentSession();const rejection=assert.rejects(p)
if(kind==='network')h.calls[0].fail({errMsg:'lost'});else h.calls[0].success(denied(kind==='server'?503:200,kind==='server'?'S0503':'A0401'))
await rejection;assert.equal(h.calls.length,1);assert.equal(h.session.token,'');assert.equal(h.drafts,null)
}
})
test('failed profile logout warns instead of reporting confirmed revocation',async()=>{
const h=harness(),p=h.page.methods.logout.call(h.context);h.calls[0].fail({errMsg:'lost'});const result=await p
assert.equal(result.revoked,false);assert.deepEqual(h.toasts,['已清除本地登录,服务端退出未确认']);assert.equal(h.session.refreshToken,'')
})
test('identity mismatch or malformed rotated credentials cannot be used for another revoke',async()=>{
for(const change of [{user_id:'43'},{membership_id:'85'},{domestic_warehouse_id:'901'},{exp:1}]) {
const h=harness(),p=h.helper.logoutCurrentSession(),rejection=assert.rejects(p);h.calls[0].success(denied());await tick()
h.calls[1].success(ok(pair('wrong',change)));await rejection;assert.equal(h.calls.length,2);assert.equal(h.session.token,'')
}
})
test('second 401 is terminal with diagnostic evidence and no refresh loop',async()=>{
const h=harness(),p=h.helper.logoutCurrentSession(),rejection=assert.rejects(p,e=>e.statusCode===401 && e.requestId==='req' && e.traceId==='trace')
h.calls[0].success(denied());await tick();h.calls[1].success(ok(pair('rotated')));await tick();h.calls[2].success(denied());await rejection;assert.equal(h.calls.length,3)
})
test('logout joins in-flight automatic refresh, revokes its result, and never revives cleared session',async()=>{
const h=harness();h.session.token=pair('expired',{exp:1}).accessToken
const business=h.http.apiRequest({url:'/work'}).catch(e=>e);await tick();assert.equal(h.calls.length,1)
const exit=h.helper.logoutCurrentSession();const newer=pair('new',{user_id:'99',membership_id:'98',domestic_warehouse_id:'950'});h.signInWithTokens(newer)
const rotated=pair('rotated');h.calls[0].success(ok(rotated));await tick()
assert.equal(h.calls.length,2);assert.match(h.calls[1].url,/\/auth\/logout$/);assert.deepEqual(h.calls[1].data,{refreshToken:rotated.refreshToken})
h.calls[1].success(ok(null));await exit;assert.match((await business).message,/会话已变更/);assert.equal(h.session.token,newer.accessToken)
})
test('old logout success and failure cannot redirect or warn over a later new login',async()=>{
for(const fail of [false,true]) {
const h=harness(),p=h.page.methods.logout.call(h.context),newer=pair('new',{user_id:'99'});h.signInWithTokens(newer)
if(fail)h.calls[0].fail({errMsg:'lost'});else h.calls[0].success(ok(null));await p
assert.equal(h.session.token,newer.accessToken);assert.equal(h.launches.length,0);assert.equal(h.toasts.length,0)
}
})
test('unknown pending refresh is not repeated and its failure remains observable',async()=>{
const h=harness(),refresh=h.auth.refresh(h.original.refreshToken).catch(e=>e),exit=h.helper.logoutCurrentSession(),rejected=assert.rejects(exit,e=>e.network===true)
h.calls[0].fail({errMsg:'lost refresh'});await rejected;await refresh;assert.equal(h.calls.length,1);assert.equal(h.session.token,'')
})
test('stale business 401 after logout cannot refresh or clear a newer login',async()=>{
const h=harness(),business=h.http.apiRequest({url:'/work'}).catch(e=>e);await tick();assert.equal(h.calls.length,1)
const exit=h.helper.logoutCurrentSession(),newer=pair('new',{user_id:'99'});h.signInWithTokens(newer)
h.calls[0].success(denied());h.calls[1].success(ok(null));await exit
assert.match((await business).message,/会话已变更/);assert.equal(h.calls.length,2);assert.equal(h.session.token,newer.accessToken)
})
test('concurrent automatic requests share one refresh and are rejected after logout without business replay',async()=>{
const h=harness();h.session.token=pair('expired',{exp:1}).accessToken
const a=h.http.apiRequest({url:'/one'}).catch(e=>e),b=h.http.apiRequest({url:'/two'}).catch(e=>e);await tick();assert.equal(h.calls.length,1)
const exit=h.helper.logoutCurrentSession();h.calls[0].success(ok(pair('rotated')));await tick();assert.equal(h.calls.length,2)
h.calls[1].success(ok(null));await exit
assert.match((await a).message,/会话已变更/);assert.match((await b).message,/会话已变更/);assert.equal(h.session.token,'')
})
test('missing rotated token fields fail closed and never report a revoked session',async()=>{
for(const field of ['accessToken','refreshToken']) {
const h=harness(),exit=h.helper.logoutCurrentSession(),rejected=assert.rejects(exit);h.calls[0].success(denied());await tick()
const bad=pair('bad');delete bad[field];h.calls[1].success(ok(bad));await rejected;assert.equal(h.calls.length,2);assert.equal(h.session.token,'')
}
})