Перейти к содержанию

Авторизация

Начнём работу с настройки взаимодействия с бэкендом: нужно реализовать получение и сохранение JWT, чтобы затем использовать их для доступа к закрытым эндпоинтам. Для этого напишем хранилище Pinia и обёртку над fetch:

stores/auth.store.js
import { defineStore } from 'pinia';

import { fetchWrapper, router } from '@/helpers';

const baseUrl = `${import.meta.env.VITE_API_URL}`;

export const useAuthStore = defineStore({
  id: 'auth',
  state: () => ({
    access: JSON.parse(localStorage.getItem('access')),
    access_expiration: JSON.parse(localStorage.getItem('access_expiration')),
    refresh: JSON.parse(localStorage.getItem('refresh')),
    refresh_expiration: JSON.parse(localStorage.getItem('refresh_expiration')),
  }),
  actions: {
    async login(code) {
      const data = await fetchWrapper.post(`${baseUrl}/oidc/login/`, { code });
      this.access = data.access;
      this.access_expiration = data.access_expiration;
      this.refresh = data.refresh;
      this.refresh_expiration = data.refresh_expiration;

      localStorage.setItem('access', JSON.stringify(data.access));
      localStorage.setItem('access_expiration', JSON.stringify(data.access_expiration));
      localStorage.setItem('refresh', JSON.stringify(data.refresh));
      localStorage.setItem('refresh_expiration', JSON.stringify(data.refresh_expiration));

      await router.push('/survey');
    },
    async logout() {
      this.access = null;
      this.access_expiration = null;
      this.refresh = null;
      this.refresh_expiration = null;

      localStorage.removeItem('access');
      localStorage.removeItem('access_expiration');
      localStorage.removeItem('refresh');
      localStorage.removeItem('refresh_expiration');

      await router.push('/login');
    },
    updateAccessToken(access, access_expiration) {
      this.access = access;
      this.access_expiration = access_expiration;

      localStorage.setItem('access', JSON.stringify(access));
      localStorage.setItem('access_expiration', JSON.stringify(access_expiration));
    }
  }
});

Объявим в хранилище четыре переменные: access, access_expiration, refresh, refresh_expiration. Помимо access токена, нам нужно хранить refresh токен, чтобы не просить пользователя регулярно авторизовываться. И чтобы понимать, когда нужно обновить токен, будем хранить их время жизни.

Также объявим несколько "действий", чтобы эти токены получить и сохранить:

В функции login с помощью нашей обёртки на fetch отправим POST запрос на эндпоинт авторизации вместе с code, полученным при вызове функции, в теле запроса.
Сохраним полученные данные как в самом хранилище Pinia, так и в локальном хранилище пользователя, а затем с помощью роутера переведём пользователя на маршрут /survey, где будет находиться наш опрос.

В функции logout очистим хранилище Pinia и локальное хранилище пользователя, а затем переведём пользователя на страницу авторизации.

В функции updateAccessToken обновим значения токенов на основе данных, полученных из аргументов функции.

helpers/fetch-wrapper.js
import { useAuthStore } from '@/stores';

const baseUrl = `${import.meta.env.VITE_API_URL}`;

export const fetchWrapper = {
  get: request('GET'),
  post: request('POST'),
  put: request('PUT'),
  delete: request('DELETE')
};

function request(method) {
  return async (url, body) => {
    const headers = await authHeader(url);
    const requestOptions = {
      method,
      headers,
    };
    if (body) {
      requestOptions.headers['Content-Type'] = 'application/json';
      requestOptions.body = JSON.stringify(body);
    }
    return fetch(url, requestOptions).then(handleResponse);
  }
}

async function refreshToken(refresh) {
  const authStore = useAuthStore();

  const response = await fetch(`${baseUrl}/oidc/token/refresh/`, {
    method: 'POST',
    body: JSON.stringify({ refresh: refresh }),
    headers: { 'Content-Type': 'application/json' },
  });

  if (!response.ok) {
    await authStore.logout();
  }

  const data = await response.json();

  if (data.access) {
    authStore.updateAccessToken(data.access, data.access_expiration);
  }

  return data.access;
}

async function checkAndRefreshToken() {
  const authStore = useAuthStore();
  const { access, access_expiration, refresh } = authStore;

  const expirationDate = new Date(access_expiration);
  const now = new Date();

  const isTokenExpired = now > expirationDate;

  if (isTokenExpired) {
    return refreshToken(refresh);
  }

  return access;
}

async function authHeader(url) {
  const authStore = useAuthStore();
  const isLoggedIn = !!authStore.access;
  const isApiUrl = url.startsWith(import.meta.env.VITE_API_URL);

  if (isLoggedIn && isApiUrl) {
    const access = await checkAndRefreshToken();
    return { Authorization: `Bearer ${access}` };
  } else {
    return {};
  }
}

async function handleResponse(response) {
  return response.text().then(async text => {
    const data = text && JSON.parse(text);

    if (!response.ok) {
      const authStore = useAuthStore();
      if ([401, 403].includes(response.status) && authStore.access) {
        await authStore.logout();
      }

      const error = (data && data.message) || response.statusText;
      return Promise.reject(error);
    }

    return data;
  });
}

Вызывать обёртку будем с помощью словаря, где ключи - названием методов, а значения - функции с одноимённым аргументом.

request - основная функция. На вход функция получает URL адрес, на который нужно отправить запрос и тело запроса, если оно необходимо. В самой функции мы получаем заголовок с JWT токеном с помощью метода authHeader нашей обёртки, конвертируем тело в JSON, а затем вызываем оригинальный метод fetch с этими параметрами. Полученный ответ обрабатываем с помощью функции handleResponse.

В функции refreshToken отправляем POST запрос на эндпоинт обновления токена с refresh токеном в теле запроса.
Если статус ответа - не 200, то вызываем функцию logout нашего хранилища.

В ином случае парсим ответ и вызываем функцию updateAccessToken хранилища, чтобы обновить токены. Также возвращаем access токен.

В функции checkAndRefreshToken получим из хранилища access токен, его время жизни и refresh токен. Проверим, живой ли ещё access токен, и, если нет, - вызовем ранее созданную функцию refreshToken. Если же живой, то просто вернём его.

В функции authHeader проверим, авторизован ли пользователь (проверив наличие access токена) и является ли полученный URL адресом нашего бэкенда. Если так, то сформируем заголовок и вернём его, в ином случае ничего не вернём.

В функции handleResponse обработаем полученный ответ: распарсим его и вернём, если полученный статус не входит в статусы ошибки в авторизации (в таком случае нужно разлогинить пользователя).