Logout и очистка данных

Logout в приложении — это не только удаление токена авторизации. В большинстве случаев необходимо полностью очистить клиентское состояние, связанное с пользователем:

  • удалить access token и refresh token;
  • очистить кэш RTK Query;
  • удалить данные пользователя из Redux Store;
  • остановить активные запросы;
  • предотвратить повторное использование старых данных;
  • сбросить подписки и polling;
  • очистить persist-хранилища;
  • перенаправить пользователя на страницу авторизации.

Если очистка выполнена неполностью, приложение может продолжать отображать приватные данные предыдущего пользователя, использовать старые токены или автоматически отправлять устаревшие запросы.


Базовая схема logout

Типичная последовательность logout:

  1. Отправка запроса logout на сервер.
  2. Удаление токенов.
  3. Очистка Redux-состояния.
  4. Сброс RTK Query cache.
  5. Перенаправление пользователя.

Пример API logout

import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';

export const authApi = createApi({
    reducerPath: 'authApi',

    baseQuery: fetchBaseQuery({
        baseUrl: '/api',
    }),

    endpoints: (builder) => ({
        logout: builder.mutation({
            query: () => ({
                url: '/logout',
                method: 'POST',
            }),
        }),
    }),
});

export const {
    useLogoutMutation,
} = authApi;

Очистка токенов

После logout access token больше не должен использоваться.

Пример хранения токенов:

localStorage.setItem('accessToken', token);

Очистка:

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

Если токены хранятся в cookies:

document.cookie =
    'accessToken=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;';

Очистка пользовательского состояния

Часто в Redux Store хранится информация о текущем пользователе:

{
    auth: {
        user: {
            id: 1,
            name: 'Alex'
        }
    }
}

После logout эти данные должны быть удалены.


Пример authSlice

import { createSlice } from '@reduxjs/toolkit';

const initialState = {
    user: null,
    isAuthenticated: false,
};

const authSlice = createSlice({
    name: 'auth',

    initialState,

    reducers: {
        setCredentials(state, action) {
            state.user = action.payload;
            state.isAuthenticated = true;
        },

        logout(state) {
            state.user = null;
            state.isAuthenticated = false;
        },
    },
});

export const {
    setCredentials,
    logout,
} = authSlice.actions;

export default authSlice.reducer;

Сброс RTK Query cache

RTK Query хранит кэш запросов внутри api slice.

Даже после удаления токена старые данные могут оставаться в store:

{
    queries: {
        getProfile: {
            data: {
                email: 'admin@test.com'
            }
        }
    }
}

Если cache не очистить:

  • UI может продолжать показывать приватные данные;
  • повторный вход под другим пользователем может использовать старый cache;
  • stale-данные могут появляться после logout.

resetApiState

RTK Query предоставляет встроенный action:

api.util.resetApiState()

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

  • queries;
  • mutations;
  • subscriptions;
  • tags;
  • cache;
  • polling.

Полный logout с очисткой cache

import { store } from './store';
import { authApi } from './services/authApi';
import { logout } from './features/auth/authSlice';

export const performLogout = () => {
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');

    store.dispatch(logout());

    store.dispatch(authApi.util.resetApiState());
};

Logout через custom hook

Часто logout инкапсулируют в отдельный hook.

import { useDispatch } from 'react-redux';
import { useNavigate } from 'react-router-dom';

import { authApi } from '../services/authApi';
import { logout } from '../features/auth/authSlice';

export const useLogout = () => {
    const dispatch = useDispatch();
    const navigate = useNavigate();

    const handleLogout = () => {
        localStorage.removeItem('accessToken');
        localStorage.removeItem('refreshToken');

        dispatch(logout());

        dispatch(authApi.util.resetApiState());

        navigate('/login');
    };

    return handleLogout;
};

Logout с запросом на сервер

Многие backend-приложения требуют уведомить сервер о logout:

  • инвалидировать refresh token;
  • удалить session;
  • обновить blacklist токенов;
  • завершить websocket session.

Пример logout mutation

logout: builder.mutation({
    query: () => ({
        url: '/auth/logout',
        method: 'POST',
    }),
})

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

const [logoutRequest] = useLogoutMutation();

const handleLogout = async () => {
    try {
        await logoutRequest().unwrap();
    } catch (error) {
        console.error(error);
    }

    localStorage.removeItem('accessToken');

    dispatch(logout());

    dispatch(api.util.resetApiState());
};

Почему очистка должна выполняться даже при ошибке

Сервер может быть недоступен:

500 Internal Server Error

Но пользователь всё равно должен выйти из системы.

Неправильно:

try {
    await logoutRequest().unwrap();

    clearState();
} catch {
    console.error('Logout failed');
}

Если запрос упал — logout не выполнится.

Правильный вариант:

try {
    await logoutRequest().unwrap();
} catch (error) {
    console.error(error);
}

clearState();

Централизованный clearState

Удобно выносить очистку в отдельную функцию.

export const clearState = (dispatch) => {
    localStorage.removeItem('accessToken');
    localStorage.removeItem('refreshToken');

    dispatch(logout());

    dispatch(api.util.resetApiState());
};

Logout после 401 Unauthorized

Очень распространённый сценарий — автоматический logout при невалидном refresh token.


Пример автоматического logout

const baseQuery = fetchBaseQuery({
    baseUrl: '/api',
    prepareHeaders: (headers) => {
        const token = localStorage.getItem('accessToken');

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

        return headers;
    },
});

baseQueryWithReauth

const baseQueryWithReauth = async (
    args,
    api,
    extraOptions
) => {
    let result = await baseQuery(
        args,
        api,
        extraOptions
    );

    if (result.error?.status === 401) {
        localStorage.removeItem('accessToken');
        localStorage.removeItem('refreshToken');

        api.dispatch(logout());

        api.dispatch(authApi.util.resetApiState());
    }

    return result;
};

Проблема циклических запросов

После logout некоторые компоненты могут автоматически перезапускать запросы.

Например:

const { data } = useGetProfileQuery();

Компонент остаётся смонтированным и снова отправляет запрос.


Условный skip

const isAuthenticated = useSelector(
    state => state.auth.isAuthenticated
);

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

Очистка persist-хранилища

Если используется redux-persist, данные могут автоматически восстановиться после refresh страницы.


Пример persistStore

import { persistStore } from 'redux-persist';

export const persistor = persistStore(store);

Очистка persist

await persistor.purge();

Полный logout:

export const performLogout = async () => {
    localStorage.removeItem('accessToken');

    store.dispatch(logout());

    store.dispatch(api.util.resetApiState());

    await persistor.purge();
};

Logout и websocket-соединения

RTK Query часто используется вместе с websocket.

После logout соединение необходимо закрывать.


Пример websocket cleanup

socket.close();

Logout и polling

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

useGetNotificationsQuery(undefined, {
    pollingInterval: 5000,
});

После logout polling должен прекратиться.

resetApiState() автоматически очищает polling subscriptions.


Logout и invalidateTags

Некоторые разработчики пытаются очищать cache через invalidatesTags:

invalidatesTags: ['User']

Это не полноценный logout.

invalidateTags:

  • запускает refetch;
  • не очищает store;
  • не удаляет subscriptions;
  • не удаляет mutations cache.

Для logout нужен именно:

api.util.resetApiState()

Полный production logout

export const performLogout = async (
    dispatch,
    navigate
) => {
    try {
        await dispatch(
            authApi.endpoints.logout.initiate()
        ).unwrap();
    } catch (error) {
        console.error(error);
    }

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

    dispatch(logout());

    dispatch(authApi.util.resetApiState());

    navigate('/login');
};

Logout нескольких API slices

В больших приложениях может быть несколько API:

userApi
adminApi
chatApi
billingApi

Каждый хранит собственный cache.


Очистка нескольких API

dispatch(userApi.util.resetApiState());

dispatch(adminApi.util.resetApiState());

dispatch(chatApi.util.resetApiState());

dispatch(billingApi.util.resetApiState());

Централизованный logout service

export const logoutService = async ({
    dispatch,
    navigate,
}) => {
    localStorage.clear();

    dispatch(authSlice.actions.logout());

    dispatch(userApi.util.resetApiState());
    dispatch(chatApi.util.resetApiState());
    dispatch(adminApi.util.resetApiState());

    navigate('/login');
};

Race condition при logout

Возможна ситуация:

  1. logout начал выполняться;
  2. параллельно отправился запрос;
  3. запрос завершился после logout;
  4. старые данные попали обратно в store.

Защита от race condition

Обычно используют:

  • abort active requests;
  • resetApiState;
  • проверку auth state;
  • серверную инвалидизацию токенов.

Abort запросов

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

const promise = dispatch(
    api.endpoints.getProfile.initiate()
);

promise.abort();

Logout и SSR

При SSR нельзя использовать:

localStorage
window
document

Нужна проверка:

if (typeof window !== 'undefined') {
    localStorage.removeItem('accessToken');
}

Logout и refresh token rotation

Современные backend-системы часто используют rotation refresh token.

При logout сервер:

  • удаляет refresh token;
  • помечает token family как revoked;
  • блокирует повторное использование старых refresh token.

Frontend в этом случае обязан:

  • удалить все локальные токены;
  • очистить RTK Query cache;
  • очистить persist storage.

Logout и безопасность

Неполный logout создаёт серьёзные риски:

  • утечка приватных данных;
  • отображение cache предыдущего пользователя;
  • повторное использование access token;
  • автоматические повторные запросы;
  • работа websocket после logout;
  • восстановление persist state.

Рекомендуемая архитектура logout

На production-проектах logout обычно включает:

  1. logout request;
  2. удаление access token;
  3. удаление refresh token;
  4. очистку Redux state;
  5. resetApiState;
  6. остановку polling;
  7. закрытие websocket;
  8. очистку persist storage;
  9. redirect на login page;
  10. серверную инвалидизацию refresh token.

Минимально правильный logout

localStorage.removeItem('accessToken');

dispatch(logout());

dispatch(api.util.resetApiState());

Production-ready logout

export const performLogout = async ({
    dispatch,
    navigate,
    persistor,
    socket,
}) => {
    try {
        await dispatch(
            authApi.endpoints.logout.initiate()
        ).unwrap();
    } catch (error) {
        console.error(error);
    }

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

    socket?.close();

    dispatch(logout());

    dispatch(authApi.util.resetApiState());

    await persistor.purge();

    navigate('/login');
};