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


На днях прочитал эту интересную статью, посвященную различным вариантам хранения токена доступа (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!




Комментарии (2)


  1. savostin
    00.00.0000 00:00
    +1

    if ('serviceWorker' in navigator) {
    registerSW()
    }

    А если нет? Если Service Worker упал, отключен? Все перестанет работать?


    1. aio350 Автор
      00.00.0000 00:00

      Хороший вопрос. Да, в таком виде не будет работать. Для подстраховки можно продублировать логику работы с токеном на клиенте. Тогда в случае неработающего СВ мы возвращаемся к классической схеме с хранением токена в памяти на клиенте.