Интеграция с Redux для хранения токенов

При работе с RTK Query одной из наиболее распространённых задач становится интеграция механизма авторизации. Наиболее частый сценарий — хранение access token и refresh token в Redux Store с последующей автоматической подстановкой токена в HTTP-запросы.

RTK Query тесно интегрирован с Redux Toolkit, поэтому доступ к глобальному состоянию возможен напрямую через API baseQuery.


Архитектура хранения токенов

Обычно структура состояния авторизации выглядит следующим образом:

{
  auth: {
    accessToken: 'jwt_access_token',
    refreshToken: 'jwt_refresh_token',
    user: {
      id: 1,
      email: 'admin@example.com'
    }
  }
}

В большинстве приложений:

  • accessToken используется для доступа к API
  • refreshToken применяется для обновления access token
  • данные пользователя сохраняются отдельно
  • RTK Query получает токен из Redux Store через getState

Создание authSlice

Для хранения токенов создаётся отдельный Redux slice.

// features/auth/authSlice.js

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
      state.user = action.payload.user
    },

    logout(state) {
      state.accessToken = null
      state.refreshToken = null
      state.user = null
    }
  }
})

export const {
  setCredentials,
  logout
} = authSlice.actions

export default authSlice.reducer

Подключение authSlice к Store

// app/store.js

import { configureStore } from '@reduxjs/toolkit'
import authReducer from '../features/auth/authSlice'
import { api } from '../services/api'

export const store = configureStore({
  reducer: {
    auth: authReducer,

    [api.reducerPath]: api.reducer
  },

  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(api.middleware)
})

Получение токена внутри RTK Query

RTK Query предоставляет функцию prepareHeaders, позволяющую изменять заголовки перед отправкой запроса.

Именно здесь чаще всего происходит интеграция с Redux Store.

// services/api.js

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.accessToken

      if (token) {
        headers.set('authorization', `Bearer ${token}`)
      }

      return headers
    }
  }),

  endpoints: () => ({})
})

Как работает prepareHeaders

Функция вызывается перед каждым запросом RTK Query.

Вторым аргументом передаётся специальный объект:

{
  getState,
  endpoint,
  type,
  forced
}

Наиболее важным является getState.

Пример:

prepareHeaders: (headers, api) => {
  const state = api.getState()

  console.log(state)

  return headers
}

RTK Query получает актуальное состояние Redux Store непосредственно перед выполнением запроса. Благодаря этому токен всегда остаётся свежим.


Автоматическое обновление заголовков

Одно из преимуществ Redux-хранилища заключается в том, что RTK Query автоматически начинает использовать новый токен сразу после обновления состояния.

Например:

dispatch(setCredentials({
  accessToken: 'new_token',
  refreshToken: 'refresh_token'
}))

Следующий запрос RTK Query автоматически отправится с новым access token.

Дополнительной синхронизации не требуется.


Авторизация пользователя через mutation

Типичный сценарий — получение токенов после логина.

// services/authApi.js

import { api } from './api'

export const authApi = api.injectEndpoints({
  endpoints: (builder) => ({
    login: builder.mutation({
      query: (credentials) => ({
        url: '/login',
        method: 'POST',
        body: credentials
      })
    })
  })
})

export const {
  useLoginMutation
} = authApi

Сохранение токенов после логина

// components/Login.jsx

import { useDispatch } from 'react-redux'
import { useLoginMutation } from '../services/authApi'
import { setCredentials } from '../features/auth/authSlice'

export default function Login() {
  const dispatch = useDispatch()

  const [login] = useLoginMutation()

  const handleLogin = async () => {
    const response = await login({
      email: 'admin@example.com',
      password: '123456'
    }).unwrap()

    dispatch(setCredentials(response))
  }

  return null
}

unwrap и работа с результатом mutation

Метод unwrap() преобразует результат mutation в обычный Promise.

Без unwrap:

const result = await login(data)

console.log(result.data)
console.log(result.error)

С unwrap:

try {
  const data = await login(data).unwrap()

  console.log(data)
} catch (error) {
  console.log(error)
}

Такой подход значительно упрощает обработку авторизации.


Хранение access token в памяти

Многие современные приложения хранят access token исключительно в Redux Store.

Преимущества:

  • токен исчезает после закрытия вкладки
  • уменьшается риск XSS-атак
  • отсутствует необходимость синхронизации localStorage
  • проще реализовать logout

Недостаток:

  • пользователь теряет авторизацию после перезагрузки страницы

Хранение токенов в localStorage

Часто Redux используется совместно с localStorage.

Сценарий:

  1. После логина токен сохраняется в Redux
  2. Токен дополнительно записывается в localStorage
  3. При старте приложения токен восстанавливается в Store

Сохранение токенов в localStorage

setCredentials(state, action) {
  state.accessToken = action.payload.accessToken
  state.refreshToken = action.payload.refreshToken

  localStorage.setItem(
    'accessToken',
    action.payload.accessToken
  )

  localStorage.setItem(
    'refreshToken',
    action.payload.refreshToken
  )
}

Восстановление токенов при запуске приложения

const initialState = {
  accessToken: localStorage.getItem('accessToken'),
  refreshToken: localStorage.getItem('refreshToken'),
  user: null
}

Очистка токенов при logout

logout(state) {
  state.accessToken = null
  state.refreshToken = null
  state.user = null

  localStorage.removeItem('accessToken')
  localStorage.removeItem('refreshToken')
}

Почему localStorage считается менее безопасным

Главная проблема localStorage — доступность токенов из JavaScript.

Если приложение уязвимо к XSS:

const token = localStorage.getItem('accessToken')

вредоносный код может украсть access token.

Поэтому многие production-приложения:

  • хранят refresh token в HttpOnly cookie
  • access token держат только в памяти
  • используют короткое время жизни access token

Использование селекторов

Вместо прямого доступа к состоянию часто используются селекторы.

// features/auth/selectors.js

export const selectAccessToken = (state) =>
  state.auth.accessToken

export const selectCurrentUser = (state) =>
  state.auth.user

Использование селектора внутри компонента

import { useSelector } from 'react-redux'
import { selectCurrentUser } from './selectors'

export default function Profile() {
  const user = useSelector(selectCurrentUser)

  return <div>{user.email}</div>
}

Интеграция с persist middleware

Для автоматического сохранения Redux Store часто используется библиотека Redux Persist.

Пример конфигурации:

import storage from 'redux-persist/lib/storage'
import { persistReducer } from 'redux-persist'

const authPersistConfig = {
  key: 'auth',
  storage
}

const persistedAuthReducer = persistReducer(
  authPersistConfig,
  authReducer
)

Подключение persisted reducer

export const store = configureStore({
  reducer: {
    auth: persistedAuthReducer,
    [api.reducerPath]: api.reducer
  },

  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware({
      serializableCheck: false
    }).concat(api.middleware)
})

Почему serializableCheck отключается

Redux Persist добавляет специальные служебные объекты, нарушающие правило сериализуемости Redux Toolkit.

Без отключения проверки консоль будет заполнена предупреждениями.


Реакция RTK Query на logout

После выхода пользователя желательно очищать кэш RTK Query.

import { api } from '../services/api'
import { logout } from './authSlice'

export const logoutUser = () => (dispatch) => {
  dispatch(logout())

  dispatch(api.util.resetApiState())
}

Что делает resetApiState

Метод:

api.util.resetApiState()

полностью очищает:

  • cache
  • subscriptions
  • pending requests
  • tags
  • данные query и mutation

Это особенно важно в multi-user системах.


Централизованная архитектура авторизации

Наиболее распространённая структура проекта:

src/
├── app/
│   └── store.js
│
├── features/
│   └── auth/
│       ├── authSlice.js
│       ├── selectors.js
│       └── authThunks.js
│
├── services/
│   ├── api.js
│   └── authApi.js
│
└── components/

Разделение ответственности

Типичная архитектура выглядит следующим образом:

authSlice

Отвечает за:

  • хранение токенов
  • хранение пользователя
  • logout
  • обновление credentials

api.js

Отвечает за:

  • baseQuery
  • prepareHeaders
  • подключение токенов
  • общую конфигурацию API

authApi.js

Отвечает за:

  • login
  • registration
  • refresh token
  • профиль пользователя

Интеграция RTK Query и Redux

RTK Query не заменяет Redux Store полностью.

RTK Query лучше использовать для:

  • серверного состояния
  • HTTP cache
  • API-запросов
  • синхронизации данных

Redux Store лучше подходит для:

  • авторизации
  • UI-состояния
  • модальных окон
  • глобальных настроек
  • темы интерфейса
  • клиентских флагов

Интеграция RTK Query с Redux позволяет объединить обе модели хранения в единую архитектуру приложения.