diff --git a/src/api/http.ts b/src/api/http.ts index 50b2e5b..9b9b4be 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -14,6 +14,7 @@ const transport = axios.create({ interface RetryableRequest extends InternalAxiosRequestConfig { _retriedAfterRefresh?: boolean _sessionIdentity?: string | null + _logoutGeneration?: number } function unwrap(envelope: ApiEnvelope) { @@ -52,6 +53,8 @@ function mapError(error: unknown) { return new ApiError(error.message || '请求失败', 'HTTP_ERROR', error.response?.status) } +let refreshExchange: { identity: string; refreshToken: string; promise: Promise } | null = null +let logoutGeneration = 0 let refreshPromise: Promise | null = null let refreshIdentity: string | null = null let redirectingToLogin = false @@ -95,8 +98,10 @@ export async function refreshAuthenticatedSession() { }, refresh: async (currentRefreshToken) => { requireCurrentIdentity(observedIdentity) - const response = await transport.post>('/auth/refresh', { refreshToken: currentRefreshToken }) - const tokens = unwrap(response.data) + const exchange = { identity: observedIdentity!, refreshToken: currentRefreshToken, promise: transport.post>('/auth/refresh', { refreshToken: currentRefreshToken }).then(response => unwrap(response.data)) } + refreshExchange = exchange + let tokens: TokenPair + try { tokens = await exchange.promise } finally { if (refreshExchange === exchange) refreshExchange = null } requireCurrentIdentity(observedIdentity) requireCurrentIdentity(observedIdentity, tokens.accessToken) if (!getRefreshToken()) throw new ApiError('登录已在其他标签页退出', 'A0401', 401) @@ -134,29 +139,42 @@ transport.interceptors.request.use((config) => { const request = config as RetryableRequest if (request._retriedAfterRefresh && !isPublicAuthRequest(config.url)) requireCurrentIdentity(request._sessionIdentity, token) if (token && !isPublicAuthRequest(config.url)) { - if (!request._retriedAfterRefresh) request._sessionIdentity = accessTokenIdentity(token) + if (!request._retriedAfterRefresh) { + request._sessionIdentity = accessTokenIdentity(token) + request._logoutGeneration = logoutGeneration + } config.headers.Authorization = `Bearer ${token}` } return config }) transport.interceptors.response.use( - (response) => response, + (response) => { + const request = response.config as RetryableRequest + if (request._sessionIdentity) { + if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') + requireCurrentIdentity(request._sessionIdentity) + } + return response + }, async (error: AxiosError) => { const request = error.config as RetryableRequest | undefined if (error.response?.status !== 401 || !request || request._retriedAfterRefresh || isPublicAuthRequest(request.url)) { throw mapError(error) } + if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') requireCurrentIdentity(request._sessionIdentity) request._retriedAfterRefresh = true const refreshTokenBeforeRetry = getRefreshToken() try { const accessToken = await refreshAuthenticatedSession() + if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') requireCurrentIdentity(request._sessionIdentity) request.headers.Authorization = `Bearer ${accessToken}` return await transport.request(request) } catch (refreshError) { + if (request._logoutGeneration !== logoutGeneration) throw new ApiError('登录身份已变化,请重新操作', 'SESSION_CHANGED') requireCurrentIdentity(request._sessionIdentity) const mappedError = mapError(refreshError) if (mappedError.status === 401 || mappedError.code === 'A0401') { @@ -170,6 +188,33 @@ transport.interceptors.response.use( }, ) +// Captured revocation never persists credentials or restores local login state. +export async function revokeCapturedSession(accessToken: string, refreshToken: string) { + logoutGeneration++ + const identity = accessTokenIdentity(accessToken) + if (!identity || !refreshToken) throw new ApiError('服务器会话注销未确认', 'SESSION_CHANGED') + const pending = refreshExchange?.identity === identity && refreshExchange.refreshToken === refreshToken ? refreshExchange.promise : null + const revoke = (pair: Pick) => apiRequest({ + method: 'POST', url: '/auth/logout', data: { refreshToken: pair.refreshToken }, + headers: { Authorization: `Bearer ${pair.accessToken}` }, + }) + const validate = (pair: TokenPair) => { + if (accessTokenIdentity(pair.accessToken) !== identity || !pair.refreshToken) throw new ApiError('刷新身份不一致,服务器会话注销未确认', 'SESSION_CHANGED') + return pair + } + // An already sent refresh can rotate the pair. Consume its actual result + // without issuing a competing refresh or restoring it to local storage. + if (pending) { + await revoke(validate(await pending)) + return + } + try { await revoke({ accessToken, refreshToken }) } catch (error) { + if (!(error instanceof ApiError) || error.status !== 401) throw error + const pair = await apiRequest({ method: 'POST', url: '/auth/refresh', data: { refreshToken } }) + await revoke(validate(pair)) + } +} + export async function apiRequest(config: AxiosRequestConfig) { try { const response = await transport.request>(config) diff --git a/src/api/session.test.mjs b/src/api/session.test.mjs index c2916d2..b305710 100644 --- a/src/api/session.test.mjs +++ b/src/api/session.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import test from 'node:test' import { + accessTokenIdentity, ACCESS_TOKEN_KEY, REFRESH_TOKEN_KEY, REMEMBERED_LOGIN_ID_KEY, @@ -68,3 +69,22 @@ test('normal token rotation never removes either shared token between writes', ( assert.ok(!removed.includes(ACCESS_TOKEN_KEY)) assert.ok(!removed.includes(REFRESH_TOKEN_KEY)) }) + +function rawIdentity(overrides = '') { + return `test.${Buffer.from(`{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"${overrides}}`).toString('base64url')}.test` +} +test('identity preserves low digits of numeric 19 digit user customer and membership',()=>{ + const original=rawIdentity();const json=Buffer.from(original.split('.')[1],'base64url').toString(); + for(const field of ['user_id','tenant_id','membership_id']){ + const changed=json.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`) + assert.notEqual(accessTokenIdentity(original),accessTokenIdentity(`test.${Buffer.from(changed).toString('base64url')}.test`)) + } +}) +test('identity string and numeric integers are equivalent without changing other strings',()=>{ + const a=rawIdentity();const raw=Buffer.from(a.split('.')[1],'base64url').toString(); + const b=`test.${Buffer.from(raw.replaceAll(':2107003446494334978',':"2107003446494334978"')).toString('base64url')}.test` + assert.equal(accessTokenIdentity(a),accessTokenIdentity(b)) + const decorated=rawIdentity(',"username":'+JSON.stringify('number 2107003446494334979 and "tenant_id":2107003446494334979')+',"note":"1234567890123456789"') + assert.equal(accessTokenIdentity(a),accessTokenIdentity(decorated)) + assert.ok(accessTokenIdentity(a).includes('2107003446494334978')) +}) diff --git a/src/api/session.ts b/src/api/session.ts index cda260b..fb639ab 100644 --- a/src/api/session.ts +++ b/src/api/session.ts @@ -27,7 +27,16 @@ export function accessTokenIdentity(token: string): string | null { if (!encoded) return null const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/') const bytes = Uint8Array.from(atob(normalized.padEnd(Math.ceil(normalized.length / 4) * 4, '=')), character => character.charCodeAt(0)) - const value = JSON.parse(new TextDecoder().decode(bytes)) + const json = new TextDecoder().decode(bytes) + // Match whole JSON strings first so digits inside arbitrary string values + // are never rewritten. Preserve integer identity claims before JSON.parse + // can round them; number/string representations then share one identity. + const exactIdentityJson = json.replace(/"(?:\\.|[^"\\])*"|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/g, (part, offset: number) => { + if (!/^-?\d+$/.test(part)) return part + const key = json.slice(0, offset).match(/"(user_id|tenant_id|membership_id|operator_tenant_id)"\s*:\s*$/) + return key ? JSON.stringify(part) : part + }) + const value = JSON.parse(exactIdentityJson) if (!value?.user_id || !value.tenant_id || !value.membership_id || value.app_code !== 'MERCHANT') return null return JSON.stringify([value.user_id, value.tenant_id, value.membership_id, value.operator_tenant_id, value.app_code, value.client_type].map(item => item == null ? null : String(item))) } catch { diff --git a/src/api/unifiedHttp.test.mjs b/src/api/unifiedHttp.test.mjs index ed66ff4..b051b7f 100644 --- a/src/api/unifiedHttp.test.mjs +++ b/src/api/unifiedHttp.test.mjs @@ -100,22 +100,6 @@ test('late refresh response cannot overwrite a session changed in another tab', function deferred() { let resolve; const promise = new Promise(yes => { resolve = yes }); return { promise, resolve } } -test('logout after local clear sends only the original refresh token and never refreshes or clears a later login', async () => { - const sent = deferred(), release = deferred(), calls = [] - const http = harness(async config => { - calls.push(config); sent.resolve(); await release.promise; throw unauthorized(config) - }) - http.setSession('', '') - const api = compile('../modules/auth/api/auth.ts', { '@/api/http': http }) - const pending = api.logoutMerchant('old-refresh') - const rejected = assert.rejects(pending, error => error.code === 'A0401') - await sent.promise; http.setSession(otherAccess, 'refresh-b'); release.resolve(); await rejected - assert.equal(calls.length, 1) - assert.equal(calls[0].url, '/auth/logout') - assert.equal(calls[0].headers.Authorization, undefined) - assert.deepEqual(JSON.parse(calls[0].data), { refreshToken: 'old-refresh' }) - assert.deepEqual(http.session(), { token: otherAccess, refreshToken: 'refresh-b' }) -}) function unauthorized(config) { return new axios.AxiosError('Expired', 'ERR_BAD_REQUEST', config, null, { status: 401, data: { code: 'A0401', message: 'Expired' } }) } @@ -173,3 +157,66 @@ test('refresh lock wait reuses same-identity rotation but rejects changed-identi assert.deepEqual(http.session(), { token: next, refreshToken: 'rotated-refresh' }) } }) + +function authApi(http) { return compile('../modules/auth/api/auth.ts', { '@/api/http': http }) } +function ok(config, data = null) { return { config, status: 200, headers: {}, data: { code: '200', message: 'OK', data } } } +test('logout after immediate local clear revokes captured Bearer and Refresh', async () => { + const calls=[]; const http=harness(async c=>{calls.push(c);return ok(c)}) + http.setSession('', '');await authApi(http).logoutMerchant(oldAccess,'old-refresh') + assert.equal(calls.length,1);assert.equal(calls[0].headers.Authorization,`Bearer ${oldAccess}`) + assert.deepEqual(JSON.parse(calls[0].data),{refreshToken:'old-refresh'});assert.deepEqual(http.session(),{token:'',refreshToken:''}) +}) +test('only explicit logout401 refreshes once and revokes same-identity replacement without storing it', async () => { + const calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'});if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)}) + http.setSession('','');await authApi(http).logoutMerchant(oldAccess,'old-refresh') + assert.deepEqual(calls.map(x=>x.url),['/auth/logout','/auth/refresh','/auth/logout']);assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[2].data).refreshToken,'new-refresh');assert.deepEqual(http.session(),{token:'',refreshToken:''}) +}) +test('unknown logout failure never refreshes; changed identity cannot be revoked as replacement', async () => { + for(const mode of ['network','wrongIdentity','second401']){const calls=[];const http=harness(async c=>{calls.push(c);if(mode==='network')throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c);if(c.url==='/auth/refresh')return ok(c,{accessToken:mode==='wrongIdentity'?otherAccess:newAccess,refreshToken:'replacement'});throw unauthorized(c)}) + http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(oldAccess,'old-refresh'));assert.equal(calls.length,mode==='network'?1:mode==='wrongIdentity'?2:3);assert.deepEqual(http.session(),{token:'',refreshToken:''})} +}) +test('logout consumes already inflight refresh once while old business caller cannot revive session',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}return ok(c)}) + const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing,e=>e.code==='SESSION_CHANGED');await started.promise + const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');release.resolve();await leaving;await rejected + assert.deepEqual(calls.map(x=>x.url),['/auth/refresh','/auth/logout']);assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:'',refreshToken:''}) +}) +test('late successful business response after logout rejects even after same-identity new login',async()=>{ + const started=deferred(),release=deferred();const http=harness(async c=>{if(c.url==='/merchant/v1/orders'){started.resolve();await release.promise}return ok(c,'old-data')}) + const pending=http.apiRequest({url:'/merchant/v1/orders'});const rejected=assert.rejects(pending,e=>e.code==='SESSION_CHANGED');await started.promise + await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected;assert.equal(http.session().refreshToken,'new-login-refresh') +}) + +test('inflight refresh unknown outcome stops logout without competing retry and preserves later login',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);started.resolve();await release.promise;throw new axios.AxiosError('offline',axios.AxiosError.ERR_NETWORK,c)}) + const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise + const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');const failure=assert.rejects(leaving,e=>e.code==='NETWORK_ERROR');http.setSession(otherAccess,'other-refresh');release.resolve();await failure;await rejected + assert.equal(calls.length,1);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'}) +}) +test('logout401 refresh and replacement revocation cannot overwrite later login',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'new-refresh'})}if(c.headers.Authorization===`Bearer ${oldAccess}`)throw unauthorized(c);return ok(c)}) + const leaving=authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession('','');await started.promise;http.setSession(otherAccess,'other-refresh');release.resolve();await leaving + assert.equal(calls[2].headers.Authorization,`Bearer ${newAccess}`);assert.deepEqual(http.session(),{token:otherAccess,refreshToken:'other-refresh'}) +}) +test('late old business401 after logout does not refresh or replay under same-identity new login',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/merchant/v1/orders/first-leg/one/submit'){started.resolve();await release.promise;throw unauthorized(c)}return ok(c)}) + const request=http.apiRequest({method:'POST',url:'/merchant/v1/orders/first-leg/one/submit'});const rejected=assert.rejects(request,e=>e.code==='SESSION_CHANGED');await started.promise;await authApi(http).logoutMerchant(oldAccess,'old-refresh');http.setSession(newAccess,'new-login-refresh');release.resolve();await rejected + assert.deepEqual(calls.map(x=>x.url),['/merchant/v1/orders/first-leg/one/submit','/auth/logout']);assert.equal(http.session().refreshToken,'new-login-refresh') +}) + +test('same identity new pair logout does not adopt another session inflight refresh',async()=>{ + const started=deferred(),release=deferred(),calls=[];const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh'){started.resolve();await release.promise;return ok(c,{accessToken:newAccess,refreshToken:'old-session-rotated'})}return ok(c)}) + const refreshing=http.refreshAuthenticatedSession();const rejected=assert.rejects(refreshing);await started.promise;http.setSession(newAccess,'new-login-refresh') + const leaving=authApi(http).logoutMerchant(newAccess,'new-login-refresh');http.setSession('','');await leaving + assert.equal(calls[1].headers.Authorization,`Bearer ${newAccess}`);assert.equal(JSON.parse(calls[1].data).refreshToken,'new-login-refresh');release.resolve();await rejected;assert.deepEqual(http.session(),{token:'',refreshToken:''}) +}) + +test('logout rejects refreshed 19 digit identity differing only in low digit',async()=>{ + const raw='{"user_id":2107003446494334978,"tenant_id":2107003446494334978,"membership_id":2107003446494334978,"app_code":"MERCHANT"}' + const original=`test.${Buffer.from(raw).toString('base64url')}.test` + for(const field of ['user_id','tenant_id','membership_id']){ + const changed=raw.replace(`"${field}":2107003446494334978`,`"${field}":2107003446494334979`),calls=[] + const http=harness(async c=>{calls.push(c);if(c.url==='/auth/refresh')return ok(c,{accessToken:`test.${Buffer.from(changed).toString('base64url')}.test`,refreshToken:'replacement'});throw unauthorized(c)}) + http.setSession('','');await assert.rejects(authApi(http).logoutMerchant(original,'original-refresh'),e=>e.code==='SESSION_CHANGED');assert.equal(calls.length,2) + } +}) diff --git a/src/modules/auth/api/auth.ts b/src/modules/auth/api/auth.ts index f462f01..7ed41de 100644 --- a/src/modules/auth/api/auth.ts +++ b/src/modules/auth/api/auth.ts @@ -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({ method: 'POST', url: '/auth/register', data: payload }) } -export function logoutMerchant(refreshToken: string) { - return apiRequest({ 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) { diff --git a/src/modules/auth/store/auth.ts b/src/modules/auth/store/auth.ts index 1997e8d..30f11c8 100644 --- a/src/modules/auth/store/auth.ts +++ b/src/modules/auth/store/auth.ts @@ -61,6 +61,7 @@ export const useAuthStore = defineStore('client-auth', () => { const permissionCodes = ref([]) const sessionRestored = ref(false) + let logoutRequest: { accessToken: string; refreshToken: string; promise: Promise } | undefined let generation = 0 let contextRequest: { generation: number; promise: Promise } | undefined let restoreTimer: ReturnType | 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) { diff --git a/src/modules/auth/store/authSession.test.mjs b/src/modules/auth/store/authSession.test.mjs index 085a95f..0fcabb0 100644 --- a/src/modules/auth/store/authSession.test.mjs +++ b/src/modules/auth/store/authSession.test.mjs @@ -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]) +}) diff --git a/src/modules/auth/views/AuthViews.test.mjs b/src/modules/auth/views/AuthViews.test.mjs index 0b2fc45..dd487a6 100644 --- a/src/modules/auth/views/AuthViews.test.mjs +++ b/src/modules/auth/views/AuthViews.test.mjs @@ -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\)/)