Защищённые endpoints в RTK Query используются для работы с API, требующим авторизации пользователя. Обычно такие endpoints доступны только после передачи access token, session token или другого идентификатора доступа.
Наиболее распространённые сценарии:
В RTK Query защита endpoints обычно строится вокруг:
Типичная схема выглядит следующим образом:
Пользователь проходит авторизацию.
Сервер возвращает access token.
Токен сохраняется:
RTK Query автоматически добавляет токен ко всем защищённым запросам.
Сервер проверяет токен.
Если токен истёк:
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.example.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'
})
})
})
export const {
useGetProfileQuery
} = api
Функция prepareHeaders вызывается перед каждым
запросом.
Она позволяет:
headers.set('Authorization', `Bearer ${token}`)
После этого браузер отправит:
Authorization: Bearer eyJhbGciOi...
const initialState = {
token: null
}
Преимущества:
Недостаток:
localStorage.setItem('token', token)
Получение:
const token = localStorage.getItem('token')
Преимущество:
Недостаток:
Безопасный вариант:
HttpOnly
Secure
SameSite=Strict
Преимущества:
Недостаток:
baseQuery: fetchBaseQuery({
baseUrl: 'https://api.example.com',
prepareHeaders: (headers) => {
const token = localStorage.getItem('token')
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
}
})
getPosts: builder.query({
query: () => '/posts'
})
updateProfile: builder.mutation({
query: (body) => ({
url: '/profile',
method: 'PATCH',
body
})
})
Токен будет автоматически добавлен через
prepareHeaders.
Иногда нельзя выполнять запрос без токена.
const token = useSelector(state => state.auth.token)
const { data } = useGetProfileQuery(undefined, {
skip: !token
})
RTK Query позволяет полностью блокировать запрос.
skip: true
или:
skip: !isAuth
Это предотвращает:
import { skipToken } from '@reduxjs/toolkit/query'
const { data } = useGetUserQuery(
userId ?? skipToken
)
Полезно при:
updateProfile: builder.mutation({
query: (userData) => ({
url: '/profile',
method: 'PATCH',
body: userData
})
})
const [updateProfile] = useUpdateProfileMutation()
const onS ave = async () => {
await updateProfile({
name: 'Alex'
})
}
Сервер может вернуть:
401 Unauthorized
или:
403 Forbidden
const { error } = useGetProfileQuery()
if (error?.status === 401) {
console.log('Не авторизован')
}
import {
fetchBaseQuery
} from '@reduxjs/toolkit/query/react'
import { logout } from './authSlice'
const rawBaseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
}
})
export const baseQuery = async (
args,
api,
extraOptions
) => {
const result = await rawBaseQuery(
args,
api,
extraOptions
)
if (result.error?.status === 401) {
api.dispatch(logout())
}
return result
}
Один из самых важных механизмов в защищённых API.
Обычно backend выдаёт:
Access token живёт недолго:
15 минут
Refresh token:
7 дней
import {
fetchBaseQuery
} from '@reduxjs/toolkit/query/react'
import {
setCredentials,
logout
} from './authSlice'
const rawBaseQuery = fetchBaseQuery({
baseUrl: 'https://api.example.com',
prepareHeaders: (headers, { getState }) => {
const token = getState().auth.token
if (token) {
headers.set('Authorization', `Bearer ${token}`)
}
return headers
}
})
export const baseQuery = async (
args,
api,
extraOptions
) => {
let result = await rawBaseQuery(
args,
api,
extraOptions
)
if (result.error?.status === 401) {
const refreshResult = await rawBaseQuery(
{
url: '/auth/refresh',
method: 'POST'
},
api,
extraOptions
)
if (refreshResult.data) {
api.dispatch(
setCredentials(refreshResult.data)
)
result = await rawBaseQuery(
args,
api,
extraOptions
)
} else {
api.dispatch(logout())
}
}
return result
}
Без защиты возможно:
Популярное решение — библиотека async-mutex.
import { Mutex } from 'async-mutex'
const mutex = new Mutex()
await mutex.waitForUnlock()
let result = await rawBaseQuery(
args,
api,
extraOptions
)
if (result.error?.status === 401) {
if (!mutex.isLocked()) {
const release = await mutex.acquire()
try {
const refreshResult =
await rawBaseQuery(
{
url: '/refresh',
method: 'POST'
},
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
)
}
}
Иногда удобно разделять API:
export const publicApi = createApi({
reducerPath: 'publicApi',
baseQuery: fetchBaseQuery({
baseUrl: '/api'
}),
endpoints: () => ({})
})
export const privateApi = createApi({
reducerPath: 'privateApi',
baseQuery,
endpoints: () => ({})
})
При cookie-based авторизации необходимо:
credentials: 'include'
fetchBaseQuery({
baseUrl: 'https://api.example.com',
credentials: 'include'
})
Это позволяет браузеру автоматически отправлять cookies.
При использовании cookies часто применяется CSRF token.
prepareHeaders: (headers) => {
const csrf =
localStorage.getItem('csrf')
if (csrf) {
headers.set('X-CSRF-Token', csrf)
}
return headers
}
Backend может проверять:
Frontend обычно скрывает UI.
const isAdmin =
user?.role === 'admin'
{
isAdmin && (
<AdminPanel />
)
}
Иногда данные должны загружаться только после login.
const [login] = useLoginMutation()
const onSub mit = async () => {
const result = await login(data).unwrap()
dispatch(
setCredentials(result)
)
}
После сохранения токена:
useGetProfileQuery()
начнёт автоматически отправлять защищённый запрос.
После выхода пользователя необходимо:
dispatch(api.util.resetApiState())
logout: (state) => {
state.token = null
state.user = null
}
localStorage.removeItem('token')
Без очистки cache возможно:
Особенно опасно на:
Некоторые приложения требуют повторного входа:
reauthenticate: builder.mutation({
query: (password) => ({
url: '/reauth',
method: 'POST',
body: { password }
})
})
Иногда frontend сам проверяет JWT.
import jwtDecode from 'jwt-decode'
const decoded = jwtDecode(token)
const isExpired =
decoded.exp * 1000 < Date.now()
Можно обновлять токен заранее:
if (isExpired) {
await refreshToken()
}
RTK Query поддерживает polling.
useNotificationsQuery(undefined, {
pollingInterval: 5000
})
Но polling должен останавливаться после logout.
skip: !token
При использовании WebSocket токен часто передаётся:
const socket = io(url, {
auth: {
token
}
})
В SSR-приложениях токен обычно хранится:
На сервере отсутствует:
window
и:
localStorage
if (typeof window !== 'undefined') {
const token =
localStorage.getItem('token')
}
После изменения профиля желательно обновить cache.
updateProfile: builder.mutation({
query: (body) => ({
url: '/profile',
method: 'PATCH',
body
}),
invalidatesTags: ['Profile']
})
getProfile: builder.query({
query: () => '/profile',
providesTags: ['Profile']
})
Защищённые endpoints часто работают с upload API.
uploadAvatar: builder.mutation({
query: (file) => {
const formData = new FormData()
formData.append('avatar', file)
return {
url: '/avatar',
method: 'POST',
body: formData
}
}
})
Authorization header будет добавлен автоматически.
Иногда приватные данные не должны загружаться сразу.
const [
trigger,
result
] = useLazyGetProfileQuery()
await trigger()
Retry может быть опасным:
Плохо:
401 -> retry -> 401 -> retry
Retry нужен:
Но не для:
if (error.status === 401) {
return error
}
Frontend-защита никогда не считается полноценной защитой.
Настоящая безопасность всегда реализуется на backend.
Frontend способен только:
Backend обязан: