Хранение токена доступа в сервис-воркере

от автора

Привет, друзья!

На днях прочитал эту интересную статью, посвященную различным вариантам хранения токена доступа (access token) на клиенте. Мое внимание привлек вариант с использованием сервис-воркера (service worker) (см. «Подход 4. Использование service worker»), поскольку я даже не задумывался о таком способе применения этого интерфейса.

СВ — это посредник между клиентом и сервером (своего рода прокси-сервер), который позволяет перехватывать запросы и ответы и модифицировать их тем или иным образом. Он запускается в отдельном контексте, работает в отдельном потоке и не имеет доступа к DOM. Клиент также не имеет доступа к СВ и хранимым в нем данным. Как правило, СВ используется для обеспечения работы приложения в режиме офлайн посредством кэширования критически важных для работы приложения ресурсов.

В этой статье я покажу, как реализовать простой сервис аутентификации на основе JSONWebToken и HTTP Cookie с хранением токена доступа в сервис-воркере.

Для тех, кого интересует только код, вот ссылка на соответствующий репозиторий.

Интересно? Тогда прошу под кат.

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

# клонируем репозиторий git init my-project cd my-project git remote add origin https://github.com/harryheman/Blog-Posts/tree/master/access-token-service-worker git config core.sparseCheckout true git sparse-checkout set access-token-service-worker git pull origin master  # переходим в директорию проекта cd access-token-service-worker  # устанавливаем зависимости yarn # или npm i  # генерируем публичный и приватный ключи для ассиметричного шифрования/декодирования токена идентификации yarn gen # или npm run gen  # создаем файл .env и копируем в него содержимое файла .env.example # значения переменных можно не менять move .env.example .env  # запускаем сервер для разработки yarn dev # или npm run dev

Обратите внимание: может потребоваться выполнить миграцию с помощью команды npx prisma migrate dev --name init, а также сгенерировать клиента Prisma с помощью команды npx prisma generate.

Для создания шаблона приложения использовался Yarn и Create Next App:

yarn create next-app access-token-service-worker --typescript

Структура проекта:

- prisma - схема, модели и миграции prisma - public   - sw.js - логика СВ - src   - components - компоненты     - CreateTodoForm.tsx - форма для создания задачи     - Footer.tsx - подвал     - Header.tsx - шапка     - TodoList.tsx - список задач   - pages - страницы     - api - "сервер"       - auth - роуты аутентификации и авторизации         - login.ts - роут авторизации         - logout.ts - выхода из системы         - register.ts - регистрации         - user.ts - получения данных пользователя       - todo.ts - создания и удаления задач     - _app.tsx     - _document.tsx     - index.tsx - главная страница     - login.tsx - страница авторизации     - register.tsx - страница регистрации   - styles - стили   - utils - утилиты     - authGuard.ts - посредник для проверки доступа к защищенным роутам     - cookies.ts - посредник для работы с куки     - formToObj.ts - утилита для преобразования данных формы в объект     - generateKeys.js - утилита для генерации ключей     - prisma.ts - клиент prisma     - registerSW.ts - функция регистрации СВ     - swr.ts - кастомные хуки swr   - types.ts - .env - переменные среды окружения - environment.d.ts - их типы - ... - другие файлы

Функционал приложения является очень простым: после регистрации/авторизации пользователь получает возможность создавать/удалять задачи. Данные о пользователях и задачах хранятся в реляционной базе данных SQLite. Взаимодействие с БД осуществляется с помощью объектно-реляционного отображения Prisma.


Клиент может отправлять на сервер следующие запросы:

  • POST /api/auth/register — запрос на регистрацию пользователя (запись данных пользователя в БД). Тело запроса:
    • email: string — адрес электронной почты;
    • password: string — пароль;
  • POST /api/auth/login — запрос на авторизацию (вход в систему) пользователя. Тело запроса такое же;
  • GET /api/auth/logout — запрос на выход пользователя из системы;
  • GET /api/auth/user — запрос на получение данных зарегистрированного пользователя;
  • GET /api/todo — получение задач пользователя;
  • POST /api/todo — создание задачи. Тело запроса:
    • title: string — название задачи;
    • content: string — описание задачи;
  • DELETE /api/todo?id=<todo-id> — запрос на удаление задачи. Строка запроса (query string) должна содержать id задачи.

Все роуты /api/auth, кроме /api/auth/user, являются открытыми (общедоступными или публичными). Все роуты /api/todo являются закрытыми (частными или приватными).

Все роуты /api/auth, кроме /api/auth/logout, возвращают данные пользователя и токен доступа. СВ перехватывает эти ответы, извлекает из тела ответа токен, записывает его в глобальную переменную и передает данные пользователя клиенту.

Все роуты /api/todo требуют наличия в объекте запроса заголовка авторизации с токеном доступа — Authorization: Bearer <accessToken>. Клиент отправляет запрос без токена. СВ перехватывает запрос и добавляет в него токен из глобальной переменной.

Роут /api/auth/user требует наличия куки с токеном идентификации. Куки хранится в браузере пользователя и прикрепляется к соответствующему запросу при его выполнении.

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


Рассмотрим процесс регистрации пользователя.

  1. Пользователь заполняет форму и отправляет данные на сервер (pages/register.tsx):

import formToObj from '@/utils/formToObj' import { useUser } from '@/utils/swr' import { User } from '@prisma/client' import { useRouter } from 'next/router' import { useState } from 'react'  export default function Register() {   const router = useRouter()   const { mutateUser } = useUser()    const [errors, setErrors] = useState<{     email?: boolean   }>({})    const onSubmit: React.FormEventHandler = async (e) => {     e.preventDefault()      // получаем данные формы в виде объекта     const formData = formToObj<Pick<User, 'email' | 'password'>>(       e.target as HTMLFormElement     )      try {       // выполняем запрос       const res = await fetch('/api/auth/register', {         method: 'POST',         body: JSON.stringify(formData)       })        if (!res.ok) {         // пользователь уже зарегистрирован         if (res.status === 409) {           return setErrors({ email: true })         }         throw res       }        const userData = (await res.json()) as Pick<User, 'id' | 'email'>       // инвалидируем кэш - обновляем информацию о пользователе       mutateUser(userData)        // выполняем перенаправление на главную страницу       router.push('/')     } catch (e) {       console.error(e)     }   }    const onInput = () => {     setErrors({})   }    return (     <>       <form onSubmit={onSubmit} onInput={onInput}>         <label>           Email:{' '}           <input             type='email'             name='email'             pattern='[^@\s]+@[^@\s]+\.[^@\s]+'             required           />           {errors.email && (             <p style={{ color: 'red' }}>               <small>Email already in use</small>             </p>           )}         </label>         <label>           Password:{' '}           <input type='password' name='password' minLength={6} required />{' '}         </label>         <button>Register</button>       </form>     </>   ) }

  1. Сервер записывает данные пользователя в БД, генерирует токен идентификации и записывает его в куки, а также создает токен доступа и возвращает данные пользователя и токен доступа (pages/api/auth/register.ts):

import { NextApiHandlerWithCookie } from '@/types' import cookies from '@/utils/cookies' import prisma from '@/utils/prisma' import { User } from '@prisma/client' import argon2 from 'argon2' import { readFileSync } from 'fs' import jwt from 'jsonwebtoken'  // читаем содержимое закрытого ключа const PRIVATE_KEY = readFileSync('./keys/private_key.pem', 'utf8')  const registerHandler: NextApiHandlerWithCookie = async (req, res) => {   // извлекаем данные пользователя из тела запроса   const data: Pick<User, 'email' | 'password'> = JSON.parse(req.body)    try {     // получаем данные пользователя     const existingUser = await prisma.user.findUnique({       where: { email: data.email }     })      // если данные имеются     // значит, пользователь уже зарегистрирован     if (existingUser) {       return res.status(409).json({ message: 'Email already in use' })     }      // хэшируем пароль     const passwordHash = await argon2.hash(data.password)     // заменяем оригинальный пароль на хэш     data.password = passwordHash      // создаем и получаем пользователя     const newUser = await prisma.user.create({       data,       // без пароля       select: {         id: true,         email: true       }     })      // генерируем токен идентификации с помощью закрытого ключа     const idToken = await jwt.sign({ userId: newUser.id }, PRIVATE_KEY, {       // срок действия - 7 дней       expiresIn: '7d',       algorithm: 'RS256'     })      // генерируем токен доступа с помощью секретного значения из переменной среды окружения     const accessToken = await jwt.sign(       { userId: newUser.id },       process.env.ACCESS_TOKEN_SECRET,       {         // срок действия - 1 час         expiresIn: '1h'       }     )      // записываем токен идентификации в куки,     // которая недоступна на клиенте     res.cookie({       name: process.env.COOKIE_NAME,       value: idToken,       options: {         // обязательно         httpOnly: true,         secure: true,         // настоятельно рекомендуется         sameSite: true,         maxAge: 1000 * 60 * 60 * 24 * 7,         path: '/'       }     })      // возвращаем данные пользователя и токен доступа     res.status(200).json({       user: newUser,       accessToken     })   } catch (e) {     console.log(e)     res.status(500).json({ message: 'User register error' })   } }  export default cookies(registerHandler)

Процесс авторизации выглядит похожим образом (см. pages/login.tsx и pages/api/auth/login.ts).

Что касается выхода пользователя из системы, то для реализации этого функционала достаточно отправить запрос на клиенте (components/Header.tsx) и удалить куки на сервере (pages/api/auth/logout.ts).


Рассмотрим процесс получения данных пользователя.

  1. При запуске приложения на главной странице (pages/index.tsx) выполняется запрос к /api/auth/user:

import CreateTodoForm from '@/components/CreateTodoForm' import TodoList from '@/components/TodoList' import { useUser } from '@/utils/swr'  export default function Home() {   // запрашиваем данные пользователя   const { user } = useUser()    return (     <>       <h1>Welcome, {user ? user.email : 'stranger'}</h1>       <CreateTodoForm />       <TodoList />     </>   ) }

Получение данных пользователя и его задач реализовано с помощью кастомных хуков SWR (utils/swr.ts):

import type { Todo, User } from '@prisma/client' import useSWRImmutable from 'swr/immutable'  function fetcher<T>(   input: RequestInfo | URL,   init?: RequestInit | undefined ): Promise<T> {   return fetch(input, init).then((res) => res.json()) }  // хук для получения данных пользователя export function useUser() {   const { data, error, mutate } = useSWRImmutable<Pick<User, 'id' | 'email'>>(     '/api/auth/user',     // обратите внимание, что мы указываем браузеру прикрепить к запросу куки     // с помощью настройки `credentials: 'include'`     (url) => fetcher(url, { credentials: 'include' }),     {       onErrorRetry(err, key, config, revalidate, revalidateOpts) {         return false       }     }   )    if (error) {     console.log(error)   }    return {     user: data?.email ? data : undefined,     mutateUser: mutate   } }  // хук для получения задач пользователя export function useTodos(shouldFetch: boolean) {   const { data, error, mutate } = useSWRImmutable<     Pick<Todo, 'id' | 'title' | 'content'>[]     // данный запрос выполняется только при наличии данных пользователя,     // индикатором чего является `shouldFetch`   >(shouldFetch ? '/api/todo' : null, (url) => fetcher(url), {     onErrorRetry(err, key, config, revalidate, revalidateOpts) {       return false     }   })    if (error) {     console.log(error)   }    return {     todos: Array.isArray(data) ? data : [],     mutateTodos: mutate   } }

  1. Сервер извлекает id пользователя из куки, получает данные пользователя из БД, генерирует токен доступа и возвращает данные пользователя и токен доступа (pages/api/auth/user.ts):

import prisma from '@/utils/prisma' import { readFileSync } from 'fs' import jwt from 'jsonwebtoken' import { NextApiHandler } from 'next'  // читаем содержимое открытого ключа const PUBLIC_KEY = readFileSync('./keys/public_key.pem', 'utf8')  const userHandler: NextApiHandler = async (req, res) => {   // извлекаем токен идентификации из куки   const idToken = req.cookies[process.env.COOKIE_NAME]   // если токен отсутствует   if (!idToken) {     return res.status(401).json({ message: 'ID token must be provided' })   }    try {     // декодируем токен с помощью открытого ключа     const decodedToken = (await jwt.verify(idToken, PUBLIC_KEY)) as unknown as {       userId: string     }      // если полезная нагрузка отсутствует     if (!decodedToken || !decodedToken.userId) {       return res.status(403).json({ message: 'Invalid token' })     }      // получаем данные пользователя на основе id из куки     const user = await prisma.user.findUnique({       where: { id: decodedToken.userId },       // без пароля       select: {         id: true,         email: true       }     })      // если данные отсутствуют     if (!user) {       return res.status(404).json({ message: 'User not found' })     }      // генерируем токен доступа     const accessToken = await jwt.sign(       { userId: user.id },       process.env.ACCESS_TOKEN_SECRET,       {         expiresIn: '1h'       }     )      // возвращаем данные пользователя и токен доступа     res.status(200).json({ user, accessToken })   } catch (e) {     console.log(e)     res.status(500).json({ message: 'User get error' })   } }  export default userHandler

Как видим, все роуты /api/auth, кроме /api/auth/logout, возвращают токен идентификации. Он не должен дойти до клиента! 🙂


Рассмотрим процесс создания и удаления задач.

Форма для создания задачи и список задач рендерятся на главной странице (pages/index.tsx).

Форма выглядит следующим образом (components/CreateTodoForm.tsx):

import formToObj from '@/utils/formToObj' import { useTodos, useUser } from '@/utils/swr' import { Todo } from '@prisma/client' import { useRef } from 'react'  export default function CreateTodoForm() {   const { user } = useUser()   const { todos, mutateTodos } = useTodos(Boolean(user))   const formRef = useRef<HTMLFormElement | null>(null)    if (!user) return null    const onSubmit: React.FormEventHandler = async (e) => {     e.preventDefault()     // получаем данные формы в виде объекта     const formData = formToObj<Pick<Todo, 'title' | 'content'>>(       e.target as HTMLFormElement     )      try {       // выполняем запрос на создание задачи       const res = await fetch('/api/todo', {         method: 'POST',         body: JSON.stringify(formData)       })       if (!res.ok) throw res       const newTodo = (await res.json()) as Pick<         Todo,         'id' | 'title' | 'content' | 'userId'       >       // инвалидируем кэш - обновляем список задач       mutateTodos([...todos, newTodo])        // сбрасываем форму       if (formRef.current) {         formRef.current.reset()       }     } catch (e) {       console.log(e)     }   }    return (     <div>       <h2>New Todo</h2>       <form onSubmit={onSubmit} ref={formRef}>         <label>           Title: <input type='text' name='title' required />         </label>         <label>           Content:{' '}           <textarea name='content' cols={30} rows={5} required></textarea>         </label>         <button>Create</button>       </form>     </div>   ) }

Список задач (components/TodoList.tsx):

import { useTodos, useUser } from '@/utils/swr'  export default function TodoList() {   const { user } = useUser()   const { todos, mutateTodos } = useTodos(Boolean(user))    if (!user || !todos.length) return null    const onClick = async (id: string) => {     try {       // выполняем запрос на удаление задачи       const res = await fetch(`/api/todo?id=${id}`, {         method: 'DELETE'       })       if (!res.ok) throw res       const newTodos = todos.filter((todo) => todo.id !== id)       // инвалидируем кэш       mutateTodos(newTodos)     } catch (e) {       console.log(e)     }   }    return (     <div>       <h2>Todo List</h2>       <ul>         {todos.map((todo) => (           <li key={todo.id}>             <p>               <b>{todo.title}</b>             </p>             <p>{todo.content}</p>             <button onClick={() => onClick(todo.id)}>X</button>           </li>         ))}       </ul>     </div>   ) }

Ничего особенного.

Вот как выглядит обработчик этих запросов (pages/api/todo.ts):

import { NextApiRequestWithUserId } from '@/types' import authGuard from '@/utils/authGuard' import prisma from '@/utils/prisma' import { Todo } from '@prisma/client' import { NextApiResponse } from 'next' import nextConnect from 'next-connect'  const todoHandler = nextConnect<NextApiRequestWithUserId, NextApiResponse>()  // роут для получения задач пользователя todoHandler.get(async (req, res) => {   try {     // получаем задачи из БД     const todos = await prisma.todo.findMany({       where: {         userId: req.userId       }     })      // возвращаем их     res.status(200).json(todos)   } catch (e) {     console.log(e)     res.status(500).json({ message: 'Todos get error' })   } })  // роут для создания задачи todoHandler.post(async (req, res) => {   // извлекаем данные задачи из тела запроса   const data: Pick<Todo, 'title' | 'content' | 'userId'> = JSON.parse(req.body)   // добавляем в данные id пользователя   data.userId = req.userId    try {     // создаем задачу     const todo = await prisma.todo.create({       data     })     // возвращаем ее     res.status(200).json(todo)   } catch (e) {     console.error(e)     res.status(500).json({ message: 'Todo create error' })   } })  // роут для удаления задачи todoHandler.delete(async (req, res) => {   // извлекаем id задачи из строки запроса   const id = req.query.id as string    try {     // удаляем задачу     const todo = await prisma.todo.delete({       where: {         id_userId: {           id,           userId: req.userId         }       }     })     // возвращаем ее     res.status(200).json(todo)   } catch (e) {     console.error(e)     res.status(500).json({ message: 'Todo remove error' })   } })  // все роуты являются защищенными export default authGuard(todoHandler)

Защита этих роутов реализована с помощью посредника utils/authGuard.ts:

import jwt from 'jsonwebtoken' import { AuthGuardMiddleware } from '../types'  const authGuard: AuthGuardMiddleware = (handler) => async (req, res) => {   // извлекаем токен доступа из заголовка авторизации - `Authorization: 'Bearer <accessToken>'`   const accessToken = req.headers.authorization?.split(' ')[1]    // если токен отсутствует   if (!accessToken) {     return res.status(403).json({ message: 'Access token must be provided' })   }    try {     // декодируем токен     const decodedToken = (await jwt.verify(       accessToken,       process.env.ACCESS_TOKEN_SECRET     )) as unknown as {       userId: string     }      // если полезная нагрузка отсутствует     if (!decodedToken || !decodedToken.userId) {       return res.status(403).json({ message: 'Invalid token' })     }      // записываем id пользователя в объект запроса     req.userId = decodedToken.userId   } catch (e: any) {     console.log(e)     // если истек срок действия токена     if (e.name === 'TokenExpiredError') {       // сервер сообщает о том, что он - чайник :)       return res.status(418).json({ message: 'Access token has been expired' })     }     return res.status(403).json({ message: 'Invalid token' })   }    // передаем управление следующему обработчику   return handler(req, res) }  export default authGuard

Как видим, для доступа к роутам /api/todo требуется наличие заголовка авторизации в объекте запроса, которого у клиента нет.


Перейдем к самому интересному — СВ.

Регистрируем его при запуске приложения (pages/_app.tsx):

import Footer from '@/components/Footer' import Header from '@/components/Header' import '@/styles/globals.css' import registerSW from '@/utils/registerSW' import type { AppProps } from 'next/app' import { useEffect } from 'react'  export default function App({ Component, pageProps }: AppProps) {   // регистрируем СВ при запуске приложения   useEffect(() => {     if ('serviceWorker' in navigator) {       registerSW()     }   }, [])    return (     <>       <Header />       <main>         <Component {...pageProps} />       </main>       <Footer />     </>   ) }

Функция регистрации СВ выглядит следующим образом (utils/registerSW.ts):

export default async function registerSW() {   try {     const reg = await navigator.serviceWorker.register('/sw.js')     console.log(`Registration scope: ${reg.scope}`)   } catch (e) {     console.log(e)   } }

Логика СВ реализована в файле public/sw.js:

// установка и активация СВ нас не интересуют // self.addEventListener('install', (e) => {}) // self.addEventListener('activate', (e) => {})  // глобальная переменная для хранения токена доступа let accessToken  // обработка запросов self.addEventListener('fetch', async (e) => {   // объект запроса   const { request } = e   // адрес запроса   const { url } = request    // если выполняется запрос к нашему серверу   if (url.startsWith(self.location.origin) && url.includes('api')) {     // регистрируем запрос на выход из системы     if (url.includes('logout')) {       // просто удаляем токен       accessToken = null        // перехватываем запрос на регистрацию/авторизацию     } else if (url.includes('auth')) {       e.respondWith(         (async () => {           // выполняем запрос           const res = await fetch(request)           // если возникла ошибка           if (!res.ok) {             // просто возвращаем ответ             return res           }           // обратите внимание, что мы клонируем объект ответа           const data = await res.clone().json()           // обновляем значение токена           accessToken = data.accessToken           // извлекаем дополнительную информацию об ответе           const { headers, status, statusText } = res.clone()           // возвращаем ответ без токена (!) и дополнительную информацию           return new Response(JSON.stringify(data.user), {             headers,             status,             statusText           })         })()       )     }      // перехватываем запрос на создание/удаление задачи     if (url.includes('todo')) {       e.respondWith(         (async () => {           // выполняем запрос           // обратите внимание, что мы клонируем объект запроса           // здесь можно выполнять дополнительную проверку того,           // что запрос выполняется нашим клиентом, например,           // с помощью заголовка `Referer`           res = await fetch(request.clone(), {             headers: {               // добавляем заголовок авторизации               Authorization: `Bearer ${accessToken}`             }           })            // если срок действия токена истек           if (res.status === 418) {             // получаем новый токен             res = await fetch(`${self.location.origin}/api/auth/user`, {               // прикрепляем к запросу куки               credentials: 'include'             })             const data = await res.json()             // обновляем значение токена             accessToken = data.accessToken              // повторяем оригинальный запрос с новым токеном             res = await fetch(request.clone(), {               headers: {                 Authorization: `Bearer ${accessToken}`               }             })           }            // возвращаем ответ           return res         })()       )     }   } })

Как видим, СВ перехватывает две группы запросов:

  • /api/auth/* — из ответа на эти запросы СВ извлекает токен доступа и передает клиенту только данные пользователя;
  • /api/todo/* — к этим запросам СВ добавляет заголовок авторизации с токеном доступа и продлевает срок действия токена при необходимости.

Пожалуй, это все, чем я хотел поделиться с вами в этой статье.

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

Благодарю за внимание и happy coding!



ссылка на оригинал статьи https://habr.com/ru/company/timeweb/blog/718320/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *