Home » Node.js JWT с Express.js — руководство по аутентификации
Node.js JWT с Express.js — руководство по аутентификации

Node.js JWT с Express.js — руководство по аутентификации

Аутентификация в современных веб-приложениях — это не просто “введите логин и пароль”. Это целая экосистема безопасности, где каждая деталь важна. JWT (JSON Web Token) стал де-факто стандартом для stateless аутентификации, а в связке с Express.js превращается в мощный инструмент разработчика. Если вы запускаете API на собственном сервере, настраиваете микросервисы или просто хотите понять, как работает современная аутентификация “под капотом” — эта статья для вас.

Мы разберём не только базовую реализацию, но и продвинутые техники: refresh tokens, middleware для защиты роутов, обработка ошибок и интеграция с базами данных. А главное — покажем, как всё это развернуть на реальном сервере и не наступить на грабли, которые поджидают каждого разработчика.

Как работает JWT аутентификация

JWT — это не магия, а простая и элегантная система. Токен состоит из трёх частей, разделённых точками: header.payload.signature. Каждая часть закодирована в base64, а подпись создаётся с помощью секретного ключа.

Принцип работы прост: клиент отправляет credentials, сервер проверяет их и возвращает JWT. Далее клиент включает этот токен в заголовок Authorization каждого запроса. Сервер декодирует токен, проверяет подпись и извлекает данные пользователя.

Преимущества JWT Недостатки JWT
Stateless (не требует хранения на сервере) Нельзя отозвать до истечения срока
Масштабируемость Размер токена больше session ID
Кроссдоменность Уязвимость к XSS атакам
Мобильная совместимость Сложность в отладке

Быстрая настройка проекта

Начнём с создания нового проекта и установки зависимостей:

mkdir jwt-auth-app
cd jwt-auth-app
npm init -y
npm install express jsonwebtoken bcryptjs cors helmet dotenv
npm install --save-dev nodemon

Создаём базовую структуру файлов:

mkdir routes middleware models
touch app.js .env
touch routes/auth.js routes/protected.js
touch middleware/auth.js
touch models/user.js

Настраиваем основное приложение в app.js:

const express = require('express');
const cors = require('cors');
const helmet = require('helmet');
require('dotenv').config();

const authRoutes = require('./routes/auth');
const protectedRoutes = require('./routes/protected');

const app = express();
const PORT = process.env.PORT || 3000;

// Middleware
app.use(helmet());
app.use(cors());
app.use(express.json({ limit: '10mb' }));
app.use(express.urlencoded({ extended: true }));

// Routes
app.use('/api/auth', authRoutes);
app.use('/api/protected', protectedRoutes);

// Error handling
app.use((err, req, res, next) => {
  console.error(err.stack);
  res.status(500).json({ error: 'Something went wrong!' });
});

app.listen(PORT, () => {
  console.log(`Server running on port ${PORT}`);
});

Создание системы аутентификации

Файл .env с настройками:

JWT_SECRET=your-super-secret-key-change-this-in-production
JWT_EXPIRES_IN=1h
JWT_REFRESH_SECRET=your-refresh-secret-key
JWT_REFRESH_EXPIRES_IN=7d
BCRYPT_ROUNDS=12

Создаём модель пользователя (models/user.js):

const bcrypt = require('bcryptjs');

// Простая in-memory база для примера
const users = [];

class User {
  constructor(username, email, password) {
    this.id = Date.now().toString();
    this.username = username;
    this.email = email;
    this.password = password;
    this.createdAt = new Date();
  }

  static async create(userData) {
    const { username, email, password } = userData;
    
    // Проверяем, существует ли пользователь
    const existingUser = users.find(u => u.email === email);
    if (existingUser) {
      throw new Error('User already exists');
    }

    // Хешируем пароль
    const hashedPassword = await bcrypt.hash(password, parseInt(process.env.BCRYPT_ROUNDS));
    
    const user = new User(username, email, hashedPassword);
    users.push(user);
    
    return user;
  }

  static async findByEmail(email) {
    return users.find(u => u.email === email);
  }

  static async findById(id) {
    return users.find(u => u.id === id);
  }

  async comparePassword(candidatePassword) {
    return await bcrypt.compare(candidatePassword, this.password);
  }
}

module.exports = User;

Middleware для проверки токенов (middleware/auth.js):

const jwt = require('jsonwebtoken');
const User = require('../models/user');

const authMiddleware = async (req, res, next) => {
  try {
    const token = req.header('Authorization')?.replace('Bearer ', '');
    
    if (!token) {
      return res.status(401).json({ error: 'No token provided' });
    }

    const decoded = jwt.verify(token, process.env.JWT_SECRET);
    const user = await User.findById(decoded.id);
    
    if (!user) {
      return res.status(401).json({ error: 'User not found' });
    }

    req.user = user;
    next();
  } catch (error) {
    if (error.name === 'JsonWebTokenError') {
      return res.status(401).json({ error: 'Invalid token' });
    }
    if (error.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'Token expired' });
    }
    
    res.status(500).json({ error: 'Server error' });
  }
};

module.exports = authMiddleware;

Роуты аутентификации

Создаём routes/auth.js с полным набором эндпоинтов:

const express = require('express');
const jwt = require('jsonwebtoken');
const User = require('../models/user');
const authMiddleware = require('../middleware/auth');

const router = express.Router();

// Генерация токенов
const generateTokens = (userId) => {
  const accessToken = jwt.sign(
    { id: userId },
    process.env.JWT_SECRET,
    { expiresIn: process.env.JWT_EXPIRES_IN }
  );

  const refreshToken = jwt.sign(
    { id: userId },
    process.env.JWT_REFRESH_SECRET,
    { expiresIn: process.env.JWT_REFRESH_EXPIRES_IN }
  );

  return { accessToken, refreshToken };
};

// Регистрация
router.post('/register', async (req, res) => {
  try {
    const { username, email, password } = req.body;

    // Валидация
    if (!username || !email || !password) {
      return res.status(400).json({ error: 'All fields are required' });
    }

    if (password.length < 6) {
      return res.status(400).json({ error: 'Password must be at least 6 characters' });
    }

    const user = await User.create({ username, email, password });
    const tokens = generateTokens(user.id);

    res.status(201).json({
      message: 'User created successfully',
      user: {
        id: user.id,
        username: user.username,
        email: user.email
      },
      ...tokens
    });
  } catch (error) {
    res.status(400).json({ error: error.message });
  }
});

// Вход
router.post('/login', async (req, res) => {
  try {
    const { email, password } = req.body;

    if (!email || !password) {
      return res.status(400).json({ error: 'Email and password are required' });
    }

    const user = await User.findByEmail(email);
    if (!user || !(await user.comparePassword(password))) {
      return res.status(401).json({ error: 'Invalid credentials' });
    }

    const tokens = generateTokens(user.id);

    res.json({
      message: 'Login successful',
      user: {
        id: user.id,
        username: user.username,
        email: user.email
      },
      ...tokens
    });
  } catch (error) {
    res.status(500).json({ error: error.message });
  }
});

// Обновление токена
router.post('/refresh', async (req, res) => {
  try {
    const { refreshToken } = req.body;

    if (!refreshToken) {
      return res.status(401).json({ error: 'Refresh token required' });
    }

    const decoded = jwt.verify(refreshToken, process.env.JWT_REFRESH_SECRET);
    const user = await User.findById(decoded.id);

    if (!user) {
      return res.status(401).json({ error: 'User not found' });
    }

    const tokens = generateTokens(user.id);
    res.json(tokens);
  } catch (error) {
    res.status(401).json({ error: 'Invalid refresh token' });
  }
});

// Получение информации о пользователе
router.get('/me', authMiddleware, (req, res) => {
  res.json({
    user: {
      id: req.user.id,
      username: req.user.username,
      email: req.user.email,
      createdAt: req.user.createdAt
    }
  });
});

module.exports = router;

Защищённые роуты

Создаём routes/protected.js для демонстрации защищённых эндпоинтов:

const express = require('express');
const authMiddleware = require('../middleware/auth');

const router = express.Router();

// Все роуты в этом файле требуют аутентификации
router.use(authMiddleware);

router.get('/dashboard', (req, res) => {
  res.json({
    message: 'Welcome to your dashboard!',
    user: {
      id: req.user.id,
      username: req.user.username
    },
    timestamp: new Date().toISOString()
  });
});

router.get('/profile', (req, res) => {
  res.json({
    profile: {
      id: req.user.id,
      username: req.user.username,
      email: req.user.email,
      createdAt: req.user.createdAt
    }
  });
});

module.exports = router;

Тестирование API

Запускаем сервер:

npm run dev

Добавляем в package.json:

{
  "scripts": {
    "dev": "nodemon app.js",
    "start": "node app.js"
  }
}

Тестируем с помощью curl:

# Регистрация
curl -X POST http://localhost:3000/api/auth/register \
  -H "Content-Type: application/json" \
  -d '{"username":"john","email":"john@example.com","password":"password123"}'

# Вход
curl -X POST http://localhost:3000/api/auth/login \
  -H "Content-Type: application/json" \
  -d '{"email":"john@example.com","password":"password123"}'

# Доступ к защищённому роуту
curl -X GET http://localhost:3000/api/protected/dashboard \
  -H "Authorization: Bearer YOUR_ACCESS_TOKEN"

Продвинутые техники и оптимизация

Для продакшена добавляем дополнительные слои безопасности:

const rateLimit = require('express-rate-limit');

// Rate limiting для auth endpoints
const authLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 минут
  max: 5, // максимум 5 попыток
  message: 'Too many authentication attempts'
});

router.use('/login', authLimiter);

Middleware для логирования:

const winston = require('winston');

const logger = winston.createLogger({
  level: 'info',
  format: winston.format.json(),
  transports: [
    new winston.transports.File({ filename: 'auth.log' })
  ]
});

// Используем в middleware
logger.info('User login attempt', { email, ip: req.ip });

Альтернативные решения

Решение Преимущества Недостатки
Passport.js Множество стратегий, готовые решения Сложность настройки, overhead
Auth0 Готовый сервис, безопасность Стоимость, зависимость от внешнего сервиса
Firebase Auth Интеграция с Google, простота Vendor lock-in, ограничения
Sessions + Redis Возможность отзыва, традиционность Stateful, проблемы с масштабированием

Развёртывание на сервере

Для развёртывания на VPS создаём ecosystem.config.js для PM2:

module.exports = {
  apps: [{
    name: 'jwt-auth-app',
    script: 'app.js',
    instances: 'max',
    exec_mode: 'cluster',
    env: {
      NODE_ENV: 'production',
      PORT: 3000
    },
    error_file: './logs/err.log',
    out_file: './logs/out.log',
    log_file: './logs/combined.log',
    time: true
  }]
};

Настройка nginx для reverse proxy:

server {
    listen 80;
    server_name your-domain.com;
    
    location / {
        proxy_pass http://localhost:3000;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection 'upgrade';
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_cache_bypass $http_upgrade;
    }
}

Интеграция с базами данных

Для подключения MongoDB:

const mongoose = require('mongoose');

const userSchema = new mongoose.Schema({
  username: { type: String, required: true, unique: true },
  email: { type: String, required: true, unique: true },
  password: { type: String, required: true },
  refreshTokens: [{ type: String }], // Для хранения refresh токенов
  createdAt: { type: Date, default: Date.now }
});

module.exports = mongoose.model('User', userSchema);

Для PostgreSQL с Sequelize:

const { DataTypes } = require('sequelize');

const User = sequelize.define('User', {
  username: {
    type: DataTypes.STRING,
    allowNull: false,
    unique: true
  },
  email: {
    type: DataTypes.STRING,
    allowNull: false,
    unique: true,
    validate: {
      isEmail: true
    }
  },
  password: {
    type: DataTypes.STRING,
    allowNull: false
  }
});

Автоматизация и скрипты

Создаём полезные скрипты для управления:

// scripts/generateSecret.js
const crypto = require('crypto');

const generateSecret = () => {
  return crypto.randomBytes(64).toString('hex');
};

console.log('JWT_SECRET=' + generateSecret());
console.log('JWT_REFRESH_SECRET=' + generateSecret());
// scripts/cleanupTokens.js
const User = require('../models/user');

async function cleanupExpiredTokens() {
  // Логика очистки истёкших refresh токенов
  const users = await User.find({});
  
  for (const user of users) {
    user.refreshTokens = user.refreshTokens.filter(token => {
      try {
        jwt.verify(token, process.env.JWT_REFRESH_SECRET);
        return true;
      } catch {
        return false;
      }
    });
    await user.save();
  }
}

// Запуск через cron
cleanupExpiredTokens().catch(console.error);

Мониторинг и аналитика

Добавляем middleware для отслеживания:

const analytics = {
  loginAttempts: 0,
  successfulLogins: 0,
  failedLogins: 0,
  activeUsers: new Set()
};

const trackAuth = (req, res, next) => {
  const originalSend = res.send;
  
  res.send = function(data) {
    if (req.path === '/login') {
      analytics.loginAttempts++;
      if (res.statusCode === 200) {
        analytics.successfulLogins++;
      } else {
        analytics.failedLogins++;
      }
    }
    
    return originalSend.call(this, data);
  };
  
  next();
};

// Эндпоинт для получения статистики
router.get('/stats', (req, res) => {
  res.json({
    loginAttempts: analytics.loginAttempts,
    successfulLogins: analytics.successfulLogins,
    failedLogins: analytics.failedLogins,
    activeUsers: analytics.activeUsers.size
  });
});

Безопасность в продакшене

Важные моменты для безопасности:

  • Используйте HTTPS всегда
  • Храните секретные ключи в переменных окружения
  • Добавьте rate limiting для критичных эндпоинтов
  • Валидируйте все входные данные
  • Логируйте подозрительную активность
  • Используйте короткие времена жизни для access токенов
  • Реализуйте blacklist для отозванных токенов

Заключение и рекомендации

JWT аутентификация с Express.js — это мощный и гибкий подход для современных веб-приложений. Она идеально подходит для API, микросервисов и SPA приложений. Главные преимущества: stateless природа, масштабируемость и простота интеграции.

Рекомендую использовать JWT когда:

  • Вы строите API для мобильных приложений
  • Нужна аутентификация между микросервисами
  • Планируете горизонтальное масштабирование
  • Работаете с SPA фреймворками

Для высоконагруженных проектов рассмотрите выделенный сервер с Redis для кеширования и балансировкой нагрузки.

Избегайте JWT если:

  • Нужна возможность мгновенного отзыва доступа
  • Работаете с чувствительными данными в токене
  • Приложение имеет сложную систему ролей

Помните: безопасность — это процесс, а не результат. Регулярно обновляйте зависимости, мониторьте логи и следите за best practices. JWT — это инструмент, а не панацея.


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

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

Leave a reply

Your email address will not be published. Required fields are marked