Хабрахабр

[Перевод] Руководство по аутентификации в Node.js без passport.js и сторонних сервисов

Автор статьи, перевод которой мы сегодня публикуем, говорит, что сейчас можно наблюдать рост популярности таких сервисов аутентификации, как Google Firebase Authentication, AWS Cognito и Auth0. Индустриальным стандартом стали универсальные решения наподобие passport.js. Но, учитывая сложившуюся ситуацию, обычным явлением стало то, что разработчики никогда в полной мере не понимают того, какие именно механизмы принимают участие в работе систем аутентификации.

В нём на практическом примере рассмотрена организация регистрации пользователей в системе и организация их входа в систему. Этот материал посвящён проблеме организации аутентификации пользователей в среде Node.js. Здесь будут подняты такие вопросы, как работа с технологией JWT и имперсонация пользователей.

Этот репозиторий вы можете использовать в качестве основы для собственных экспериментов.
Кроме того, обратите внимание на этот GitHub-репозиторий, в котором содержится код Node.js-проекта, некоторые примеры из которого приведены в этой статье.

Требования к проекту

Вот требования к проекту, которым мы будем здесь заниматься:

  • Наличие базы данных, в которой будет храниться адрес электронной почты пользователя и его пароль, либо — clientId и clientSecret, либо — нечто вроде комбинации из приватного и публичного ключей.
  • Использование сильного и эффективного криптографического алгоритма для шифрования пароля.

В тот момент, когда я пишу этот материал, я считаю, что лучшим из существующих криптографических алгоритмов является Argon2. Я прошу вас не использовать простые криптографические алгоритмы вроде SHA256, SHA512 или MD5.

Кроме того, предлагаю вам взглянуть на этот замечательный материал, в котором вы можете найти подробности о выборе алгоритма для хэширования паролей.

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

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

import * as argon2 from 'argon2'; class AuthService ); return { // Никогда не передавайте куда-либо пароль!!! user: { email: userRecord.email, name: userRecord.name, }, }

Данные учётной записи пользователя должны выглядеть примерно так, как показано ниже.

Данные пользователя, полученные из MongoDB с помощью Robo3T

Вход пользователей в систему

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

Вход пользователя в систему

Вот что происходит при входе пользователя в систему:

  • Клиент отправляет серверу комбинацию, состоящую из публичного идентификатора и приватного ключа пользователя. Обычно это — адрес электронной почты и пароль.
  • Сервер ищет пользователя в базе данных по адресу электронной почты.
  • Если пользователь существует в базе данных — сервер хэширует отправленный ему пароль и сравнивает то, что получилось, с хэшем пароля, сохранённым в базе данных.
  • Если проверка оказывается успешной — сервер генерирует так называемый токен или маркер аутентификации — JSON Web Token (JWT). 

JWT — это временный ключ. Клиент должен отправлять этот ключ серверу с каждым запросом к аутентифицированной конечной точке.

import * as argon2 from 'argon2'; class AuthService { public async Login(email, password): Promise<any> { const userRecord = await UserModel.findOne({ email }); if (!userRecord) { throw new Error('User not found') } else { const correctPassword = await argon2.verify(userRecord.password, password); if (!correctPassword) { throw new Error('Incorrect password') return { user: { email: userRecord.email, name: userRecord.name, }, token: this.generateJWT(userRecord), }

Верификация пароля производится с использованием библиотеки argon2. Это делается для предотвращения так называемых «атак по времени». При выполнении такой атаки злоумышленник пытается взломать пароль методом грубой силы, основываясь на анализе того, сколько времени нужно серверу на формирование ответа.

Теперь давайте поговорим о том, как генерировать JWT.

Что такое JWT?

JSON Web Token (JWT) — это закодированный в строковой форме JSON-объект. Токены можно воспринимать как замену куки-файлов, имеющую несколько преимуществ перед ними.

Это — заголовок (header), полезная нагрузка (payload) и подпись (signature). Токен состоит из трёх частей. На следующем рисунке показан его внешний вид.

JWT

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

Подобные метаданные, могут описывать роль пользователя, его профиль, время действия токена, и так далее. Это может быть полезным для передачи, например метаданных, закодированных внутри токена. Они могут быть предназначены для использования во фронтенд-приложениях.

Вот как может выглядеть декодированный токен.

Декодированный токен

Генерирование JWT в Node.js

Давайте создадим функцию generateToken, которая нужна нам для завершения работы над сервисом аутентификации пользователей.

Найти эту библиотеку можно в npm. Создавать JWT можно с помощью библиотеки jsonwebtoken.

import * as jwt from 'jsonwebtoken'
class AuthService { private generateToken(user) { const data = { _id: user._id, name: user.name, email: user.email }; const signature = 'MySuP3R_z3kr3t'; const expiration = '6h'; return jwt.sign({ data, }, signature, { expiresIn: expiration }); }

Самое важное здесь — это закодированные данные. Не отправляйте в токенах секретную информацию о пользователях.

Очень важно следить за тем, чтобы подпись не попала в чужие руки. Подпись (здесь — константа signature) — это секретные данные, которые используются для генерирования JWT. Если подпись окажется скомпрометированной — атакующий сможет генерировать токены от имени пользователей и красть их сессии.

Защита конечных точек и проверка JWT

Теперь клиентскому коду нужно отправлять JWT в каждом запросе к защищённой конечной точке.

Обычно их включают в заголовок Authorization. Рекомендуется включать JWT в заголовки запросов.

Заголовок Authorization

Поместим этот код в файл isAuth.ts: Теперь, на сервере, нужно создать код, представляющий собой промежуточное ПО для маршрутов express.

import * as jwt from 'express-jwt'; // Мы исходим из предположения о том, что JWT приходит на сервер в заголовке Authorization, но токен может быть передан и в req.body, и в параметре запроса, поэтому вам нужно выбрать тот вариант, который подходит вам лучше всего. const getTokenFromHeader = (req) => { if (req.headers.authorization && req.headers.authorization.split(' ')[0] === 'Bearer') { return req.headers.authorization.split(' ')[1]; } export default jwt({ secret: 'MySuP3R_z3kr3t', // Тут должно быть то же самое, что использовалось при подписывании JWT userProperty: 'token', // Здесь следующее промежуточное ПО сможет найти то, что было закодировано в services/auth:generateToken -> 'req.token' getToken: getTokenFromHeader, // Функция для получения токена аутентификации из запроса
})

Полезно иметь возможность получать полные сведения об учётной записи пользователя из базы данных и присоединять их к запросу. В нашем случае эта возможность реализуется средствами промежуточного ПО из файла attachCurrentUser.ts. Вот его упрощённый код:

export default (req, res, next) => { const decodedTokenData = req.tokenData; const userRecord = await UserModel.findOne({ _id: decodedTokenData._id }) req.currentUser = userRecord; if(!userRecord) { return res.status(401).end('User not found') } else { return next(); }

После реализации этого механизма маршруты смогут получать сведения о пользователе, который выполняет запрос:

import isAuth from '../middlewares/isAuth'; import attachCurrentUser from '../middlewares/attachCurrentUser'; import ItemsModel from '../models/items'; export default (app) => { app.get('/inventory/personal-items', isAuth, attachCurrentUser, (req, res) => { const user = req.currentUser; const userItems = await ItemsModel.find({ owner: user._id }); return res.json(userItems).status(200); })

Теперь маршрут inventory/personal-items защищён. Для доступа к нему пользователь должен иметь валидный JWT. Маршрут, кроме того, может использовать сведения о пользователе для поиска в базе данных необходимых ему сведений.

Почему токены защищены от злоумышленников?

Почитав об использовании JWT, вы можете задаться следующим вопросом: «Если данные JWT могут быть декодированы на стороне клиента — можно ли так обработать токен, чтобы изменить идентификатор пользователя или другие данные?».

Однако нельзя «переделать» этот токен, не имея той подписи, тех секретных данных, которые были использованы при подписывании JWT на сервере. Декодирование токена — операция очень простая.

Именно поэтому так важна защита этих секретных данных.

За проверку отвечает библиотека express-jwt. Наш сервер проверяет подпись в промежуточном ПО isAuth.

Теперь, после того, как мы разобрались с тем, как работает технология JWT, поговорим о некоторых интересных дополнительных возможностях, которые она нам даёт.

Как имперсонировать пользователя?

Имперсонация пользователей — это техника, используемая для входа в систему под видом некоего конкретного пользователя без знания его пароля.

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

Для этого достаточно сгенерировать JWT с правильной подписью и с необходимыми метаданными, описывающими пользователя. Работать с приложением от имени пользователя можно и не зная его пароля.

Этой конечной точкой сможет пользоваться только супер-администратор системы. Создадим конечную точку, которая может генерировать токены для входа в систему под видом конкретных пользователей.

Это можно сделать множеством различных способов. Для чала нам нужно назначить этому пользователю роль, с которой связан более высокий, чем у других пользователей, уровень привилегий. Например, достаточно просто добавить поле role в сведения о пользователе, хранящиеся в базе данных.

Выглядеть это может так, как показано ниже.

Новое поле в сведениях о пользователе

Значением поля role супер-администратора будет super-admin.

Далее, надо создать новое промежуточное ПО, которое проверяет роль пользователя:

export default (requiredRole) => { return (req, res, next) => { if(req.currentUser.role === requiredRole) { return next(); } else { return res.status(401).send('Action not allowed'); }

Оно должно быть помещено после isAuth и attachCurrentUser. Теперь создадим конечную точку, которая генерирует JWT для пользователя, от имени которого супер-администратор хочет войти в систему:

import isAuth from '../middlewares/isAuth'; import attachCurrentUser from '../middlewares/attachCurrentUser'; import roleRequired from '../middlwares/roleRequired'; import UserModel from '../models/user'; export default (app) => { app.post('/auth/signin-as-user', isAuth, attachCurrentUser, roleRequired('super-admin'), (req, res) => { const userEmail = req.body.email; const userRecord = await UserModel.findOne({ email: userEmail }); if(!userRecord) { return res.status(404).send('User not found'); return res.json({ user: { email: userRecord.email, name: userRecord.name }, jwt: this.generateToken(userRecord) }) .status(200); })

Как видите, тут нет ничего таинственного. Супер-администратор знает адрес электронной почты пользователя, от имени которого нужно войти в систему. Логика работы вышеприведённого кода очень напоминает то, как работает код, обеспечивающий вход в систему обычных пользователей. Главное отличие заключается в том, что здесь не производится проверка правильности пароля.
Пароль тут не проверяется из-за того, что он здесь просто не нужен. Безопасность конечной точки обеспечивается промежуточным ПО.

Итоги

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

Мы рассмотрели создание механизма имперсонации пользователей. В этом материале мы исследовали возможности JWT-аутентификации, поговорили о важности выбора хорошего криптографического алгоритма для хэширования паролей.

Аутентификация — это огромная тема. Сделать то же самое с помощью чего-то вроде passport.js далеко не так просто. Возможно, мы к ней ещё вернёмся.

Уважаемые читатели! Как вы создаёте системы аутентификации для своих Node.js-проектов?

Теги
Показать больше

Похожие статьи

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

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

Кнопка «Наверх»
Закрыть