Привет, друзья!
На днях прочитал эту интересную статью, посвященную различным вариантам хранения токена доступа (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 требует наличия куки с токеном идентификации. Куки хранится в браузере пользователя и прикрепляется к соответствующему запросу при его выполнении.
Таким образом, клиент ничего не знает ни о токене доступа, который хранится в СВ, ни о токене аутентификации, который хранится в куки, доступной только серверу.
Рассмотрим процесс регистрации пользователя.
- Пользователь заполняет форму и отправляет данные на сервер (
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> </> ) }
- Сервер записывает данные пользователя в БД, генерирует токен идентификации и записывает его в куки, а также создает токен доступа и возвращает данные пользователя и токен доступа (
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).
Рассмотрим процесс получения данных пользователя.
- При запуске приложения на главной странице (
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 } }
- Сервер извлекает 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/

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