fix(auth): revoke overseas PDA session on user logout
This commit is contained in:
+19
-3
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
@@ -13,7 +13,7 @@
|
||||
</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">退出后会清空海外 PDA 的本地登录会话;未提交作业数据不得跨账号保留。</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>
|
||||
<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>
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -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(/<script>([\s\S]*?)<\/script>/)[1].replace(/^\s*import[^\n]*$/gm,'').replace('export default','return');return new Function('PortalHeader','PopupHeader','signOut','logoutCurrentSession','getSessionRevision','session',source)({}, {},signOut,logoutHelper.logoutCurrentSession,getSessionRevision,session)}
|
||||
test('actual profile confirmation clears drafts and revokes captured session before reporting success',async()=>{
|
||||
const state=prepare();uni.request=o=>{state.calls.push(o);assert.equal(session.token,'');assert.equal(receiving.current,null);o.success(ok(null))}
|
||||
const context={user:session.currentUser,closeLogout(){this.closed=true}}
|
||||
await page().methods.logout.call(context)
|
||||
assert.equal(state.calls.length,1)
|
||||
assert.match(state.calls[0].url,/\/auth\/logout$/)
|
||||
assert.deepEqual(state.calls[0].data,{refreshToken:state.original.refreshToken})
|
||||
assert.equal(state.calls[0].header.Authorization,`Bearer ${state.original.accessToken}`)
|
||||
assert.equal(state.values.has('overseas-pda-auth-session'),false)
|
||||
})
|
||||
const denied = (statusCode=401,code='A0401') => ({statusCode,data:{code,message:'denied',requestId:'logout-request',traceId:'logout-trace'},header:{}})
|
||||
test('explicit expired-access 401 refreshes captured credentials once and revokes without restoring local tokens',async()=>{
|
||||
const state=prepare(),replacement=tokens('rotated')
|
||||
session.token=tokens('expired',{exp:1}).accessToken
|
||||
uni.request=o=>{state.calls.push(o);assert.equal(session.token,'');if(state.calls.length===1)o.success(denied());else if(o.url.endsWith('/auth/refresh')){assert.deepEqual(o.data,{refreshToken:state.original.refreshToken});assert.equal(o.header.Authorization,undefined);o.success(ok(replacement))}else{o.success(ok(null));assert.equal(o.header.Authorization,`Bearer ${replacement.accessToken}`);assert.deepEqual(o.data,{refreshToken:replacement.refreshToken})}}
|
||||
const result=await logoutHelper.logoutCurrentSession()
|
||||
assert.equal(result.revoked,true);assert.equal(state.calls.length,3);assert.equal(session.currentUser,null);assert.equal(state.values.size,0)
|
||||
})
|
||||
test('unknown network and 5xx results never trigger refresh or blind logout replay',async()=>{
|
||||
for(const failure of ['network','server','business']){
|
||||
const state=prepare();uni.request=o=>{state.calls.push(o);if(failure==='network')o.fail({errMsg:'connection lost'});else o.success(denied(failure==='server'?503:200,failure==='server'?'200':'A0401'))}
|
||||
await assert.rejects(logoutHelper.logoutCurrentSession(),error=>failure==='network'?error.network===true:error.statusCode===(failure==='server'?503:200))
|
||||
assert.equal(state.calls.length,1);assert.equal(session.token,'');assert.equal(receiving.current,null)
|
||||
}
|
||||
})
|
||||
test('refresh failure preserves diagnostic evidence and does not send a second revoke',async()=>{
|
||||
const state=prepare();uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():denied(503,'S0503'))}
|
||||
await assert.rejects(logoutHelper.logoutCurrentSession(),error=>error.code==='S0503'&&error.requestId==='logout-request'&&error.traceId==='logout-trace')
|
||||
assert.equal(state.calls.length,2);assert.equal(getPendingRefresh(state.original.refreshToken),null);assert.equal(session.token,'')
|
||||
})
|
||||
test('different user membership or warehouse from logout refresh cannot revoke another session',async()=>{
|
||||
for(const identity of [{user_id:43},{membership_id:85},{overseas_warehouse_id:901}]){
|
||||
const state=prepare();uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():ok(tokens('wrong',identity)))}
|
||||
await assert.rejects(logoutHelper.logoutCurrentSession(),/不同作业主体/);assert.equal(state.calls.length,2);assert.equal(state.values.size,0)
|
||||
}
|
||||
})
|
||||
test('missing refreshed credentials fail closed without local restoration',async()=>{
|
||||
for(const field of ['refreshToken','accessToken']){
|
||||
const state=prepare(),replacement=tokens('invalid');delete replacement[field]
|
||||
uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():ok(replacement))}
|
||||
await assert.rejects(logoutHelper.logoutCurrentSession());assert.equal(state.calls.length,2);assert.equal(session.token,'');assert.equal(state.values.size,0)
|
||||
}
|
||||
})
|
||||
test('revocation-only refresh does not load permissions or recreate a login when permission snapshots are absent',async()=>{
|
||||
const state=prepare(),replacement=tokens('revocation-only');delete replacement.roles;delete replacement.authorities
|
||||
uni.request=o=>{state.calls.push(o);o.success(state.calls.length===1?denied():o.url.endsWith('/auth/refresh')?ok(replacement):ok(null))}
|
||||
assert.equal((await logoutHelper.logoutCurrentSession()).revoked,true)
|
||||
assert.equal(state.calls.length,3);assert.equal(session.token,'');assert.deepEqual(session.authorization,{roles:[],authorities:[]});assert.equal(state.values.size,0)
|
||||
})
|
||||
test('second revoke 401 is terminal and cannot start another refresh loop',async()=>{
|
||||
const state=prepare();uni.request=o=>{state.calls.push(o);o.success(o.url.endsWith('/auth/refresh')?ok(tokens('rotated')):denied())}
|
||||
await assert.rejects(logoutHelper.logoutCurrentSession(),error=>error.statusCode===401)
|
||||
assert.equal(state.calls.length,3);assert.equal(state.calls.filter(o=>o.url.endsWith('/auth/refresh')).length,1)
|
||||
})
|
||||
test('profile duplicate confirmation shares its pending operation',async()=>{
|
||||
const state=prepare();uni.request=o=>state.calls.push(o)
|
||||
const context={user:session.currentUser,closeLogout(){this.closed=true}},methods=page().methods
|
||||
const first=methods.logout.call(context),second=methods.logout.call(context)
|
||||
assert.equal(first,second);assert.equal(state.calls.length,1);assert.equal(state.launches.length,1)
|
||||
state.calls[0].success(ok(null));await first
|
||||
})
|
||||
test('old logout result cannot clear redirect or warn over a subsequent new login',async()=>{
|
||||
for(const failed of [false,true]){
|
||||
const state=prepare();uni.request=o=>state.calls.push(o)
|
||||
const context={user:session.currentUser,closeLogout(){}},pending=page().methods.logout.call(context)
|
||||
const replacement=tokens('new-login',{user_id:43,membership_id:85,overseas_warehouse_id:901})
|
||||
signInWithTokens(replacement);receiving.current={containerNo:'NEW-USER'}
|
||||
if(failed)state.calls[0].fail({errMsg:'old connection lost'});else state.calls[0].success(ok(null))
|
||||
await pending
|
||||
assert.equal(session.token,replacement.accessToken);assert.equal(receiving.current.containerNo,'NEW-USER');assert.equal(state.launches.length,1);assert.equal(state.toasts.length,0)
|
||||
}
|
||||
})
|
||||
test('logout joins actual in-flight business refresh and revokes its rotation while preserving a new login',async()=>{
|
||||
const state=prepare();let refreshRequest
|
||||
uni.request=o=>{state.calls.push(o);if(o.url.endsWith('/auth/refresh'))refreshRequest=o;else if(o.url.endsWith('/auth/logout'))o.success(ok(null));else o.success(denied())}
|
||||
const business=overseasRequest('GET','/pda/v1/last-leg/receiving-containers/current').catch(error=>error)
|
||||
await new Promise(resolve=>setImmediate(resolve));assert.ok(refreshRequest)
|
||||
const logout=logoutHelper.logoutCurrentSession()
|
||||
const newer=tokens('new-login',{user_id:43,membership_id:85,overseas_warehouse_id:901});signInWithTokens(newer)
|
||||
const rotated=tokens('rotated-old');refreshRequest.success(ok(rotated))
|
||||
assert.equal((await logout).revoked,true);assert.match((await business).message,/会话已变更/)
|
||||
assert.equal(state.calls.filter(o=>o.url.endsWith('/auth/refresh')).length,1)
|
||||
const revoked=state.calls.find(o=>o.url.endsWith('/auth/logout'));assert.deepEqual(revoked.data,{refreshToken:rotated.refreshToken});assert.equal(revoked.header.Authorization,`Bearer ${rotated.accessToken}`)
|
||||
assert.equal(session.token,newer.accessToken);assert.deepEqual(state.launches,[])
|
||||
})
|
||||
test('unknown in-flight refresh never rotates captured credentials again or reports remote success',async()=>{
|
||||
const state=prepare();uni.request=o=>state.calls.push(o)
|
||||
const inFlight=refresh(state.original.refreshToken).catch(error=>error)
|
||||
const logout=logoutHelper.logoutCurrentSession();const rejected=assert.rejects(logout,error=>error.network===true)
|
||||
state.calls[0].fail({errMsg:'refresh result unknown'});await rejected;await inFlight
|
||||
assert.equal(state.calls.length,1);assert.equal(session.token,'');assert.equal(getPendingRefresh(state.original.refreshToken),null)
|
||||
})
|
||||
test('ordinary local signOut and cancelled profile dialog never contact Auth',()=>{
|
||||
const state=prepare();uni.request=o=>state.calls.push(o)
|
||||
page().methods.closeLogout.call({logoutOpen:true,$refs:{logoutPopup:{close(){}}}})
|
||||
assert.equal(session.token,state.original.accessToken);signOut();assert.equal(state.calls.length,0);assert.equal(session.token,'')
|
||||
})
|
||||
test('no captured credentials require no remote request',async()=>{
|
||||
const state=prepare();signOut();uni.request=o=>state.calls.push(o)
|
||||
assert.equal((await logoutHelper.logoutCurrentSession()).revoked,false);assert.equal(state.calls.length,0)
|
||||
})
|
||||
Reference in New Issue
Block a user