From 84341a5e27c2f4427feddb08aa4c18c0bf07477d Mon Sep 17 00:00:00 2001 From: Fedorov Vladimir Date: Thu, 8 Oct 2026 02:07:59 +0700 Subject: [PATCH] fix --- dist/index.html | 2 +- src/api/__tests__/authRetry.test.ts | 61 ++++++ src/api/authRetry.ts | 14 ++ src/api/auth_client.ts | 42 ++-- src/api/binary_client.ts | 43 ++--- src/components/HeaderMenu.vue | 8 +- src/stores/__tests__/auth.test.ts | 289 ++++++++++++++++++++++++++++ src/stores/auth.ts | 146 +++++++++----- 8 files changed, 494 insertions(+), 111 deletions(-) create mode 100644 src/api/__tests__/authRetry.test.ts create mode 100644 src/api/authRetry.ts create mode 100644 src/stores/__tests__/auth.test.ts diff --git a/dist/index.html b/dist/index.html index ee26b9a..01dc784 100644 --- a/dist/index.html +++ b/dist/index.html @@ -5,7 +5,7 @@ Вечерний детектив - + diff --git a/src/api/__tests__/authRetry.test.ts b/src/api/__tests__/authRetry.test.ts new file mode 100644 index 0000000..d26d38e --- /dev/null +++ b/src/api/__tests__/authRetry.test.ts @@ -0,0 +1,61 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' + +import { retryWithAuth } from '../authRetry.ts' + +for (const scenario of [ + { name: '401 retries after refresh', error: 'unauthorized', refresh: true, attempts: 2 }, + { name: 'failed refresh does not retry', error: 'unauthorized', refresh: false, attempts: 1 }, + { name: '403 does not refresh', error: 'permission denied', refresh: true, attempts: 1 }, + { name: '500 does not refresh', error: 'server error', refresh: true, attempts: 1 }, + { + name: 'network failure does not refresh', + error: 'Failed to fetch', + refresh: true, + attempts: 1, + }, +]) { + test(scenario.name, async () => { + let attempts = 0 + let refreshes = 0 + const original = new Error(scenario.error) + const result = retryWithAuth( + async () => { + attempts++ + if (attempts === 1) throw original + return 'ok' + }, + async (failedToken) => { + assert.equal(failedToken, 'expired') + refreshes++ + return scenario.refresh + }, + () => 'expired', + ) + if (scenario.attempts === 2) assert.equal(await result, 'ok') + else await assert.rejects(result, (err) => err === original) + assert.equal(attempts, scenario.attempts) + assert.equal(refreshes, scenario.error === 'unauthorized' ? 1 : 0) + }) +} + +test('second 401 is propagated without another refresh', async () => { + let attempts = 0 + let refreshes = 0 + await assert.rejects( + retryWithAuth( + async () => { + attempts++ + throw new Error('unauthorized') + }, + async () => { + refreshes++ + return true + }, + () => 'expired', + ), + /unauthorized/, + ) + assert.equal(attempts, 2) + assert.equal(refreshes, 1) +}) diff --git a/src/api/authRetry.ts b/src/api/authRetry.ts new file mode 100644 index 0000000..97bcd5e --- /dev/null +++ b/src/api/authRetry.ts @@ -0,0 +1,14 @@ +export async function retryWithAuth( + request: () => Promise, + refreshTokens: (failedToken?: string | null) => Promise, + getToken: () => string | null, +): Promise { + const token = getToken() + try { + return await request() + } catch (err) { + if (!(err instanceof Error) || err.message !== 'unauthorized') throw err + if (!(await refreshTokens(token))) throw err + return request() + } +} diff --git a/src/api/auth_client.ts b/src/api/auth_client.ts index 5dba122..8dbfc63 100644 --- a/src/api/auth_client.ts +++ b/src/api/auth_client.ts @@ -1,45 +1,27 @@ import { useAuthStore } from '@/stores/auth' +import { retryWithAuth } from './authRetry' import { createEveningDetectiveServerClient, type EveningDetectiveServer, } from './generated/crabs/evening_detective_server' -import { createHttpHandler } from './generated/crabs/evening_detective_server/client' +import { + createHttpHandler, + type ExtraHeadersProvider, +} from './generated/crabs/evening_detective_server/client' -export function getAuthClient() { +export function getAuthClient(getExtraHeaders?: ExtraHeadersProvider) { const authStore = useAuthStore() - return buildAuthClient(authStore.refreshTokenAction, authStore.getToken) + return buildAuthClient(authStore.refreshTokenAction, authStore.getToken, getExtraHeaders) } function buildAuthClient( - refreshTokens: () => Promise, + refreshTokens: (failedToken?: string | null) => Promise, getToken: () => string | null, + getExtraHeaders?: ExtraHeadersProvider, ): EveningDetectiveServer { - return createEveningDetectiveServerClient( - createAuthHttpHandler(refreshTokens, createHttpHandler(getToken)), + const handler = createHttpHandler(getToken, getExtraHeaders) + return createEveningDetectiveServerClient((request) => + retryWithAuth(() => handler(request), refreshTokens, getToken), ) } - -function createAuthHttpHandler( - refreshTokens: () => Promise, - handler: (request: { path: string; method: string; body: string | null }) => Promise, -): (request: { path: string; method: string; body: string | null }) => Promise { - return async (request: { path: string; method: string; body: string | null }) => { - let response: unknown - try { - response = await handler(request) - } catch { - await refreshTokens() - response = await handler(request) - } - // Gateway возвращает gRPC-ошибки как JSON даже при HTTP 403/400/500. - if (response && typeof response === 'object' && 'code' in response && response.code !== 0) { - throw new Error( - 'message' in response && typeof response.message === 'string' - ? response.message - : 'Ошибка запроса', - ) - } - return response - } -} diff --git a/src/api/binary_client.ts b/src/api/binary_client.ts index eea09c8..ecf5e5b 100644 --- a/src/api/binary_client.ts +++ b/src/api/binary_client.ts @@ -1,5 +1,6 @@ import { useAuthStore } from '@/stores/auth' +import { retryWithAuth } from './authRetry' import { getURL } from './generated/crabs/evening_detective_server/client' export type DownloadFileResult = { @@ -18,33 +19,23 @@ export function getBinaryClient() { return buildBinaryClient(authStore.refreshTokenAction, authStore.getToken) } -function buildBinaryClient(refreshTokens: () => Promise, getToken: () => string | null) { +function buildBinaryClient( + refreshTokens: (failedToken?: string | null) => Promise, + getToken: () => string | null, +) { async function request(path: string, init: RequestInit): Promise { - const headers: Record = {} - const token = getToken() - if (token) { - headers['Authorization'] = `Bearer ${token}` - } - - let response = await fetch(`${getURL()}/${path}`, { - ...init, - headers: { ...headers, ...(init.headers as Record) }, - }) - - // Токен протух — обновляем и повторяем запрос один раз. - if (response.status == 401) { - await refreshTokens() - const newToken = getToken() - if (newToken) { - headers['Authorization'] = `Bearer ${newToken}` - } - response = await fetch(`${getURL()}/${path}`, { - ...init, - headers: { ...headers, ...(init.headers as Record) }, - }) - } - - return response + return retryWithAuth( + async () => { + const headers = new Headers(init.headers) + const token = getToken() + if (token) headers.set('Authorization', `Bearer ${token}`) + const response = await fetch(`${getURL()}/${path}`, { ...init, headers }) + if (response.status === 401) throw new Error('unauthorized') + return response + }, + refreshTokens, + getToken, + ) } async function download(path: string, fallbackName: string): Promise { diff --git a/src/components/HeaderMenu.vue b/src/components/HeaderMenu.vue index d8e1b1e..4b1747b 100644 --- a/src/components/HeaderMenu.vue +++ b/src/components/HeaderMenu.vue @@ -2,7 +2,7 @@ import { MenuOutlined } from '@vicons/material' import { Icon } from '@vicons/utils' import { useMessage } from 'naive-ui' -import { ref } from 'vue' +import { ref, watch } from 'vue' import { useRouter } from 'vue-router' import { getAuthClient } from '@/api/auth_client' @@ -19,15 +19,17 @@ const permissions = ref([]) async function getPermissions() { permissions.value = [] + if (!authStore.user) return + const token = authStore.getToken() try { const res = await client.GetPermissions({}) - permissions.value = res.permissions || [] + if (token === authStore.getToken()) permissions.value = res.permissions || [] } catch (err) { message.error('Не удалось загрузить права доступа') console.error('Failed to load permissions:', err) } } -getPermissions() +watch(() => authStore.user, getPermissions, { immediate: true }) function hasPermission(permission: string): boolean { return permissions.value.includes(permission) diff --git a/src/stores/__tests__/auth.test.ts b/src/stores/__tests__/auth.test.ts new file mode 100644 index 0000000..f6784af --- /dev/null +++ b/src/stores/__tests__/auth.test.ts @@ -0,0 +1,289 @@ +import assert from 'node:assert/strict' +import { after, beforeEach, test } from 'node:test' +import { fileURLToPath } from 'node:url' + +import { createPinia, setActivePinia } from 'pinia' +import { createServer } from 'vite' + +const server = await createServer({ + configFile: false, + root: fileURLToPath(new URL('../../..', import.meta.url)), + resolve: { alias: { '@': fileURLToPath(new URL('../..', import.meta.url)) } }, + server: { middlewareMode: true, hmr: false, ws: false }, +}) +const { useAuthStore } = (await server.ssrLoadModule( + '/src/stores/auth.ts', +)) as typeof import('../auth') +const { getAuthClient } = (await server.ssrLoadModule( + '/src/api/auth_client.ts', +)) as typeof import('../../api/auth_client') +const { getBinaryClient } = (await server.ssrLoadModule( + '/src/api/binary_client.ts', +)) as typeof import('../../api/binary_client') +const originalFetch = globalThis.fetch +const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator') +after(async () => { + globalThis.fetch = originalFetch + if (originalNavigator) Object.defineProperty(globalThis, 'navigator', originalNavigator) + await server.close() +}) + +class MemoryStorage { + values = new Map() + getItem(key: string) { + return this.values.get(key) ?? null + } + setItem(key: string, value: string) { + this.values.set(key, value) + } + removeItem(key: string) { + this.values.delete(key) + } +} + +let storage: MemoryStorage +let listeners: Array<(event: { key: string | null; storageArea: MemoryStorage }) => void> +let lockTail: Promise +beforeEach(() => { + storage = new MemoryStorage() + storage.setItem('accessToken', 'expired') + storage.setItem('refreshToken', 'refresh-old') + listeners = [] + lockTail = Promise.resolve() + Object.defineProperty(globalThis, 'localStorage', { configurable: true, value: storage }) + Object.defineProperty(globalThis, 'window', { + configurable: true, + value: { + addEventListener: (_name: string, listener: (typeof listeners)[number]) => + listeners.push(listener), + }, + }) + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { + locks: { + request: (_name: string, action: () => Promise) => { + const result = lockTail.then(action) + lockTail = result.catch(() => {}) + return result + }, + }, + }, + }) + setActivePinia(createPinia()) +}) + +function storeWithTokens() { + const store = useAuthStore() + store.accessToken = storage.getItem('accessToken') + store.refreshToken = storage.getItem('refreshToken') + return store +} + +function json(data: unknown, status = 200) { + return new Response(JSON.stringify(data), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((done) => { + resolve = done + }) + return { promise, resolve } +} + +for (const withLocks of [true, false]) { + test(`simultaneous JSON and binary 401 share one refresh (locks=${withLocks})`, async () => { + if (!withLocks) + Object.defineProperty(globalThis, 'navigator', { configurable: true, value: {} }) + const store = storeWithTokens() + let refreshes = 0 + globalThis.fetch = async (url, init) => { + if (String(url).endsWith('/refresh')) { + refreshes++ + assert.equal(new Headers(init?.headers).get('Authorization'), null) + assert.equal(JSON.parse(String(init?.body)).refreshToken, 'refresh-old') + return json({ accessToken: 'fresh', refreshToken: 'refresh-new' }) + } + if (new Headers(init?.headers).get('Authorization') !== 'Bearer fresh') return json({}, 401) + if (String(url).endsWith('/archive')) return new Response('zip') + return json({ user: { ID: 1, username: 'Пользователь', roles: ['admin'] } }) + } + await Promise.all([ + store.fetchUser(), + store.fetchUser(), + getBinaryClient().downloadScenarioArchive(1), + ]) + assert.equal(refreshes, 1) + assert.equal(store.username, 'Пользователь') + assert.equal(store.hasRole('admin'), true) + assert.equal(storage.getItem('accessToken'), 'fresh') + // A delayed 401 for the previous token must not rotate the new token again. + assert.equal(await store.refreshTokenAction('expired'), true) + assert.equal(refreshes, 1) + }) +} + +test('reload restores expired tokens, user and roles', async () => { + const store = useAuthStore() + const fetched = deferred() + globalThis.fetch = async (url, init) => { + if (String(url).endsWith('/refresh')) + return json({ accessToken: 'fresh', refreshToken: 'refresh-new' }) + if (new Headers(init?.headers).get('Authorization') !== 'Bearer fresh') return json({}, 401) + fetched.resolve() + return json({ user: { ID: 1, username: 'Пользователь', roles: ['admin'] } }) + } + store.initFromStorage() + await fetched.promise + await store.fetchUser() + assert.equal(store.username, 'Пользователь') + assert.equal(store.hasRole('admin'), true) +}) + +for (const failure of ['401', '500', 'network']) { + test(`refresh ${failure} ${failure === '401' ? 'clears' : 'preserves'} session`, async () => { + const store = storeWithTokens() + globalThis.fetch = async () => { + if (failure === 'network') throw new TypeError('Failed to fetch') + return json({ code: failure === '401' ? 16 : 13, message: 'server error' }, Number(failure)) + } + if (failure === '401') assert.equal(await store.refreshTokenAction(), false) + else await assert.rejects(store.refreshTokenAction()) + assert.equal(storage.getItem('refreshToken'), failure === '401' ? null : 'refresh-old') + assert.equal(store.isAuthenticated, failure !== '401') + }) +} + +test('logout while refresh is in flight prevents restoring the session', async () => { + const store = storeWithTokens() + const started = deferred() + const response = deferred() + globalThis.fetch = async () => { + started.resolve() + return response.promise + } + const refreshing = store.refreshTokenAction() + await started.promise + await store.logout() + response.resolve(json({ accessToken: 'fresh', refreshToken: 'refresh-new' })) + assert.equal(await refreshing, false) + assert.equal(store.isAuthenticated, false) + assert.equal(storage.getItem('refreshToken'), null) +}) + +test('two tabs serialize refresh and reuse the stored result', async () => { + const first = storeWithTokens() + setActivePinia(createPinia()) + const second = storeWithTokens() + let refreshes = 0 + globalThis.fetch = async () => { + refreshes++ + return json({ accessToken: 'fresh', refreshToken: 'refresh-new' }) + } + const results = await Promise.all([first.refreshTokenAction(), second.refreshTokenAction()]) + assert.deepEqual(results, [true, true]) + assert.equal(refreshes, 1) + assert.equal(second.accessToken, 'fresh') + assert.equal(second.refreshToken, 'refresh-new') +}) + +test('storage events synchronize tokens and logout', async () => { + const store = useAuthStore() + globalThis.fetch = async () => + json({ user: { ID: 1, username: 'Пользователь', roles: ['admin'] } }) + store.initFromStorage() + await store.fetchUser() + storage.setItem('refreshToken', 'refresh-new') + storage.setItem('accessToken', 'fresh') + for (const listener of listeners) listener({ key: 'accessToken', storageArea: storage }) + await store.fetchUser() + assert.equal(store.accessToken, 'fresh') + assert.equal(store.username, 'Пользователь') + storage.removeItem('accessToken') + storage.removeItem('refreshToken') + for (const listener of listeners) listener({ key: 'accessToken', storageArea: storage }) + assert.equal(store.isAuthenticated, false) + assert.equal(store.user, null) +}) + +test('login omits stale authorization and account deletion retains password on retry', async () => { + const store = storeWithTokens() + let deletions = 0 + globalThis.fetch = async (url, init) => { + const headers = new Headers(init?.headers) + const path = String(url) + if (path.endsWith('/login')) { + assert.equal(headers.get('Authorization'), null) + return json({ accessToken: 'expired', refreshToken: 'refresh-old' }) + } + if (path.endsWith('/refresh')) + return json({ accessToken: 'fresh', refreshToken: 'refresh-new' }) + if (init?.method === 'DELETE') { + assert.equal(headers.get('X-Password'), 'password') + deletions++ + return json({}, deletions === 1 ? 401 : 200) + } + return json({ user: { ID: 1, username: 'Пользователь', roles: [] } }) + } + assert.equal(await store.login('test@example.invalid', 'password'), true) + assert.equal(await store.deleteAccount('password'), true) + assert.equal(deletions, 2) + assert.equal(store.isAuthenticated, false) +}) + +for (const status of [403, 500]) { + test(`JSON ${status} is not refreshed or retried`, async () => { + storeWithTokens() + let requests = 0 + globalThis.fetch = async () => { + requests++ + return json({ code: 7, message: 'denied' }, status) + } + await assert.rejects(getAuthClient().GetMe({}), /denied/) + assert.equal(requests, 1) + }) +} + +for (const operation of ['login', 'fetchUser']) { + test(`late ${operation} response cannot restore the user after logout`, async () => { + const store = storeWithTokens() + const started = deferred() + const response = deferred() + globalThis.fetch = async () => { + started.resolve() + return response.promise + } + const pending = + operation === 'login' ? store.login('test@example.invalid', 'password') : store.fetchUser() + await started.promise + await store.logout() + response.resolve( + json({ + accessToken: 'fresh', + refreshToken: 'refresh-new', + user: { ID: 1, username: 'Old user', roles: ['admin'] }, + }), + ) + await pending + assert.equal(store.isAuthenticated, false) + assert.equal(store.user, null) + assert.equal(storage.getItem('accessToken'), null) + }) +} + +test('successful recovery clears the previous temporary error', async () => { + const store = storeWithTokens() + globalThis.fetch = async () => { + throw new TypeError('Failed to fetch') + } + await store.fetchUser() + assert.equal(store.error, 'Failed to fetch') + globalThis.fetch = async () => json({ user: { ID: 1, username: 'Recovered', roles: ['admin'] } }) + await store.fetchUser() + assert.equal(store.error, null) + assert.equal(store.username, 'Recovered') +}) diff --git a/src/stores/auth.ts b/src/stores/auth.ts index 55c0a45..15ca035 100644 --- a/src/stores/auth.ts +++ b/src/stores/auth.ts @@ -1,6 +1,7 @@ import { defineStore } from 'pinia' import { computed, ref } from 'vue' +import { getAuthClient } from '@/api/auth_client' import { type User } from '@/api/generated/crabs/evening_detective_server' import { buildClient } from '@/api/generated/crabs/evening_detective_server/client' @@ -12,6 +13,10 @@ export const useAuthStore = defineStore('auth', () => { const isLoading = ref(false) const error = ref(null) + let refreshPromise: Promise | null = null + let sessionVersion = 0 + let isStorageListening = false + // ===== GETTERS ===== const isAuthenticated = computed(() => !!accessToken.value) const username = computed(() => user.value?.username || '') @@ -20,17 +25,49 @@ export const useAuthStore = defineStore('auth', () => { // ===== ACTIONS ===== // Инициализация из localStorage при загрузке + function readTokens() { + accessToken.value = localStorage.getItem('accessToken') + refreshToken.value = localStorage.getItem('refreshToken') + } + + function saveTokens(access: string, refresh: string | undefined) { + accessToken.value = access + refreshToken.value = refresh || null + if (refresh) localStorage.setItem('refreshToken', refresh) + else localStorage.removeItem('refreshToken') + localStorage.setItem('accessToken', access) + } + function initFromStorage() { - const token = localStorage.getItem('accessToken') - const refresh = localStorage.getItem('refreshToken') - - if (token) { - accessToken.value = token - refreshToken.value = refresh - - // Получаем данные пользователя - fetchUser() + readTokens() + if (!isStorageListening) { + window.addEventListener('storage', (event) => { + if ( + event.storageArea !== localStorage || + (event.key !== 'accessToken' && event.key !== null) + ) + return + const sync = () => { + sessionVersion++ + readTokens() + user.value = null + error.value = null + void fetchUser() + } + // Дождаться уже начатого обновления; выход обрабатывается сразу. + if ( + refreshPromise && + localStorage.getItem('accessToken') && + localStorage.getItem('refreshToken') + ) { + void refreshPromise.then(sync, sync) + } else { + sync() + } + }) + isStorageListening = true } + void fetchUser() } function getToken(): string | null { @@ -41,18 +78,17 @@ export const useAuthStore = defineStore('auth', () => { async function fetchUser() { if (!accessToken.value) return + const version = sessionVersion try { - const client = buildClient(getToken) - const response = await client.GetMe({}) + const response = await getAuthClient().GetMe({}) - if (response.user) { + if (response.user && version === sessionVersion && accessToken.value) { user.value = response.user + error.value = null } } catch (err) { - console.error('Failed to fetch user:', err) - // Если 401 — токен протух - if (err instanceof Error && err.message.includes('401')) { - await logout() + if (version === sessionVersion) { + error.value = err instanceof Error ? err.message : 'Не удалось восстановить сессию' } } } @@ -62,20 +98,14 @@ export const useAuthStore = defineStore('auth', () => { isLoading.value = true error.value = null + const version = ++sessionVersion try { - const client = buildClient(getToken) + const client = buildClient() const response = await client.Login({ email, password }) - // Сохраняем токены - if (response.accessToken) { - accessToken.value = response.accessToken - refreshToken.value = response.refreshToken || null - - // Сохраняем в localStorage - localStorage.setItem('accessToken', response.accessToken) - if (response.refreshToken) { - localStorage.setItem('refreshToken', response.refreshToken) - } + if (response.accessToken && version === sessionVersion) { + user.value = null + saveTokens(response.accessToken, response.refreshToken) // Получаем данные пользователя await fetchUser() @@ -93,32 +123,45 @@ export const useAuthStore = defineStore('auth', () => { } // Обновление токена - async function refreshTokenAction() { - if (!refreshToken.value) return false - - try { - const client = buildClient(getToken) - const response = await client.Refresh({ - refreshToken: refreshToken.value, - }) - - if (!response.accessToken) { + function refreshTokenAction(failedToken: string | null = accessToken.value): Promise { + if (refreshPromise) return refreshPromise + const version = sessionVersion + const token = refreshToken.value + const refresh = async () => { + if (version !== sessionVersion) return false + readTokens() + if (!accessToken.value || !refreshToken.value) { + await logout() return false } - - // Обновляем токены - accessToken.value = response.accessToken - refreshToken.value = response.refreshToken || null - - localStorage.setItem('accessToken', response.accessToken) - if (response.refreshToken) { - localStorage.setItem('refreshToken', response.refreshToken) + if (accessToken.value !== failedToken || refreshToken.value !== token) return true + try { + const response = await buildClient().Refresh({ refreshToken: token || undefined }) + if (version !== sessionVersion || localStorage.getItem('refreshToken') !== token) + return false + if (!response.accessToken || !response.refreshToken) { + throw new Error('Сервер не вернул токены сессии') + } + saveTokens(response.accessToken, response.refreshToken) + return true + } catch (err) { + if (version !== sessionVersion || localStorage.getItem('refreshToken') !== token) + return false + if (err instanceof Error && err.message === 'unauthorized') { + await logout() + return false + } + throw err } - - return true - } catch { - return false } + refreshPromise = ( + navigator.locks + ? navigator.locks.request('evening-detective-auth-refresh', refresh) + : refresh() + ).finally(() => { + refreshPromise = null + }) + return refreshPromise } // Регистрация @@ -151,7 +194,7 @@ export const useAuthStore = defineStore('auth', () => { try { // Пароль передаётся HTTP-заголовком X-Password, а не в URL или body - const client = buildClient(getToken, () => ({ 'X-Password': password })) + const client = getAuthClient(() => ({ 'X-Password': password })) await client.DeleteAccount({}) await logout() @@ -184,13 +227,14 @@ export const useAuthStore = defineStore('auth', () => { // Выход async function logout() { + sessionVersion++ accessToken.value = null refreshToken.value = null user.value = null error.value = null - localStorage.removeItem('accessToken') localStorage.removeItem('refreshToken') + localStorage.removeItem('accessToken') } // Проверка ролей