Kodokon kodokon.com

Аутентификация: хеширование, сессии против JWT

Хешируй пароли дорогой функцией, осознанно выбирай между сессиями и JWT и защищай маршруты через middleware.

10 мин · 3 вопросов

Открыть этот урок в Kodokon

Хранить пароль - значит готовиться к будущей утечке: проектируй хеширование так, чтобы оно пережило кражу базы данных. Быстрый хеш вроде SHA-256 перебирается на GPU со скоростью миллиардов попыток в секунду. Нужна намеренно дорогая функция - scrypt из node:crypto, либо bcrypt/argon2 - и случайная соль для каждого пользователя, которая обесценивает заранее посчитанные таблицы. Соль не секрет: она хранится в открытом виде рядом с хешем.

JAVASCRIPT
import {
  randomBytes,
  scryptSync,
  timingSafeEqual,
} from 'node:crypto';

export function hashPassword(password) {
  const salt = randomBytes(16).toString('hex');
  const hash = scryptSync(password, salt, 64)
    .toString('hex');
  return `${salt}:${hash}`;
}

export function verifyPassword(password, stored) {
  const [salt, hash] = stored.split(':');
  const candidate = scryptSync(password, salt, 64);
  const expected = Buffer.from(hash, 'hex');
  return timingSafeEqual(candidate, expected);
}
Случайная соль, дорогое вычисление, сравнение за постоянное время.

Две модели, чтобы держать пользователя авторизованным. Сессия: непрозрачный идентификатор в cookie с флагом httpOnly, состояние на стороне сервера. Мгновенный отзыв, cookie невидим для JavaScript, но общее хранилище (Redis) становится необходимым, как только появляется второй экземпляр. JWT: подписанное состояние зашито в токен, проверяется без всякого хранилища - идеально между сервисами - но не отзывается до истечения срока. Честный компромисс: сессии для классического веб-приложения, короткоживущие JWT для API, которые потребляют сторонние клиенты, или для распределённых архитектур.

JAVASCRIPT
import jwt from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET;
if (!SECRET) {
  throw new Error('JWT_SECRET must be set');
}

export function signToken(user) {
  return jwt.sign(
    { sub: user.id, email: user.email },
    SECRET,
    { expiresIn: '15m' },
  );
}
Секрет обязателен при старте, токены с коротким сроком жизни.

Middleware защиты выносит контроль доступа в одно место: она извлекает токен из заголовка Authorization: Bearer, проверяет его, прикрепляет расшифрованную личность к req.user - или обрывает всё ошибкой 401, направленной в middleware ошибок из предыдущего урока. Применяй её на уровне роутера (router.use(requireAuth)), чтобы защитить сразу целую группу маршрутов, а не по одному.

JAVASCRIPT
import jwt from 'jsonwebtoken';
import { HttpError } from './http-error.js';

const SECRET = process.env.JWT_SECRET;

export function requireAuth(req, res, next) {
  const header = req.headers.authorization ?? '';
  const [scheme, token] = header.split(' ');
  if (scheme !== 'Bearer' || !token) {
    return next(new HttpError(401, 'Missing token'));
  }
  try {
    req.user = jwt.verify(token, SECRET);
    next();
  } catch {
    next(new HttpError(401, 'Invalid or expired token'));
  }
}
Проверить, прикрепить req.user или вернуть 401 - и ничего больше.

Проверка знаний

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

  1. Почему одного SHA-256 недостаточно для хранения паролей?
    • Потому что алгоритм криптографически взломан
    • Потому что он слишком быстрый: GPU проверяет миллиарды кандидатов в секунду, а нужна медленная функция с солью
    • Потому что он даёт коллизии на коротких паролях
    • Потому что Node не реализует его нативно
  2. Какой механизм позволяет отозвать доступ немедленно, до всякого истечения срока?
    • JWT, потому что он несёт в себе дату истечения
    • Серверная сессия, потому что достаточно удалить её запись из хранилища
    • Оба одинаково
  3. Зачем нужен timingSafeEqual при проверке пароля?
    • Чтобы ускорить сравнение двух хешей
    • Чтобы сравнивать за постоянное время, и длительность ответа не выдавала, сколько байтов совпало
    • Чтобы преобразовать шестнадцатеричные хеши в Buffer