Большинство современных API используют механизм авторизации через токены. Обычно клиент получает токен после успешного входа пользователя в систему, а затем передаёт его в HTTP-заголовках при каждом запросе.
В RTK Query обработка токенов чаще всего реализуется через:
prepareHeadersbaseQuery401 Unauthorizedrefresh tokenlocalStorage или
cookiesНаиболее распространённый сценарий:
Пользователь выполняет вход.
Сервер возвращает:
accessTokenrefreshTokenaccessToken добавляется в заголовки
запросов.
При истечении срока действия accessToken выполняется
запрос обновления токена.
После обновления повторяется исходный запрос.
Типичная структура:
Client
↓
POST /login
↓
accessToken + refreshToken
↓
Все последующие запросы:
Authorization: Bearer <token>
В RTK Query центральной точкой настройки авторизации является
fetchBaseQuery.
Самый распространённый способ передачи токена — использование
prepareHeaders.
Пример:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.site.com',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
}
}),
endpoints: (builder) => ({
getProfile: builder.query({
query: () => '/profile'
})
})
})
Функция вызывается перед каждым HTTP-запросом.
Она получает:
(headers, api)
Где:
| Аргумент | Назначение |
|---|---|
headers |
объект HTTP-заголовков |
api |
служебная информация RTK Query |
Через второй аргумент можно получить состояние Redux:
prepareHeaders: (headers, { getState }) => {
const state = getState()
console.log(state)
return headers
}
Это позволяет динамически извлекать токен.
Обычно токен хранится в отдельном слайсе:
import { createSlice } from '@reduxjs/toolkit'
const initialState = {
token: null,
user: null
}
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
setCredentials: (state, action) => {
state.token = action.payload.token
state.user = action.payload.user
},
logout: (state) => {
state.token = null
state.user = null
}
}
})
export const {
setCredentials,
logout
} = authSlice.actions
export default authSlice.reducer
Mutation для входа:
login: builder.mutation({
query: (credentials) => ({
url: '/login',
method: 'POST',
body: credentials
})
})
Использование:
const [login] = useLoginMutation()
const handleLogin = async () => {
const result = await login({
email: 'admin@mail.com',
password: '123456'
}).unwrap()
dispatch(setCredentials(result))
}
После записи токена в Store все последующие запросы автоматически
начнут отправлять Authorization.
Стандартный формат:
Authorization: Bearer eyJhbGciOiJIUzI1Ni...
Слово Bearer является частью стандарта OAuth2.
Иногда токен не должен добавляться ко всем запросам.
Пример:
prepareHeaders: (headers, { endpoint, getState }) => {
const token = getState().auth.token
const publicEndpoints = [
'login',
'register'
]
if (token && !publicEndpoints.includes(endpoint)) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
}
RTK Query передаёт название endpoint:
prepareHeaders: (
headers,
{ endpoint }
) => {
console.log(endpoint)
return headers
}
Это позволяет строить гибкую систему авторизации.
В prepareHeaders также доступен extra.
Пример:
prepareHeaders: (
headers,
{ extra }
) => {
console.log(extra)
return headers
}
Чаще используется в сложных middleware-сценариях.
RTK Query сообщает тип запроса:
prepareHeaders: (
headers,
{ type }
) => {
console.log(type)
return headers
}
Возможные значения:
query
mutation
Очень распространённый вариант:
prepareHeaders: (headers) => {
const token = localStorage.getItem('token')
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
}
localStorage удобен, но имеет недостатки:
| Проблема | Описание |
|---|---|
| XSS | JavaScript может получить доступ к токену |
| Нет автоматической защиты | Токен хранится в открытом виде |
| Нет HttpOnly | Защита браузером отсутствует |
Во многих production-проектах используют cookies:
Set-Cookie: accessToken=...
HttpOnly
Secure
SameSite=Strict
Преимущества:
Если сервер использует cookies:
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.site.com',
credentials: 'include'
})
Браузер начинает автоматически отправлять:
Обычно применяют двухтокенную схему.
Короткоживущий токен:
5 минут
15 минут
30 минут
Используется для API-запросов.
Долгоживущий токен:
7 дней
30 дней
90 дней
Используется для обновления accessToken.
Если токен украден:
короткое время жизни = меньший ущерб
Одна из самых важных задач RTK Query.
Схема:
Запрос → 401
↓
refresh token request
↓
новый access token
↓
повтор исходного запроса
Для refresh-механизма почти всегда создают обёртку над
fetchBaseQuery.
Пример:
import {
fetchBaseQuery
} from '@reduxjs/toolkit/query'
const baseQuery = fetchBaseQuery({
baseUrl: 'https://api.site.com',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token
if (token) {
headers.set(
'Authorization',
`Bearer ${token}`
)
}
return headers
}
})
const baseQueryWithReauth = async (
args,
api,
extraOptions
) => {
let result = await baseQuery(
args,
api,
extraOptions
)
if (result.error?.status === 401) {
const refreshResult = await baseQuery(
{
url: '/refresh',
method: 'POST'
},
api,
extraOptions
)
if (refreshResult.data) {
api.dispatch(
setCredentials(refreshResult.data)
)
result = await baseQuery(
args,
api,
extraOptions
)
} else {
api.dispatch(logout())
}
}
return result
}
export const api = createApi({
reducerPath: 'api',
baseQuery: baseQueryWithReauth,
endpoints: (builder) => ({
getProfile: builder.query({
query: () => '/profile'
})
})
})
RTK Query получает:
401 Unauthorized
После этого:
/refreshКлючевая строка:
result = await baseQuery(
args,
api,
extraOptions
)
Используются те же аргументы запроса.
Представим:
10 запросов одновременно получили 401
Без защиты произойдёт:
10 refresh запросов
Это создаёт гонки данных.
Часто используют async-mutex.
Установка:
npm install async-mutex
import { Mutex } from 'async-mutex'
const mutex = new Mutex()
await mutex.waitForUnlock()
let result = await baseQuery(
args,
api,
extraOptions
)
if (result.error?.status === 401) {
if (!mutex.isLocked()) {
const release = await mutex.acquire()
try {
const refreshResult = await baseQuery(
{
url: '/refresh',
method: 'POST'
},
api,
extraOptions
)
if (refreshResult.data) {
api.dispatch(
setCredentials(refreshResult.data)
)
result = await baseQuery(
args,
api,
extraOptions
)
} else {
api.dispatch(logout())
}
} finally {
release()
}
} else {
await mutex.waitForUnlock()
result = await baseQuery(
args,
api,
extraOptions
)
}
}
Без него:
При logout важно:
dispatch(api.util.resetApiState())
logout: (state) => {
state.token = null
state.user = null
}
И:
dispatch(api.util.resetApiState())
Иначе после выхода могут остаться:
После логина RTK Query автоматически начнёт использовать новый токен,
потому что prepareHeaders вызывается перед каждым
запросом.
Иногда нужно извлекать токен отдельно:
const token = useSelector(
(state) => state.auth.token
)
Но RTK Query обычно работает напрямую через
getState.
Нужно отличать:
| Код | Значение |
|---|---|
| 401 | токен истёк |
| 403 | доступа нет |
if (status === 401) {
// refresh token
}
if (status === 403) {
// недостаточно прав
}
JWT содержит payload:
const payload = JSON.parse(
atob(token.split('.')[1])
)
JWT обычно содержит:
payload.exp
Это Unix timestamp времени истечения.
const isExpired =
Date.now() >= payload.exp * 1000
Это позволяет:
if (isExpired) {
await refreshToken()
}
Клиенту нельзя полностью доверять.
Сервер всё равно обязан:
Иногда токен приходит в нестандартном формате.
Пример:
{
"data": {
"access_token": "123"
}
}
login: builder.mutation({
query: (credentials) => ({
url: '/login',
method: 'POST',
body: credentials
}),
transformResponse: (response) => {
return {
token: response.data.access_token
}
}
})
Иногда токен нужно сохранять автоматически.
login: builder.mutation({
query: (credentials) => ({
url: '/login',
method: 'POST',
body: credentials
}),
async onQueryStarted(
arg,
{ dispatch, queryFulfilled }
) {
try {
const { data } =
await queryFulfilled
dispatch(setCredentials(data))
} catch (error) {
console.error(error)
}
}
})
Позволяет:
Если refresh тоже вернул 401:
refresh token недействителен
Требуется:
Часто используют:
window.location.href = '/login'
Или:
navigate('/login')
При Server-Side Rendering появляются особенности:
localStorage недоступенRefresh token нельзя:
Обычно:
access token
user info
auth state
Refresh token чаще помещают в HttpOnly cookie.
После:
dispatch(setCredentials(newToken))
следующий вызов baseQuery автоматически получит новый
токен через getState().
RTK Query позволяет полностью прозрачно повторять запросы.
Пользовательский интерфейс при этом даже не узнает о refresh-механизме.
Наиболее распространённая архитектура:
access token:
- хранится в памяти
- короткое время жизни
refresh token:
- HttpOnly cookie
- long-term
Плохо:
headers: {
Authorization: `Bearer ${token}`
}
в каждом endpoint.
Правильно — централизованный prepareHeaders.
Это создаёт серьёзные риски безопасности.
Может привести к утечке приватных данных.
Решается через mutex.
Серверная проверка обязательна всегда.
Часто используют:
authSlice
↓
baseQueryWithReauth
↓
prepareHeaders
↓
mutex refresh
↓
resetApiState
Такая структура обеспечивает: