fix(auth): revoke captured client sessions safely on logout

This commit is contained in:
2026-10-05 17:13:46 +08:00
parent e139c5c87b
commit 3703d0a677
8 changed files with 175 additions and 28 deletions
+3 -3
View File
@@ -1,4 +1,4 @@
import { apiRequest } from '@/api/http'
import { apiRequest, revokeCapturedSession } from '@/api/http'
import type { TokenPair } from '@/api/session'
export interface RegisterMerchantRequest {
@@ -35,8 +35,8 @@ export function registerMerchant(payload: RegisterMerchantRequest) {
return apiRequest<RegisterMerchantResult>({ method: 'POST', url: '/auth/register', data: payload })
}
export function logoutMerchant(refreshToken: string) {
return apiRequest<void>({ method: 'POST', url: '/auth/logout', data: { refreshToken } })
export function logoutMerchant(accessToken: string, refreshToken: string) {
return revokeCapturedSession(accessToken, refreshToken)
}
export function changeCurrentPassword(oldPassword: string, newPassword: string) {
+13 -2
View File
@@ -61,6 +61,7 @@ export const useAuthStore = defineStore('client-auth', () => {
const permissionCodes = ref<string[]>([])
const sessionRestored = ref(false)
let logoutRequest: { accessToken: string; refreshToken: string; promise: Promise<void> } | undefined
let generation = 0
let contextRequest: { generation: number; promise: Promise<void> } | undefined
let restoreTimer: ReturnType<typeof setTimeout> | undefined
@@ -199,10 +200,20 @@ export const useAuthStore = defineStore('client-auth', () => {
await restoreSession()
}
async function logout() {
function logout() {
const accessToken = getAccessToken()
const refreshToken = getRefreshToken()
if (logoutRequest && ((!accessToken && !refreshToken)
|| (logoutRequest.accessToken === accessToken && logoutRequest.refreshToken === refreshToken))) return logoutRequest.promise
const pending = accessToken || refreshToken
? logoutMerchant(accessToken, refreshToken)
: Promise.resolve()
clearSession()
if (refreshToken) await logoutMerchant(refreshToken)
const request = { accessToken, refreshToken, promise: pending.finally(() => {
if (logoutRequest === request) logoutRequest = undefined
}) }
logoutRequest = request
return request.promise
}
function hasPermission(permission?: string) {
+15 -1
View File
@@ -35,7 +35,7 @@ function harness(t, expired = false) {
class ApiError extends Error { constructor(status) { super('failed'); this.status = status } }
const module = compile('./auth.ts', {
vue, pinia, '@/api/session': session,
'@/modules/auth/api/auth': { logoutMerchant: async refresh => behavior.logout?.(refresh) },
'@/modules/auth/api/auth': { logoutMerchant: async (access, refresh) => behavior.logout?.(refresh, access) },
'@/modules/auth/utils/jwt': compile('../utils/jwt.ts'),
'@/api/http': { ApiError, refreshAuthenticatedSession: async () => {
session.persistTokenPair({ accessToken: token(1, 1, 1, 'refreshed'), refreshToken: 'refresh-new' })
@@ -152,3 +152,17 @@ test('store logout clears immediately and late revocation failure preserves a la
assert.equal(h.auth.isAuthenticated, true)
assert.equal(h.session.getRefreshToken(), 'refresh-b')
})
test('duplicate logout joins captured pair revocation and does not send again',async t=>{
const h=harness(t);const remote=deferred();let count=0,sent
h.behavior.logout=(refresh,access)=>{count++;sent={refresh,access};return remote.promise}
const a=h.auth.logout(),b=h.auth.logout();assert.equal(h.auth.isAuthenticated,false);assert.equal(sent.access,token());assert.equal(sent.refresh,'refresh-a');assert.equal(count,1);remote.resolve();await Promise.all([a,b])
})
test('B logout during A pending revokes B; A finally cannot release B deduplication',async t=>{
const h=harness(t);const a=deferred(),b=deferred(),calls=[]
h.behavior.logout=(refresh,access)=>{calls.push({refresh,access});return calls.length===1?a.promise:b.promise}
const leavingA=h.auth.logout();h.rotate(token(2,2,2));await flush();assert.equal(h.auth.isAuthenticated,true)
const leavingB=h.auth.logout();assert.equal(h.auth.isAuthenticated,false);assert.equal(h.session.getAccessToken(),'');assert.equal(calls.length,2);assert.equal(calls[1].access,token(2,2,2))
a.resolve();await leavingA;const duplicateB=h.auth.logout();assert.equal(calls.length,2);let duplicateFinished=false;void duplicateB.then(()=>{duplicateFinished=true});await flush();assert.equal(duplicateFinished,false);b.resolve();await Promise.all([leavingB,duplicateB])
})
+2 -1
View File
@@ -102,7 +102,8 @@ test('opens complete service and privacy documents from registration', () => {
test('uses real auth endpoints and single-flight refresh handling', () => {
assert.match(authApiSource, /\/auth\/login/)
assert.match(authApiSource, /\/auth\/register/)
assert.match(authApiSource, /\/auth\/logout/)
assert.match(authApiSource, /revokeCapturedSession\(accessToken, refreshToken\)/)
assert.match(httpSource, /\/auth\/logout/)
assert.match(httpSource, /refreshPromise/)
assert.match(httpSource, /\/auth\/refresh/)
assert.match(httpSource, /persistTokenPair\(tokens\)/)