В большинстве современных API используется схема авторизации на основе двух токенов:
accessToken — короткоживущий токен доступа;refreshToken — долгоживущий токен обновления.accessToken передаётся практически в каждом HTTP-запросе
и используется сервером для проверки прав пользователя. Из соображений
безопасности срок его жизни обычно ограничен несколькими минутами или
часами.
После истечения времени действия сервер начинает возвращать ошибку:
{
"status": 401,
"message": "Unauthorized"
}
или:
{
"error": "Token expired"
}
Без механизма обновления токена пользователь был бы вынужден повторно
проходить авторизацию после каждого истечения
accessToken.
RTK Query позволяет централизованно реализовать автоматическое
обновление токенов через кастомный baseQuery.
Последовательность работы обычно выглядит следующим образом:
accessToken.401 Unauthorized.refreshToken на
endpoint обновления.accessToken.Типичная конфигурация RTK Query начинается с
fetchBaseQuery.
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.accessToken
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
}
})
В этом примере:
Authorization добавляется автоматически;Стандартный fetchBaseQuery умеет:
Однако он не умеет:
Для решения этих задач создаётся обёртка над
baseQuery.
Основная идея — перехватывать ошибки 401.
import { fetchBaseQuery } from '@reduxjs/toolkit/query/react'
const rawBaseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.accessToken
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
}
})
const baseQueryWithReauth = async (args, api, extraOptions) => {
let result = await rawBaseQuery(args, api, extraOptions)
if (result.error && result.error.status === 401) {
const refreshResult = await rawBaseQuery(
{
url: '/auth/refresh',
method: 'POST',
body: {
refreshToken: api.getState().auth.refreshToken
}
},
api,
extraOptions
)
if (refreshResult.data) {
api.dispatch(setCredentials(refreshResult.data))
result = await rawBaseQuery(args, api, extraOptions)
} else {
api.dispatch(logout())
}
}
return result
}
После создания обёртки необходимо подключить её в
createApi.
export const api = createApi({
reducerPath: 'api',
baseQuery: baseQueryWithReauth,
endpoints: () => ({})
})
Теперь:
После успешного refresh необходимо обновить Redux Store.
Пример slice:
import { createSlice } from '@reduxjs/toolkit'
const initialState = {
accessToken: null,
refreshToken: null,
user: null
}
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setCredentials: (state, action) => {
state.accessToken = action.payload.accessToken
state.refreshToken = action.payload.refreshToken
},
logout: (state) => {
state.accessToken = null
state.refreshToken = null
state.user = null
}
}
})
export const {
setCredentials,
logout
} = authSlice.actions
export default authSlice.reducer
Ключевой особенностью RTK Query является возможность повторно выполнить тот же запрос.
result = await rawBaseQuery(args, api, extraOptions)
args содержит:
Повторный вызов полностью воспроизводит исходный запрос.
Практически всегда рекомендуется хранить токены отдельно.
Используется:
Authorization;Используется:
Существует несколько подходов.
Простой вариант:
state.auth.refreshToken
Недостатки:
localStorage.setItem('refreshToken', token)
Преимущества:
Недостатки:
Наиболее безопасный вариант.
Особенности:
В этом случае refresh-запрос выглядит проще:
const refreshResult = await rawBaseQuery(
{
url: '/auth/refresh',
method: 'POST',
credentials: 'include'
},
api,
extraOptions
)
Если refreshToken хранится в cookie, необходимо разрешить передачу cookies.
const rawBaseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
credentials: 'include'
})
Без этого браузер не отправит cookie вместе с запросом.
Одна из самых опасных ошибок — бесконечное обновление токена.
Проблемный сценарий:
401;Правильная реализация:
if (refreshResult.data) {
api.dispatch(setCredentials(refreshResult.data))
result = await rawBaseQuery(args, api, extraOptions)
} else {
api.dispatch(logout())
}
Если refresh не удался:
Иногда требуется исключить сам refresh-endpoint из повторного refresh.
Пример:
if (
result.error &&
result.error.status === 401 &&
args.url !== '/auth/refresh'
) {
}
Это дополнительная защита от циклических запросов.
Сложная проблема возникает при параллельных запросах.
Сценарий:
401;Результат:
Наиболее популярное решение — mutex.
Для этого часто используется библиотека:
npm install async-mutex
import { Mutex } from 'async-mutex'
const mutex = new Mutex()
import {
createApi,
fetchBaseQuery
} from '@reduxjs/toolkit/query/react'
import { Mutex } from 'async-mutex'
const mutex = new Mutex()
const rawBaseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.accessToken
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
}
})
const baseQueryWithReauth = async (
args,
api,
extraOptions
) => {
await mutex.waitForUnlock()
let result = await rawBaseQuery(
args,
api,
extraOptions
)
if (result.error && result.error.status === 401) {
if (!mutex.isLocked()) {
const release = await mutex.acquire()
try {
const refreshResult = await rawBaseQuery(
{
url: '/auth/refresh',
method: 'POST',
body: {
refreshToken:
api.getState().auth.refreshToken
}
},
api,
extraOptions
)
if (refreshResult.data) {
api.dispatch(
setCredentials(refreshResult.data)
)
result = await rawBaseQuery(
args,
api,
extraOptions
)
} else {
api.dispatch(logout())
}
} finally {
release()
}
} else {
await mutex.waitForUnlock()
result = await rawBaseQuery(
args,
api,
extraOptions
)
}
}
return result
}
Первый запрос:
401;Остальные запросы:
Если refreshToken просрочен:
Пример:
api.dispatch(logout())
После этого обычно выполняется:
window.location.href = '/login'
или:
navigate('/login')
Важно очищать API cache.
api.dispatch(api.util.resetApiState())
Без этого:
Часто logout включает:
logout: (state) => {
state.accessToken = null
state.refreshToken = null
state.user = null
}
и:
dispatch(api.util.resetApiState())
Некоторые приложения не ждут 401.
Подход:
exp;Пример:
const payload = JSON.parse(
atob(token.split('.')[1])
)
Проверка срока:
const currentTime = Date.now() / 1000
if (payload.exp < currentTime) {
}
Преимущества:
401;Недостатки:
Иногда после обновления токена запрос всё равно может завершиться ошибкой.
Например:
RTK Query позволяет комбинировать refresh и retry.
import { retry } from '@reduxjs/toolkit/query'
const staggeredBaseQuery = retry(
baseQueryWithReauth,
{
maxRetries: 3
}
)
Если приложение использует WebSocket:
Пример:
socket.auth.token = newAccessToken
socket.connect()
При серверном рендеринге возникают особенности:
window.В SSR-проектах refresh обычно строится через:
Неправильно:
queryFn: async () => {
}
Refresh должен быть централизован в baseQuery.
Проблемный вариант:
Это приводит к рассинхронизации.
Без mutex:
Если refresh не удался, но приложение продолжает работу:
401.Типичная production-схема:
RTK Query Endpoint
↓
baseQueryWithReauth
↓
fetchBaseQuery
↓
API Request
↓
401 Unauthorized
↓
Refresh Token Request
↓
New Access Token
↓
Retry Original Request
Для production-приложений оптимально:
Нежелательные практики:
export const api = createApi({
reducerPath: 'api',
baseQuery: retry(
baseQueryWithReauth,
{
maxRetries: 2
}
),
tagTypes: [
'User',
'Post',
'Comment'
],
endpoints: () => ({})
})
Такая архитектура обеспечивает: