Автор статьи, перевод которой мы сегодня публикуем, говорит, что сейчас можно наблюдать рост популярности таких сервисов аутентификации, как 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 {
public async SignUp(email, password, name): Promise<any> {
const passwordHashed = await argon2.hash(password);
const userRecord = await UserModel.create({
password: passwordHashed,
email,
name,
});
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
, которая нужна нам для завершения работы над сервисом аутентификации пользователей.
Создавать JWT можно с помощью библиотеки jsonwebtoken. Найти эту библиотеку можно в npm.
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 в каждом запросе к защищённой конечной точке.
Рекомендуется включать JWT в заголовки запросов. Обычно их включают в заголовок Authorization.
Заголовок Authorization
Теперь, на сервере, нужно создать код, представляющий собой промежуточное ПО для маршрутов express. Поместим этот код в файл isAuth.ts
:
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 на сервере.
Именно поэтому так важна защита этих секретных данных.
Наш сервер проверяет подпись в промежуточном ПО isAuth. За проверку отвечает библиотека express-jwt.
Теперь, после того, как мы разобрались с тем, как работает технология 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-проектов?
Автор: ru_vds