Защищенные endpoints

Защищённые endpoints в RTK Query используются для работы с API, требующим авторизации пользователя. Обычно такие endpoints доступны только после передачи access token, session token или другого идентификатора доступа.

Наиболее распространённые сценарии:

  • получение профиля пользователя;
  • изменение персональных данных;
  • работа с административными API;
  • загрузка приватных ресурсов;
  • выполнение действий от имени авторизованного пользователя;
  • доступ к CRM, CMS и внутренним сервисам.

В RTK Query защита endpoints обычно строится вокруг:

  • добавления токенов в headers;
  • проверки авторизации;
  • автоматического обновления токенов;
  • обработки ошибок 401;
  • ограничения доступа к запросам;
  • разделения публичных и приватных API.

Архитектура защищённых запросов

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

  1. Пользователь проходит авторизацию.

  2. Сервер возвращает access token.

  3. Токен сохраняется:

    • в Redux;
    • localStorage;
    • cookies;
    • sessionStorage.
  4. RTK Query автоматически добавляет токен ко всем защищённым запросам.

  5. Сервер проверяет токен.

  6. Если токен истёк:

    • сервер возвращает 401;
    • RTK Query обновляет токен;
    • запрос повторяется.

Базовый пример защищённого API

Создание API

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

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

Она позволяет:

  • добавлять токены;
  • модифицировать заголовки;
  • задавать язык;
  • устанавливать timezone;
  • передавать tenant id;
  • внедрять служебные данные.

Добавление Authorization

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

После этого браузер отправит:

Authorization: Bearer eyJhbGciOi...

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

В Redux Store

const initialState = {
    token: null
}

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

  • быстрый доступ;
  • интеграция с RTK Query;
  • централизованное состояние.

Недостаток:

  • потеря данных после перезагрузки страницы.

В localStorage

localStorage.setItem('token', token)

Получение:

const token = localStorage.getItem('token')

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

  • сохранение между перезагрузками.

Недостаток:

  • уязвимость для XSS.

В cookies

Безопасный вариант:

HttpOnly
Secure
SameSite=Strict

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

  • защита от JavaScript;
  • безопасность;
  • удобная серверная авторизация.

Недостаток:

  • более сложная настройка backend.

Получение токена из localStorage

Пример

baseQuery: fetchBaseQuery({
    baseUrl: 'https://api.example.com',

    prepareHeaders: (headers) => {
        const token = localStorage.getItem('token')

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

        return headers
    }
})

Разделение публичных и защищённых endpoints

Публичные endpoints

getPosts: builder.query({
    query: () => '/posts'
})

Приватные endpoints

updateProfile: builder.mutation({
    query: (body) => ({
        url: '/profile',
        method: 'PATCH',
        body
    })
})

Токен будет автоматически добавлен через prepareHeaders.


Проверка авторизации перед запросом

Иногда нельзя выполнять запрос без токена.

Пример с skip

const token = useSelector(state => state.auth.token)

const { data } = useGetProfileQuery(undefined, {
    skip: !token
})

Условная загрузка данных

RTK Query позволяет полностью блокировать запрос.

skip: true

или:

skip: !isAuth

Это предотвращает:

  • лишние запросы;
  • ошибки 401;
  • ненужные обращения к API.

Использование skipToken

Пример

import { skipToken } from '@reduxjs/toolkit/query'

const { data } = useGetUserQuery(
    userId ?? skipToken
)

Полезно при:

  • отсутствии ID;
  • ожидании авторизации;
  • ленивой загрузке данных.

Защищённые mutations

Изменение профиля

updateProfile: builder.mutation({
    query: (userData) => ({
        url: '/profile',
        method: 'PATCH',
        body: userData
    })
})

Использование mutation

const [updateProfile] = useUpdateProfileMutation()

const onS ave = async () => {
    await updateProfile({
        name: 'Alex'
    })
}

Обработка ошибок авторизации

Сервер может вернуть:

401 Unauthorized

или:

403 Forbidden

Проверка ошибки

const { error } = useGetProfileQuery()

if (error?.status === 401) {
    console.log('Не авторизован')
}

Автоматический logout при 401

Пример кастомного baseQuery

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.


Схема refresh token

Обычно backend выдаёт:

  • access token;
  • refresh token.

Access token живёт недолго:

15 минут

Refresh token:

7 дней

Алгоритм обновления токена

  1. Запрос отправляется с access token.
  2. Сервер возвращает 401.
  3. RTK Query отправляет refresh token.
  4. Сервер возвращает новый access token.
  5. Старый запрос повторяется.

Реализация refresh token

Полный пример

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
}

Защита от множественных refresh запросов

Без защиты возможно:

  • 10 запросов одновременно;
  • все получают 401;
  • все вызывают refresh;
  • сервер перегружается.

Использование mutex

Популярное решение — библиотека async-mutex.

import { Mutex } from 'async-mutex'

Создание mutex

const mutex = new Mutex()

Защищённый refresh

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 по уровням доступа

Иногда удобно разделять API:

  • publicApi;
  • privateApi;
  • adminApi.

Пример

export const publicApi = createApi({
    reducerPath: 'publicApi',
    baseQuery: fetchBaseQuery({
        baseUrl: '/api'
    }),
    endpoints: () => ({})
})

Приватное API

export const privateApi = createApi({
    reducerPath: 'privateApi',
    baseQuery,
    endpoints: () => ({})
})

Использование credentials

При cookie-based авторизации необходимо:

credentials: 'include'

Пример

fetchBaseQuery({
    baseUrl: 'https://api.example.com',
    credentials: 'include'
})

Это позволяет браузеру автоматически отправлять cookies.


CSRF-защита

При использовании cookies часто применяется CSRF token.


Добавление CSRF header

prepareHeaders: (headers) => {

    const csrf =
        localStorage.getItem('csrf')

    if (csrf) {
        headers.set('X-CSRF-Token', csrf)
    }

    return headers
}

Защита административных endpoints

Backend может проверять:

  • role;
  • permissions;
  • scopes.

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()

начнёт автоматически отправлять защищённый запрос.


Поведение cache после logout

После выхода пользователя необходимо:

  • удалить токен;
  • очистить cache;
  • удалить приватные данные.

Сброс cache

dispatch(api.util.resetApiState())

Logout action

logout: (state) => {
    state.token = null
    state.user = null
}

Очистка localStorage

localStorage.removeItem('token')

Защита от утечки данных

Без очистки cache возможно:

  • предыдущий пользователь выйдет;
  • следующий пользователь увидит старые данные.

Особенно опасно на:

  • общих компьютерах;
  • терминалах;
  • корпоративных устройствах.

Re-authentication flow

Некоторые приложения требуют повторного входа:

  • перед удалением аккаунта;
  • перед изменением email;
  • перед финансовыми операциями.

Отдельный endpoint

reauthenticate: builder.mutation({
    query: (password) => ({
        url: '/reauth',
        method: 'POST',
        body: { password }
    })
})

Проверка истечения токена

Иногда frontend сам проверяет JWT.


Декодирование JWT

import jwtDecode from 'jwt-decode'

const decoded = jwtDecode(token)

Проверка exp

const isExpired =
    decoded.exp * 1000 < Date.now()

Предварительное обновление токена

Можно обновлять токен заранее:

if (isExpired) {
    await refreshToken()
}

Polling в защищённых endpoints

RTK Query поддерживает polling.


Пример

useNotificationsQuery(undefined, {
    pollingInterval: 5000
})

Но polling должен останавливаться после logout.


Остановка polling

skip: !token

WebSocket и авторизация

При использовании WebSocket токен часто передаётся:

  • в query string;
  • в headers;
  • в handshake.

Пример

const socket = io(url, {
    auth: {
        token
    }
})

SSR и защищённые endpoints

В SSR-приложениях токен обычно хранится:

  • в cookies;
  • в server session.

Проблема localStorage в 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 будет добавлен автоматически.


Ленивая загрузка защищённых данных

Иногда приватные данные не должны загружаться сразу.


Lazy query

const [
    trigger,
    result
] = useLazyGetProfileQuery()

Запуск вручную

await trigger()

Retry и защищённые endpoints

Retry может быть опасным:

  • при 401;
  • при invalid token;
  • при logout.

Нежелательный retry

Плохо:

401 -> retry -> 401 -> retry

Правильная стратегия

Retry нужен:

  • для network errors;
  • для 500;
  • для timeout.

Но не для:

  • 401;

Пример проверки

if (error.status === 401) {
    return error
}

Безопасность frontend

Frontend-защита никогда не считается полноценной защитой.

Настоящая безопасность всегда реализуется на backend.

Frontend способен только:

  • скрывать интерфейс;
  • управлять UX;
  • предотвращать лишние запросы;
  • хранить токены;
  • корректно обрабатывать состояния авторизации.

Backend обязан:

  • проверять токены;
  • валидировать permissions;
  • контролировать роли;
  • ограничивать доступ;
  • проверять ownership ресурсов.