
Express.js + JWT: аутентификация
JWT (JSON Web Token) — это открытый стандарт для создания токенов доступа, который позволяет безопасно передавать информацию между сторонами в виде JSON-объекта. В сочетании с Express.js JWT обеспечивает надёжную и масштабируемую систему аутентификации, идеально подходящую для современных веб-приложений и API. В этой статье мы рассмотрим, как реализовать полноценную систему аутентификации с использованием JWT токенов в Express.js.
1. Что такое JWT и зачем он нужен
JWT (JSON Web Token) — это открытый стандарт RFC 7519, который определяет компактный и самодостаточный способ безопасной передачи информации между сторонами в виде JSON-объекта. Эта информация может быть проверена и доверена, поскольку она цифрово подписана.
Структура JWT токена
JWT состоит из трёх частей, разделённых точками (.
):
- Header — содержит метаданные о токене:
- Тип токена (
typ: "JWT"
) - Алгоритм подписи (
alg: "HS256"
,RS256
и др.)
- Payload — содержит данные (claims) о пользователе и токене:
- Registered claims — стандартные поля:
iss
(издатель),exp
(время истечения),sub
(субъект) - Public claims — пользовательские данные:
user_id
,username
,roles
- Private claims — внутренние данные приложения
- Signature — цифровая подпись для проверки подлинности и целостности данных
Пример JWT токена
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4gRG9lIiwiaWF0IjoxNTE2MjM5MDIyfQ.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c
Преимущества JWT перед традиционными сессиями
Stateless архитектура — сервер не хранит информацию о сессиях в памяти или базе данных. Это означает, что каждый запрос содержит всю необходимую информацию для аутентификации, что значительно упрощает масштабирование приложения.
Масштабируемость — поскольку сервер не хранит состояние сессий, легко распределять нагрузку между несколькими серверами. Пользователь может обращаться к любому серверу в кластере, и аутентификация будет работать одинаково.
Безопасность — токены подписываются криптографическими алгоритмами (HMAC, RSA), что гарантирует их целостность. Дополнительно можно установить время жизни токена, после которого он становится недействительным.
Универсальность — JWT работает с любыми типами клиентов: веб-приложения, мобильные приложения, десктопные программы. Токен можно передавать через заголовки HTTP, cookies или в теле запроса.
Производительность — отсутствие необходимости обращаться к базе данных для проверки сессии на каждом запросе значительно повышает скорость работы API.
2. Установка необходимых зависимостей
Для работы с JWT в Express.js потребуются дополнительные библиотеки. Каждая библиотека выполняет свою специфическую роль в системе аутентификации.
npm install express jsonwebtoken bcryptjs express-validator
Подробное описание зависимостей
express
— основной веб-фреймворк для Node.js, который мы используем для создания API. Он предоставляет все необходимые инструменты для создания маршрутов, middleware и обработки запросов.
jsonwebtoken
— библиотека для работы с JWT токенами. Она предоставляет функции для создания, подписи, проверки и декодирования JWT токенов. Это самая популярная библиотека для работы с JWT в Node.js экосистеме.
bcryptjs
— библиотека для хэширования паролей. Она предоставляет безопасные алгоритмы хэширования и функции для проверки паролей. bcrypt считается одним из самых безопасных алгоритмов для хэширования паролей.
express-validator
— библиотека для валидации входящих данных. Она предоставляет удобные middleware для проверки и санитизации данных, что критически важно для безопасности аутентификации.
Альтернативные варианты установки
Если вы используете Yarn для управления зависимостями:
yarn add express jsonwebtoken bcryptjs express-validator
Или если используете pnpm:
pnpm add express jsonwebtoken bcryptjs express-validator
Проверка установки
После установки можно проверить, что все библиотеки работают корректно:
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const { body, validationResult } = require('express-validator');
console.log('Все зависимости установлены успешно!');
3. Базовая настройка Express.js приложения
Создадим базовую структуру приложения с необходимыми middleware и конфигурацией для работы с JWT.
const express = require('express');
const jwt = require('jsonwebtoken');
const bcrypt = require('bcryptjs');
const { body, validationResult } = require('express-validator');
const app = express();
// Middleware для парсинга JSON
app.use(express.json());
// Middleware для парсинга URL-encoded данных
app.use(express.urlencoded({ extended: true }));
// Конфигурация JWT
const JWT_SECRET = process.env.JWT_SECRET || 'your-secret-key';
const JWT_EXPIRES_IN = '24h';
// Простое хранилище пользователей (в реальном проекте используйте базу данных)
const users = [];
// Middleware для обработки ошибок валидации
const handleValidationErrors = (req, res, next) => {
const errors = validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({
success: false,
errors: errors.array()
});
}
next();
};
const PORT = process.env.PORT || 3000;
app.listen(PORT, () => {
console.log(`Сервер запущен на http://localhost:${PORT}`);
});
Пояснения:
express.json()
— middleware для автоматического парсинга JSON в теле запросовexpress.urlencoded()
— middleware для парсинга данных из формJWT_SECRET
— секретный ключ для подписи JWT токенов (в продакшене должен храниться в переменных окружения)JWT_EXPIRES_IN
— время жизни токена (24 часа)users
— простое хранилище пользователей (в реальном проекте замените на базу данных)handleValidationErrors
— middleware для обработки ошибок валидации
4. Создание функций для работы с JWT
Реализуем основные функции для создания и проверки JWT токенов.
// Функция для создания JWT токена
const generateToken = (userId, username) => {
return jwt.sign(
{
userId,
username,
iat: Math.floor(Date.now() / 1000) // issued at
},
JWT_SECRET,
{ expiresIn: JWT_EXPIRES_IN }
);
};
// Функция для проверки JWT токена
const verifyToken = (token) => {
try {
return jwt.verify(token, JWT_SECRET);
} catch (error) {
return null;
}
};
// Middleware для аутентификации
const authenticateToken = (req, res, next) => {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
if (!token) {
return res.status(401).json({
success: false,
message: 'Токен доступа не предоставлен'
});
}
const decoded = verifyToken(token);
if (!decoded) {
return res.status(403).json({
success: false,
message: 'Недействительный токен'
});
}
req.user = decoded;
next();
};
// Функция для хэширования паролей
const hashPassword = async (password) => {
const saltRounds = 10;
return await bcrypt.hash(password, saltRounds);
};
// Функция для проверки паролей
const comparePassword = async (password, hashedPassword) => {
return await bcrypt.compare(password, hashedPassword);
};
Пояснения:
generateToken()
— создаёт JWT токен с данными пользователя и временем жизниverifyToken()
— проверяет подлинность токена и возвращает декодированные данныеauthenticateToken()
— middleware для защиты маршрутов, проверяет наличие и валидность токенаhashPassword()
— хэширует пароль с использованием bcryptcomparePassword()
— сравнивает введённый пароль с хэшем
5. Регистрация пользователей
Создадим маршрут для регистрации новых пользователей с валидацией данных.
// Валидация данных для регистрации
const registerValidation = [
body('username')
.isLength({ min: 3, max: 30 })
.withMessage('Имя пользователя должно содержать от 3 до 30 символов')
.isAlphanumeric()
.withMessage('Имя пользователя должно содержать только буквы и цифры'),
body('email')
.isEmail()
.withMessage('Введите корректный email адрес')
.normalizeEmail(),
body('password')
.isLength({ min: 6 })
.withMessage('Пароль должен содержать минимум 6 символов')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('Пароль должен содержать хотя бы одну строчную букву, одну заглавную букву и одну цифру')
];
// Маршрут для регистрации
app.post('/auth/register', registerValidation, handleValidationErrors, async (req, res) => {
try {
const { username, email, password } = req.body;
// Проверка на существующего пользователя
const existingUser = users.find(user =>
user.username === username || user.email === email
);
if (existingUser) {
return res.status(400).json({
success: false,
message: 'Пользователь с таким именем или email уже существует'
});
}
// Хэширование пароля
const hashedPassword = await hashPassword(password);
// Создание нового пользователя
const newUser = {
id: Date.now().toString(),
username,
email,
password: hashedPassword,
createdAt: new Date()
};
users.push(newUser);
// Создание JWT токена
const token = generateToken(newUser.id, newUser.username);
res.status(201).json({
success: true,
message: 'Пользователь успешно зарегистрирован',
data: {
user: {
id: newUser.id,
username: newUser.username,
email: newUser.email,
createdAt: newUser.createdAt
},
token
}
});
} catch (error) {
console.error('Ошибка при регистрации:', error);
res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
Пояснения:
registerValidation
— массив правил валидации для проверки корректности данных- Проверка на существующего пользователя по имени и email
- Хэширование пароля перед сохранением
- Создание JWT токена после успешной регистрации
- Возврат данных пользователя (без пароля) и токена
6. Аутентификация пользователей
Создадим маршрут для входа пользователей в систему.
// Валидация данных для входа
const loginValidation = [
body('username')
.notEmpty()
.withMessage('Имя пользователя обязательно'),
body('password')
.notEmpty()
.withMessage('Пароль обязателен')
];
// Маршрут для входа
app.post('/auth/login', loginValidation, handleValidationErrors, async (req, res) => {
try {
const { username, password } = req.body;
// Поиск пользователя
const user = users.find(u => u.username === username);
if (!user) {
return res.status(401).json({
success: false,
message: 'Неверное имя пользователя или пароль'
});
}
// Проверка пароля
const isPasswordValid = await comparePassword(password, user.password);
if (!isPasswordValid) {
return res.status(401).json({
success: false,
message: 'Неверное имя пользователя или пароль'
});
}
// Создание JWT токена
const token = generateToken(user.id, user.username);
res.json({
success: true,
message: 'Успешный вход в систему',
data: {
user: {
id: user.id,
username: user.username,
email: user.email,
createdAt: user.createdAt
},
token
}
});
} catch (error) {
console.error('Ошибка при входе:', error);
res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
Пояснения:
- Поиск пользователя по имени пользователя
- Проверка пароля с использованием bcrypt
- Создание JWT токена при успешной аутентификации
- Возврат данных пользователя и токена
- Единое сообщение об ошибке для безопасности (не раскрываем, что именно неверно)
7. Защищённые маршруты
Создадим примеры защищённых маршрутов, которые требуют аутентификации.
// Получение профиля пользователя
app.get('/profile', authenticateToken, (req, res) => {
const user = users.find(u => u.id === req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
message: 'Пользователь не найден'
});
}
res.json({
success: true,
data: {
id: user.id,
username: user.username,
email: user.email,
createdAt: user.createdAt
}
});
});
// Обновление профиля пользователя
const updateProfileValidation = [
body('email')
.optional()
.isEmail()
.withMessage('Введите корректный email адрес')
.normalizeEmail(),
body('currentPassword')
.optional()
.notEmpty()
.withMessage('Текущий пароль обязателен для изменения пароля'),
body('newPassword')
.optional()
.isLength({ min: 6 })
.withMessage('Новый пароль должен содержать минимум 6 символов')
.matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)/)
.withMessage('Новый пароль должен содержать хотя бы одну строчную букву, одну заглавную букву и одну цифру')
];
app.put('/profile', authenticateToken, updateProfileValidation, handleValidationErrors, async (req, res) => {
try {
const { email, currentPassword, newPassword } = req.body;
const userIndex = users.findIndex(u => u.id === req.user.userId);
if (userIndex === -1) {
return res.status(404).json({
success: false,
message: 'Пользователь не найден'
});
}
const user = users[userIndex];
const updates = {};
// Обновление email
if (email && email !== user.email) {
const emailExists = users.some(u => u.email === email && u.id !== user.id);
if (emailExists) {
return res.status(400).json({
success: false,
message: 'Email уже используется другим пользователем'
});
}
updates.email = email;
}
// Обновление пароля
if (newPassword) {
if (!currentPassword) {
return res.status(400).json({
success: false,
message: 'Текущий пароль обязателен для изменения пароля'
});
}
const isCurrentPasswordValid = await comparePassword(currentPassword, user.password);
if (!isCurrentPasswordValid) {
return res.status(400).json({
success: false,
message: 'Неверный текущий пароль'
});
}
updates.password = await hashPassword(newPassword);
}
// Применение обновлений
Object.assign(users[userIndex], updates);
res.json({
success: true,
message: 'Профиль успешно обновлён',
data: {
id: user.id,
username: user.username,
email: updates.email || user.email,
createdAt: user.createdAt
}
});
} catch (error) {
console.error('Ошибка при обновлении профиля:', error);
res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
// Выход из системы (опционально - можно реализовать чёрный список токенов)
app.post('/auth/logout', authenticateToken, (req, res) => {
// В stateless архитектуре с JWT выход обычно реализуется на клиенте
// путём удаления токена. Здесь можно добавить логику для чёрного списка токенов
res.json({
success: true,
message: 'Успешный выход из системы'
});
});
Пояснения:
authenticateToken
middleware защищает маршруты от неавторизованного доступа- Получение профиля пользователя по ID из токена
- Обновление профиля с валидацией данных
- Проверка текущего пароля при его изменении
- Опциональная реализация выхода из системы
8. Обновление токенов (Refresh Tokens)
Для повышения безопасности реализуем систему обновления токенов с использованием refresh tokens.
// Конфигурация для refresh токенов
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET || 'refresh-secret-key';
const REFRESH_TOKEN_EXPIRES_IN = '7d';
// Хранилище refresh токенов (в реальном проекте используйте Redis или базу данных)
const refreshTokens = new Set();
// Функция для создания refresh токена
const generateRefreshToken = (userId) => {
const refreshToken = jwt.sign(
{ userId, type: 'refresh' },
REFRESH_TOKEN_SECRET,
{ expiresIn: REFRESH_TOKEN_EXPIRES_IN }
);
refreshTokens.add(refreshToken);
return refreshToken;
};
// Функция для проверки refresh токена
const verifyRefreshToken = (token) => {
try {
const decoded = jwt.verify(token, REFRESH_TOKEN_SECRET);
if (decoded.type !== 'refresh') {
return null;
}
return refreshTokens.has(token) ? decoded : null;
} catch (error) {
return null;
}
};
// Обновление access токена
app.post('/auth/refresh', async (req, res) => {
try {
const { refreshToken } = req.body;
if (!refreshToken) {
return res.status(400).json({
success: false,
message: 'Refresh токен обязателен'
});
}
const decoded = verifyRefreshToken(refreshToken);
if (!decoded) {
return res.status(401).json({
success: false,
message: 'Недействительный refresh токен'
});
}
const user = users.find(u => u.id === decoded.userId);
if (!user) {
return res.status(404).json({
success: false,
message: 'Пользователь не найден'
});
}
// Создание нового access токена
const newAccessToken = generateToken(user.id, user.username);
res.json({
success: true,
data: {
accessToken: newAccessToken,
refreshToken: refreshToken // тот же refresh токен
}
});
} catch (error) {
console.error('Ошибка при обновлении токена:', error);
res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
}
});
// Отзыв refresh токена
app.post('/auth/revoke', authenticateToken, (req, res) => {
const { refreshToken } = req.body;
if (refreshToken) {
refreshTokens.delete(refreshToken);
}
res.json({
success: true,
message: 'Токен успешно отозван'
});
});
Пояснения:
- Refresh токены имеют долгое время жизни (7 дней) и используются для получения новых access токенов
- Отдельный секретный ключ для refresh токенов повышает безопасность
- Хранилище активных токенов позволяет их отзывать
- Типизация токенов предотвращает неправильное использование
Пояснения:
- Refresh токены имеют более длительное время жизни (7 дней)
- Хранилище активных refresh токенов для возможности их отзыва
- Функция обновления access токена без повторной аутентификации
- Возможность отзыва refresh токенов для безопасности
9. Middleware для ролей и разрешений
Создадим систему ролей для более детального контроля доступа.
// Роли пользователей
const ROLES = {
USER: 'user',
MODERATOR: 'moderator',
ADMIN: 'admin'
};
// Middleware для проверки ролей
const requireRole = (roles) => {
return (req, res, next) => {
if (!req.user) {
return res.status(401).json({
success: false,
message: 'Требуется аутентификация'
});
}
const user = users.find(u => u.id === req.user.userId);
if (!user) {
return res.status(404).json({
success: false,
message: 'Пользователь не найден'
});
}
const userRole = user.role || ROLES.USER;
if (!roles.includes(userRole)) {
return res.status(403).json({
success: false,
message: 'Недостаточно прав для выполнения операции'
});
}
next();
};
};
// Примеры защищённых маршрутов с ролями
app.get('/admin/users', authenticateToken, requireRole([ROLES.ADMIN]), (req, res) => {
const userList = users.map(user => ({
id: user.id,
username: user.username,
email: user.email,
role: user.role || ROLES.USER,
createdAt: user.createdAt
}));
res.json({
success: true,
data: userList
});
});
app.put('/admin/users/:id/role', authenticateToken, requireRole([ROLES.ADMIN]), (req, res) => {
const { role } = req.body;
const userId = req.params.id;
if (!Object.values(ROLES).includes(role)) {
return res.status(400).json({
success: false,
message: 'Недопустимая роль'
});
}
const userIndex = users.findIndex(u => u.id === userId);
if (userIndex === -1) {
return res.status(404).json({
success: false,
message: 'Пользователь не найден'
});
}
users[userIndex].role = role;
res.json({
success: true,
message: 'Роль пользователя обновлена',
data: {
id: users[userIndex].id,
username: users[userIndex].username,
role: users[userIndex].role
}
});
});
Пояснения:
requireRole
middleware проверяет наличие необходимых ролей у пользователя- Роли определяют уровень доступа к различным ресурсам
- Административные маршруты доступны только пользователям с ролью ADMIN
- Система ролей обеспечивает принцип наименьших привилегий
Пояснения:
- Система ролей для контроля доступа к различным ресурсам
requireRole
middleware проверяет наличие необходимых ролейrequireOwnership
middleware проверяет владение ресурсом- Примеры административных маршрутов
10. Обработка ошибок и логирование
Создадим централизованную систему обработки ошибок и логирования.
// Middleware для обработки ошибок
const errorHandler = (err, req, res, next) => {
console.error('Ошибка:', err);
// JWT ошибки
if (err.name === 'JsonWebTokenError') {
return res.status(401).json({
success: false,
message: 'Недействительный токен'
});
}
if (err.name === 'TokenExpiredError') {
return res.status(401).json({
success: false,
message: 'Токен истёк'
});
}
// Валидация ошибок
if (err.name === 'ValidationError') {
return res.status(400).json({
success: false,
message: 'Ошибка валидации',
errors: err.errors
});
}
// Общие ошибки сервера
res.status(500).json({
success: false,
message: 'Внутренняя ошибка сервера'
});
};
// Middleware для логирования запросов
const requestLogger = (req, res, next) => {
const start = Date.now();
res.on('finish', () => {
const duration = Date.now() - start;
console.log(`${req.method} ${req.originalUrl} ${res.statusCode} ${duration}ms`);
});
next();
};
// Применение middleware
app.use(requestLogger);
app.use(errorHandler);
// Обработка несуществующих маршрутов
app.use('*', (req, res) => {
res.status(404).json({
success: false,
message: 'Маршрут не найден'
});
});
Пояснения:
- Централизованная обработка различных типов ошибок
- Логирование всех запросов с временем выполнения
- Обработка несуществующих маршрутов
- Специальная обработка JWT ошибок
11. Безопасность и лучшие практики
Рассмотрим важные аспекты безопасности при работе с JWT.
Безопасное хранение секретных ключей
// Используйте переменные окружения для секретных ключей
require('dotenv').config();
const JWT_SECRET = process.env.JWT_SECRET;
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
if (!JWT_SECRET || !REFRESH_TOKEN_SECRET) {
throw new Error('JWT_SECRET и REFRESH_TOKEN_SECRET должны быть установлены');
}
Rate Limiting для защиты от брутфорса
const rateLimit = require('express-rate-limit');
// Ограничение попыток входа
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 минут
max: 5, // максимум 5 попыток
message: {
success: false,
message: 'Слишком много попыток входа. Попробуйте позже.'
}
});
app.post('/auth/login', loginLimiter, loginValidation, handleValidationErrors, loginWithRefreshToken);
Дополнительные меры безопасности
const cors = require('cors');
const helmet = require('helmet');
// CORS настройки
app.use(cors({
origin: process.env.ALLOWED_ORIGINS?.split(',') || ['http://localhost:3000'],
credentials: true
}));
// Helmet для безопасности заголовков
app.use(helmet());
Заключение
JWT аутентификация в Express.js предоставляет мощный и гибкий способ реализации системы безопасности для современных веб-приложений. Основные преимущества включают:
Stateless архитектура — отсутствие необходимости хранить состояние сессий на сервере Масштабируемость — легкость распределения нагрузки между серверами Безопасность — криптографическая подпись токенов и возможность их отзыва Универсальность — работа с различными типами клиентов
При реализации JWT аутентификации важно следовать лучшим практикам безопасности:
- Использовать сильные секретные ключи и хранить их в переменных окружения
- Устанавливать разумное время жизни токенов
- Реализовывать систему обновления токенов
- Применять rate limiting для защиты от атак
- Использовать HTTPS в продакшене
- Регулярно обновлять зависимости
JWT аутентификация отлично подходит для API, микросервисов и современных веб-приложений, где важны производительность и масштабируемость.