Как стать автором
Обновить
1466.15
Timeweb Cloud
То самое облако

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

Уровень сложностиСредний
Время на прочтение14 мин
Количество просмотров6.3K


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


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




Теги:
Хабы:
Всего голосов 13: ↑12 и ↓1+14
Комментарии2

Публикации

Информация

Сайт
timeweb.cloud
Дата регистрации
Дата основания
Численность
201–500 человек
Местоположение
Россия
Представитель
Timeweb Cloud