Auth. JWT, OAuth 2.0 и управление доступом

Auth обычно объединяет несколько связанных, но разных задач:

Упрощённый сценарий входа:

Пользователь вводит email и пароль
                ↓
Сервер находит пользователя
                ↓
Сервер проверяет хеш пароля
                ↓
Сервер создаёт сессию или выдаёт токены
                ↓
Клиент передаёт учётные данные в новых запросах
                ↓
Сервер проверяет личность и права доступа

Важно разделять:

Аутентификация — кто выполняет запрос?
Авторизация    — что этому субъекту разрешено?

Содержание


Аутентификация и авторизация

Аутентификация

Аутентификация подтверждает личность пользователя, приложения или сервиса.

Примеры способов аутентификации:

После успешной аутентификации система может определить субъекта:

const authenticatedUser = {
  id: "user-42",
  email: "anna@example.com",
};

Аутентификация отвечает на вопрос:

Кто отправил запрос?

Авторизация

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

Например:

const user = {
  id: "user-42",
  roles: ["editor"],
};

Пользователь может:

Но ему может быть запрещено:

Авторизация отвечает на вопрос:

Можно ли этому пользователю выполнить действие
над этим ресурсом?

Последовательность проверок

Сначала выполняется аутентификация, затем авторизация:

async function handleRequest(request) {
  const user = await authenticate(request);

  if (!user) {
    throw new AuthenticationError(
      "Требуется аутентификация",
    );
  }

  if (!canReadProfile(user, request.params.userId)) {
    throw new AuthorizationError(
      "Недостаточно прав",
    );
  }

  return getProfile(request.params.userId);
}

Статусы HTTP

Если учётные данные отсутствуют или недействительны:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer

Несмотря на название, 401 Unauthorized означает, что запрос не прошёл аутентификацию.

Если пользователь аутентифицирован, но у него нет необходимых прав:

HTTP/1.1 403 Forbidden

Краткое различие:

401 — система не смогла подтвердить личность
403 — личность подтверждена, но действие запрещено

Иногда сервер возвращает 404 Not Found вместо 403 Forbidden, чтобы не раскрывать существование защищённого ресурса:

GET /users/secret-user

Такое решение должно применяться последовательно и быть частью модели безопасности API.


Пароли

Пароль нельзя хранить в базе данных в исходном виде:

const user = {
  email: "anna@example.com",
  password: "secret-password",
};

Нельзя хранить пароль и в обратимо зашифрованном виде, если приложению не требуется восстановление исходного значения.

Для проверки паролей используется хеширование паролей.

Пароль
   ↓
Медленная функция хеширования
   ↓
Хеш
   ↓
Сохранение хеша в базе данных

Во время входа сервер не расшифровывает пароль. Он проверяет, соответствует ли введённый пароль сохранённому хешу.


Хеширование и шифрование

Хеширование и шифрование решают разные задачи.

Операция Можно обратить Типичное применение
Хеширование Нет Проверка паролей
Шифрование Да, при наличии ключа Защита данных, которые нужно восстановить
Кодирование Да, без секрета Изменение представления данных

Base64 не защищает пароль:

const encoded = Buffer
  .from("secret-password")
  .toString("base64");

console.log(encoded);
// c2VjcmV0LXBhc3N3b3Jk

Строку можно восстановить:

const password = Buffer
  .from(encoded, "base64")
  .toString("utf8");

Поэтому Base64 не является ни шифрованием, ни хешированием пароля.


bcrypt

bcrypt — функция хеширования паролей, разработанная так, чтобы проверка была намеренно относительно медленной.

Медленная проверка затрудняет массовый перебор паролей после утечки базы данных.

bcrypt автоматически использует:

Пример хеша:

$2b$12$2wpgHvDMPk48qtc3oNAuKuFf3FUURygjMtZm6vLJjWV03JJcRJSXq

В таком значении содержатся:

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


Хеширование пароля в JavaScript

Пример с пакетом bcrypt:

import bcrypt from "bcrypt";

const password = "correct horse battery staple";
const costFactor = 12;

const passwordHash = await bcrypt.hash(
  password,
  costFactor,
);

console.log(passwordHash);

Соль отдельно создавать и хранить обычно не требуется. bcrypt.hash() генерирует её и включает в итоговый хеш.

Проверка пароля

import bcrypt from "bcrypt";

async function verifyPassword(
  password,
  passwordHash,
) {
  return bcrypt.compare(password, passwordHash);
}

Использование:

const isValid = await verifyPassword(
  enteredPassword,
  user.passwordHash,
);

if (!isValid) {
  throw new Error(
    "Неверный email или пароль",
  );
}

bcrypt.compare() извлекает параметры и соль из сохранённого хеша и выполняет корректное сравнение.

Нельзя проверять пароль так:

const newHash = await bcrypt.hash(password, 12);

if (newHash === user.passwordHash) {
  // ...
}

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


Cost factor bcrypt

Стоимость bcrypt определяет количество вычислительной работы.

const passwordHash = await bcrypt.hash(
  password,
  12,
);

Чем выше значение:

Стоимость необходимо выбирать с помощью измерений на рабочем оборудовании:

import bcrypt from "bcrypt";

async function measureBcryptCost(cost) {
  const startedAt = performance.now();

  await bcrypt.hash(
    "benchmark-password",
    cost,
  );

  const duration = performance.now() - startedAt;

  console.log({
    cost,
    durationMilliseconds: Math.round(duration),
  });
}

await measureBcryptCost(12);

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

Защита должна включать не только bcrypt, но и:


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

Упрощённый сервис регистрации:

import bcrypt from "bcrypt";

class RegisterUser {
  constructor({
    userRepository,
    passwordCost = 12,
  }) {
    this.userRepository = userRepository;
    this.passwordCost = passwordCost;
  }

  async execute(input) {
    const email = input.email
      .trim()
      .toLowerCase();

    const existingUser =
      await this.userRepository.findByEmail(email);

    if (existingUser) {
      throw new Error(
        "Пользователь уже существует",
      );
    }

    validatePassword(input.password);

    const passwordHash = await bcrypt.hash(
      input.password,
      this.passwordCost,
    );

    const user =
      await this.userRepository.create({
        email,
        passwordHash,
      });

    return {
      id: user.id,
      email: user.email,
    };
  }
}

function validatePassword(password) {
  if (typeof password !== "string") {
    throw new Error("Пароль обязателен");
  }

  if (password.length < 12) {
    throw new Error(
      "Пароль должен содержать не менее 12 символов",
    );
  }
}

Хеш пароля не должен возвращаться клиенту:

return {
  id: user.id,
  email: user.email,
};

Нежелательно:

return user;
// Объект может содержать passwordHash

Вход пользователя

import bcrypt from "bcrypt";

class LoginUser {
  constructor({
    userRepository,
    tokenService,
  }) {
    this.userRepository = userRepository;
    this.tokenService = tokenService;
  }

  async execute(input) {
    const email = input.email
      .trim()
      .toLowerCase();

    const user =
      await this.userRepository.findByEmail(email);

    if (!user) {
      throw new Error(
        "Неверный email или пароль",
      );
    }

    const passwordIsValid =
      await bcrypt.compare(
        input.password,
        user.passwordHash,
      );

    if (!passwordIsValid) {
      throw new Error(
        "Неверный email или пароль",
      );
    }

    return this.tokenService.issueTokenPair(user);
  }
}

Для отсутствующего пользователя и неверного пароля желательно возвращать одинаковое внешнее сообщение:

Неверный email или пароль

Сообщения вида:

Пользователь с таким email не существует

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


Ограничения bcrypt

bcrypt имеет ограничение на объём пароля, фактически участвующий в вычислении. Значение зависит от байтового представления, поэтому длинные Unicode-строки требуют особого внимания.

Приложение должно:

Пример проверки размера UTF-8:

function validatePasswordSize(password) {
  const sizeInBytes = Buffer.byteLength(
    password,
    "utf8",
  );

  if (sizeInBytes > 72) {
    throw new Error(
      "Пароль превышает допустимый размер",
    );
  }
}

В некоторых системах вместо bcrypt применяются современные специализированные функции, например Argon2id. Но если проект использует bcrypt, его необходимо настраивать, измерять и регулярно пересматривать.


Pepper

Pepper — дополнительный секрет, хранящийся отдельно от базы пользователей.

Упрощённо:

const preparedPassword =
  password + process.env.PASSWORD_PEPPER;

const passwordHash = await bcrypt.hash(
  preparedPassword,
  12,
);

Проверка должна использовать тот же pepper:

const preparedPassword =
  password + process.env.PASSWORD_PEPPER;

const isValid = await bcrypt.compare(
  preparedPassword,
  user.passwordHash,
);

Pepper может уменьшить ущерб от утечки только базы данных, если секрет хранится отдельно.

Но он усложняет:

Pepper не заменяет bcrypt, соль, контроль доступа к базе и защиту секретов.


Обновление старого хеша

После успешного входа можно обновить хеш, если текущая стоимость устарела:

import bcrypt from "bcrypt";

async function upgradePasswordHashIfNeeded({
  password,
  currentHash,
  desiredCost,
  userId,
  userRepository,
}) {
  const currentCost =
    bcrypt.getRounds(currentHash);

  if (currentCost >= desiredCost) {
    return;
  }

  const newHash = await bcrypt.hash(
    password,
    desiredCost,
  );

  await userRepository.updatePasswordHash(
    userId,
    newHash,
  );
}

Это позволяет постепенно обновлять защиту без принудительного сброса всех паролей.


JWT

JWT (JSON Web Token) — компактный формат передачи набора утверждений между сторонами.

JWT часто используется как access-токен, но JWT и access-токен — не одно и то же:

JWT выглядит как три части, разделённые точками:

header.payload.signature

Пример:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1c2VyLTQyIiwicm9sZSI6ImVkaXRvciJ9
.
signature

Обычно токен записывается одной строкой без переносов.


Структура JWT

Header описывает тип токена и алгоритм подписи:

const header = {
  alg: "HS256",
  typ: "JWT",
};

Payload

Payload содержит claims — утверждения о субъекте и токене:

const payload = {
  sub: "user-42",
  roles: ["editor"],
  iss: "https://auth.example.com",
  aud: "example-api",
  iat: 1789580000,
  exp: 1789580900,
};

Signature

Подпись защищает header и payload от незаметного изменения.

Концептуально:

signature = Sign(
  base64url(header) + "." + base64url(payload),
  secretOrPrivateKey
)

Сервер проверяет подпись перед использованием claims.


JWT не шифрует payload

Header и payload JWT обычно только кодируются через Base64URL.

Их может прочитать любой, кто получил токен.

Нельзя помещать в обычный подписанный JWT:

JWT может быть подписан и при этом оставаться полностью читаемым:

const parts = token.split(".");

const payload = JSON.parse(
  Buffer
    .from(parts[1], "base64url")
    .toString("utf8"),
);

console.log(payload);

Такой разбор не проверяет подпись и не должен использоваться для принятия решений о доступе.


Подпись JWT

Подпись обеспечивает:

Подпись не обеспечивает:

Симметричная подпись

При HS256 один секрет используется и для создания, и для проверки токена:

Issuer  → общий секрет ← API

Любой сервис, умеющий проверять такой токен, при наличии секрета также может выпускать новые токены.

HS256 подходит, когда выпуск и проверка выполняются в доверенной среде с аккуратным управлением секретом.

Асимметричная подпись

При RS256, ES256 и подобных схемах используются два ключа:

Приватный ключ — создание подписи
Публичный ключ — проверка подписи

Проверяющим сервисам можно выдать только публичный ключ. Они смогут проверять токены, но не смогут выпускать новые.

Это удобно в распределённой системе:

Auth server
    │
    ├── подписывает приватным ключом
    │
    ├── API 1 проверяет публичным ключом
    ├── API 2 проверяет публичным ключом
    └── API 3 проверяет публичным ключом

Стандартные claims JWT

Claim Назначение
sub Идентификатор субъекта
iss Издатель токена
aud Получатель токена
exp Время окончания действия
nbf Токен нельзя использовать раньше этого времени
iat Время выпуска
jti Уникальный идентификатор токена

sub

{
  sub: "user-42"
}

sub должен быть стабильным идентификатором. Email не всегда подходит, потому что он может измениться.

iss

{
  iss: "https://auth.example.com"
}

API должно проверять ожидаемого издателя.

aud

{
  aud: "example-api"
}

API должно отклонять токен, выпущенный для другого получателя.

exp

{
  exp: 1789580900
}

exp содержит время в секундах Unix, после которого токен считается просроченным.

Наличие exp полезно только при фактической проверке срока библиотекой или кодом сервера.

jti

{
  jti: "63dd92ef-55ac-44ac-a278-ab732ec405d4"
}

Уникальный идентификатор можно использовать для:


Создание JWT в JavaScript

Пример с библиотекой jose:

import { SignJWT } from "jose";
import { randomUUID } from "node:crypto";

const accessTokenSecret = new TextEncoder().encode(
  process.env.ACCESS_TOKEN_SECRET,
);

async function createAccessToken(user) {
  return new SignJWT({
    roles: user.roles,
    tokenType: "access",
  })
    .setProtectedHeader({
      alg: "HS256",
      typ: "JWT",
    })
    .setSubject(user.id)
    .setIssuer("https://auth.example.com")
    .setAudience("example-api")
    .setIssuedAt()
    .setExpirationTime("15m")
    .setJti(randomUUID())
    .sign(accessTokenSecret);
}

Секрет должен:

Нежелательно:

const secret = "my-secret";

Также нельзя хранить серверный секрет в переменной фронтенда или собирать его в браузерный JavaScript.


Проверка JWT

import { jwtVerify } from "jose";

const accessTokenSecret = new TextEncoder().encode(
  process.env.ACCESS_TOKEN_SECRET,
);

async function verifyAccessToken(token) {
  const { payload, protectedHeader } =
    await jwtVerify(
      token,
      accessTokenSecret,
      {
        issuer: "https://auth.example.com",
        audience: "example-api",
        algorithms: ["HS256"],
      },
    );

  if (payload.tokenType !== "access") {
    throw new Error(
      "Ожидался access-токен",
    );
  }

  return {
    userId: payload.sub,
    roles: payload.roles ?? [],
    tokenId: payload.jti,
    algorithm: protectedHeader.alg,
  };
}

Проверять необходимо не только подпись, но и:

Нельзя выбирать алгоритм только на основании недоверенного header без ограничений:

const { alg } = decodedHeader;

Библиотеке следует передать явный список разрешённых алгоритмов:

{
  algorithms: ["HS256"]
}

Извлечение Bearer-токена

Клиент отправляет access-токен:

Authorization: Bearer access-token

JavaScript-функция извлечения:

function extractBearerToken(request) {
  const authorization =
    request.headers.authorization;

  if (!authorization) {
    return null;
  }

  const [scheme, token] =
    authorization.split(" ");

  if (
    scheme?.toLowerCase() !== "bearer" ||
    !token
  ) {
    return null;
  }

  return token;
}

Middleware аутентификации:

function createAuthenticationMiddleware({
  verifyToken,
}) {
  return async function authenticate(
    request,
    response,
    next,
  ) {
    try {
      const token =
        extractBearerToken(request);

      if (!token) {
        return response
          .status(401)
          .json({
            error: "authentication_required",
            message:
              "Требуется аутентификация",
          });
      }

      request.user =
        await verifyToken(token);

      return next();
    } catch {
      return response
        .status(401)
        .json({
          error: "invalid_token",
          message:
            "Токен отсутствует, истёк или недействителен",
        });
    }
  };
}

Не следует возвращать пользователю внутренние подробности проверки подписи.


Срок жизни JWT

Access-токен должен иметь ограниченный срок действия:

.setExpirationTime("15m")

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

Но слишком короткий срок:

Обычно срок выбирается с учётом:

Универсального срока для всех приложений нет.


JWT и отзыв токена

Самодостаточный JWT может проверяться без обращения к базе:

Подпись корректна
Срок не истёк
Issuer и audience совпадают

Но это создаёт проблему отзыва. Если пользователь вышел из системы или его доступ заблокирован, ранее выпущенный JWT может оставаться действительным до exp.

Подходы к отзыву:

Пример версии токенов:

const payload = {
  sub: user.id,
  tokenVersion: user.tokenVersion,
};

При проверке:

if (
  payload.tokenVersion !==
  user.tokenVersion
) {
  throw new Error("Токен отозван");
}

Для выхода со всех устройств сервер увеличивает версию:

await userRepository.incrementTokenVersion(
  user.id,
);

Такой подход требует обращения к хранилищу и уменьшает преимущество полностью автономной проверки JWT.


Access- и Refresh-токены

Access token используется для доступа к защищённым ресурсам.

Refresh token используется для получения новой пары токенов без повторного ввода пароля.

Схема:

Вход с паролем
      ↓
Access token + Refresh token
      ↓
Access token используется для API
      ↓
Access token истекает
      ↓
Refresh token отправляется на auth-сервер
      ↓
Выдаётся новый Access token

Access token

Access-токен обычно:

GET /api/profile HTTP/1.1
Authorization: Bearer access-token

Access-токен не следует помещать в URL:

Нежелательно:
https://api.example.com/profile?access_token=secret

URL может попасть:


Refresh token

Refresh-токен обычно:

Пример запроса:

POST /auth/refresh HTTP/1.1
Cookie: __Host-refresh_token=refresh-token

Ответ может содержать новый access-токен:

{
  accessToken: "new-access-token",
  expiresIn: 900
}

Refresh token не обязан быть JWT

Refresh-токен часто удобнее делать непрозрачной случайной строкой:

import { randomBytes } from "node:crypto";

function generateRefreshToken() {
  return randomBytes(48).toString("base64url");
}

В базе лучше хранить не исходный refresh-токен, а его хеш:

import { createHash } from "node:crypto";

function hashRefreshToken(token) {
  return createHash("sha256")
    .update(token)
    .digest("hex");
}

Создание записи:

import { randomUUID } from "node:crypto";

async function createRefreshSession({
  userId,
  refreshTokenRepository,
}) {
  const refreshToken =
    generateRefreshToken();

  await refreshTokenRepository.create({
    id: randomUUID(),
    userId,
    tokenHash:
      hashRefreshToken(refreshToken),
    expiresAt: new Date(
      Date.now() +
        30 * 24 * 60 * 60 * 1000,
    ),
    revokedAt: null,
  });

  return refreshToken;
}

Если база утечёт, исходные refresh-токены нельзя будет непосредственно использовать.

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


Ротация Refresh-токенов

Refresh token rotation означает, что после каждого успешного обновления старый refresh-токен аннулируется, а клиент получает новый.

Refresh A
   ↓
Новый Access + Refresh B
   ↓
Refresh A отзывается

Следующее обновление:

Refresh B
   ↓
Новый Access + Refresh C
   ↓
Refresh B отзывается

Старый токен нельзя использовать повторно.


Семейство токенов

Все refresh-токены одного сеанса можно объединить идентификатором семейства:

const refreshSession = {
  id: "session-1",
  familyId: "family-1",
  userId: "user-42",
  tokenHash: "hash",
  expiresAt: new Date(),
  revokedAt: null,
  replacedByTokenId: null,
};

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

Сервер может отозвать всё семейство:

Refresh A использован и заменён на B
Refresh A снова предъявлен
               ↓
Обнаружено повторное использование
               ↓
Отзываются A, B и последующие токены семейства

Упрощённая ротация

async function rotateRefreshToken({
  rawRefreshToken,
  refreshTokenRepository,
  accessTokenService,
}) {
  const tokenHash =
    hashRefreshToken(rawRefreshToken);

  const storedToken =
    await refreshTokenRepository.findByHash(
      tokenHash,
    );

  if (!storedToken) {
    throw new Error(
      "Недействительный refresh-токен",
    );
  }

  if (storedToken.revokedAt) {
    await refreshTokenRepository.revokeFamily(
      storedToken.familyId,
    );

    throw new Error(
      "Обнаружено повторное использование токена",
    );
  }

  if (
    storedToken.expiresAt.getTime() <=
    Date.now()
  ) {
    throw new Error(
      "Refresh-токен истёк",
    );
  }

  const newRawToken =
    generateRefreshToken();

  const newStoredToken = {
    id: crypto.randomUUID(),
    familyId: storedToken.familyId,
    userId: storedToken.userId,
    tokenHash:
      hashRefreshToken(newRawToken),
    expiresAt: new Date(
      Date.now() +
        30 * 24 * 60 * 60 * 1000,
    ),
    revokedAt: null,
  };

  await refreshTokenRepository.rotate({
    currentTokenId: storedToken.id,
    newToken: newStoredToken,
  });

  const accessToken =
    await accessTokenService.create({
      id: storedToken.userId,
    });

  return {
    accessToken,
    refreshToken: newRawToken,
  };
}

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


Хранение токенов в браузере

Нет одного способа, подходящего для всех приложений.

Refresh-токен часто хранят в cookie:

function setRefreshTokenCookie(
  response,
  refreshToken,
) {
  response.cookie(
    "__Host-refresh_token",
    refreshToken,
    {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      path: "/",
      maxAge:
        30 * 24 * 60 * 60 * 1000,
    },
  );
}

HttpOnly запрещает обычному JavaScript читать cookie:

document.cookie;

Но браузер продолжает автоматически отправлять cookie подходящему серверу.

Преимущества:

Ограничения:

Префикс __Host- требует:

localStorage

Хранение токена:

localStorage.setItem(
  "accessToken",
  accessToken,
);

Любой JavaScript в контексте страницы может прочитать значение:

const token =
  localStorage.getItem("accessToken");

При XSS токен может быть украден и отправлен на внешний сервер.

Поэтому долгоживущие токены не рекомендуется хранить в localStorage без тщательного анализа рисков.

Память приложения

Access-токен можно держать только в памяти:

let accessToken = null;

export function setAccessToken(token) {
  accessToken = token;
}

export function getAccessToken() {
  return accessToken;
}

После перезагрузки страницы значение исчезнет. Новый access-токен можно получить через refresh-токен в защищённой cookie.

Такой подход уменьшает срок доступности access-токена для внешнего скрипта, но не устраняет последствия активной XSS.


Если аутентификационные данные отправляются автоматически в cookie, сервер должен учитывать CSRF.

Возможные меры:

Пример проверки CSRF-токена:

function verifyCsrfToken(request) {
  const cookieToken =
    request.cookies.csrf_token;

  const headerToken =
    request.headers["x-csrf-token"];

  if (
    !cookieToken ||
    !headerToken ||
    cookieToken !== headerToken
  ) {
    throw new Error(
      "Некорректный CSRF-токен",
    );
  }
}

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

CORS не является полной защитой от CSRF. Сервер должен отдельно проектировать защиту запросов, меняющих состояние.


Выход из системы

Для токенной схемы недостаточно удалить access-токен только на клиенте.

Сервер должен отозвать refresh-сессию:

async function logout({
  rawRefreshToken,
  refreshTokenRepository,
}) {
  if (!rawRefreshToken) {
    return;
  }

  const tokenHash =
    hashRefreshToken(rawRefreshToken);

  await refreshTokenRepository.revokeByHash(
    tokenHash,
    new Date(),
  );
}

Cookie очищается с теми же основными атрибутами:

function clearRefreshTokenCookie(response) {
  response.clearCookie(
    "__Host-refresh_token",
    {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      path: "/",
    },
  );
}

Для функции «выйти со всех устройств» сервер отзывает все refresh-сессии пользователя:

await refreshTokenRepository.revokeAllForUser(
  userId,
);

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


Сессии и токены

Серверная сессия обычно работает так:

Пользователь входит
       ↓
Сервер создаёт сессию
       ↓
В cookie сохраняется session ID
       ↓
Клиент отправляет session ID
       ↓
Сервер загружает сессию из хранилища

Пример cookie:

Set-Cookie: __Host-session=opaque-session-id; HttpOnly; Secure; SameSite=Lax; Path=/

В хранилище сервера:

const session = {
  id: "opaque-session-id",
  userId: "user-42",
  createdAt: new Date(),
  expiresAt: new Date(),
};

Клиент не получает полное содержимое сессии. Он получает только непрозрачный идентификатор.


Создание сессии

import { randomBytes } from "node:crypto";

function generateSessionId() {
  return randomBytes(32).toString("base64url");
}

async function createSession({
  userId,
  sessionRepository,
}) {
  const sessionId = generateSessionId();

  await sessionRepository.create({
    id: sessionId,
    userId,
    createdAt: new Date(),
    expiresAt: new Date(
      Date.now() +
        7 * 24 * 60 * 60 * 1000,
    ),
  });

  return sessionId;
}

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


Проверка сессии

async function authenticateBySession({
  request,
  sessionRepository,
  userRepository,
}) {
  const sessionId =
    request.cookies["__Host-session"];

  if (!sessionId) {
    return null;
  }

  const session =
    await sessionRepository.findById(
      sessionId,
    );

  if (!session) {
    return null;
  }

  if (
    session.expiresAt.getTime() <= Date.now()
  ) {
    await sessionRepository.delete(
      session.id,
    );

    return null;
  }

  return userRepository.findById(
    session.userId,
  );
}

После успешного входа идентификатор сессии следует заменить, чтобы снизить риск фиксации сессии.


Сравнение сессий и JWT

Характеристика Серверная сессия Самодостаточный JWT
Состояние Хранится на сервере Основные claims находятся в токене
Отзыв Обычно простой Может быть сложным
Проверка Требует хранилища Может выполняться локально
Обновление ролей Видно при следующем чтении сессии Старые claims могут оставаться до exp
Размер cookie/заголовка Обычно небольшой ID JWT обычно больше
Масштабирование Требуется общее хранилище или маршрутизация Проверка может не требовать общей БД
Утечка Сессионный ID даёт доступ к сессии Токен даёт доступ до истечения или отзыва
Сложность Часто проще для обычного сайта Полезно для API и распределённых систем

Когда удобны сессии

Сессии часто подходят, если:

Когда удобны токены

Токены могут быть удобны, если:

JWT не является обязательным улучшением по сравнению с сессиями. Для обычного серверного веб-приложения сессия часто проще и безопаснее в эксплуатации.


OAuth 2.0

OAuth 2.0 — протокол делегированной авторизации.

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

OAuth отвечает прежде всего на вопрос:

Может ли клиент получить ограниченный доступ
к защищённому API?

OAuth сам по себе не является протоколом входа пользователя. Для аутентификации поверх OAuth используется OpenID Connect (OIDC).


Участники OAuth 2.0

Участник Роль
Resource Owner Владелец данных, обычно пользователь
Client Приложение, запрашивающее доступ
Authorization Server Выполняет авторизацию и выдаёт токены
Resource Server API, принимающее access-токены

Пример:

Пользователь
    ↓
Веб-приложение
    ↓
Authorization Server
    ↓ выдаёт access token
Веб-приложение
    ↓ access token
Resource Server

Один сервер может одновременно выполнять роли Authorization Server и Resource Server, но логически это разные обязанности.


Scope

Scope ограничивает возможности access-токена.

Примеры:

profile:read
profile:write
orders:read
orders:create
admin:users

Запрос доступа:

scope=profile:read orders:read

Токен должен получать только необходимые полномочия.

Нежелательно выдавать клиенту полный доступ:

scope=*

если ему нужна только возможность прочитать профиль.

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


Authorization Code Flow с PKCE

Authorization Code Flow — основной поток для приложений с участием пользователя.

Для публичных клиентов, включая браузерные и мобильные приложения, используется PKCE.

PKCE защищает authorization code от использования злоумышленником, который перехватил код, но не знает code_verifier.

Упрощённая последовательность:

Клиент создаёт code_verifier
            ↓
Клиент вычисляет code_challenge
            ↓
Пользователь перенаправляется на Authorization Server
            ↓
Пользователь входит и подтверждает доступ
            ↓
Authorization Server возвращает authorization code
            ↓
Клиент отправляет code + code_verifier
            ↓
Authorization Server проверяет PKCE
            ↓
Клиент получает access token

Создание PKCE-параметров

Пример с Web Crypto API:

function toBase64Url(bytes) {
  return btoa(
    String.fromCharCode(...bytes),
  )
    .replaceAll("+", "-")
    .replaceAll("/", "_")
    .replaceAll("=", "");
}

function generateRandomValue(size = 32) {
  const bytes = new Uint8Array(size);
  crypto.getRandomValues(bytes);

  return toBase64Url(bytes);
}

async function createCodeChallenge(
  codeVerifier,
) {
  const data = new TextEncoder().encode(
    codeVerifier,
  );

  const digest =
    await crypto.subtle.digest(
      "SHA-256",
      data,
    );

  return toBase64Url(
    new Uint8Array(digest),
  );
}

const codeVerifier =
  generateRandomValue(64);

const codeChallenge =
  await createCodeChallenge(codeVerifier);

code_verifier необходимо сохранить до обработки callback, но нельзя отправлять в первоначальном redirect-запросе.


Начало Authorization Code Flow

const authorizationUrl =
  new URL(
    "https://auth.example.com/authorize",
  );

const state = generateRandomValue();
const nonce = generateRandomValue();

authorizationUrl.search =
  new URLSearchParams({
    response_type: "code",
    client_id: "example-client",
    redirect_uri:
      "https://app.example.com/callback",
    scope: "openid profile email",
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
  }).toString();

window.location.assign(
  authorizationUrl.toString(),
);

Ключевые параметры:

Параметр Назначение
response_type=code Запрос authorization code
client_id Идентификатор клиента
redirect_uri Адрес возврата
scope Запрашиваемые полномочия
state Связывает начало операции с callback
code_challenge Производное от code_verifier
code_challenge_method=S256 Использование SHA-256
nonce Защита OIDC-потока от повторного использования

redirect_uri должен точно соответствовать заранее зарегистрированному адресу.


Обработка callback

Authorization Server перенаправляет пользователя:

https://app.example.com/callback
  ?code=authorization-code
  &state=random-state

Клиент обязан проверить state:

function validateOAuthCallback({
  returnedState,
  expectedState,
}) {
  if (
    !returnedState ||
    returnedState !== expectedState
  ) {
    throw new Error(
      "Некорректный OAuth state",
    );
  }
}

После этого code обменивается на токены:

async function exchangeAuthorizationCode({
  code,
  codeVerifier,
}) {
  const response = await fetch(
    "https://auth.example.com/token",
    {
      method: "POST",
      headers: {
        "Content-Type":
          "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({
        grant_type:
          "authorization_code",
        client_id: "example-client",
        redirect_uri:
          "https://app.example.com/callback",
        code,
        code_verifier: codeVerifier,
      }),
    },
  );

  if (!response.ok) {
    throw new Error(
      "Не удалось получить токены",
    );
  }

  return response.json();
}

Для конфиденциального серверного клиента может дополнительно применяться аутентификация самого клиента. Секрет клиента нельзя хранить в SPA, мобильном приложении или другом публичном клиенте.


OpenID Connect

OpenID Connect добавляет к OAuth 2.0 слой аутентификации.

В OIDC появляется id_token, содержащий информацию о событии входа и пользователе.

Типичный запрос включает scope:

openid profile email

Результат может содержать:

{
  access_token: "access-token",
  token_type: "Bearer",
  expires_in: 900,
  id_token: "id-token"
}

Назначение токенов различается:

access_token — доступ к API
id_token     — информация об аутентификации пользователя

Нельзя использовать id_token как обычный access-токен API, если система явно не спроектирована таким образом.

Клиент должен проверять у ID token:

Для реализации входа через внешнего поставщика предпочтительно использовать проверенную OIDC-библиотеку, а не разбирать протокол вручную.


Client Credentials Flow

Client Credentials Flow используется для взаимодействия сервисов без участия пользователя.

Service A
   ↓ client credentials
Authorization Server
   ↓ access token
Service A
   ↓ access token
Service B

Пример получения токена:

async function getServiceAccessToken({
  clientId,
  clientSecret,
}) {
  const credentials = Buffer
    .from(
      `${clientId}:${clientSecret}`,
      "utf8",
    )
    .toString("base64");

  const response = await fetch(
    "https://auth.example.com/token",
    {
      method: "POST",
      headers: {
        Authorization:
          `Basic ${credentials}`,
        "Content-Type":
          "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({
        grant_type:
          "client_credentials",
        scope: "reports:write",
      }),
    },
  );

  if (!response.ok) {
    throw new Error(
      "Не удалось получить service token",
    );
  }

  return response.json();
}

Этот поток подходит для:

Он не должен использоваться в браузерном приложении, потому что браузер не может безопасно хранить client_secret.


Device Authorization Flow

Device Authorization Flow подходит для устройств, на которых неудобно выполнять обычный браузерный вход:

Сценарий:

Устройство запрашивает device code
                 ↓
Показывает пользователю адрес и короткий код
                 ↓
Пользователь открывает адрес на другом устройстве
                 ↓
Входит и подтверждает доступ
                 ↓
Устройство получает токен

Устройство получает значения, похожие на:

{
  device_code: "device-secret-code",
  user_code: "ABCD-EFGH",
  verification_uri:
    "https://auth.example.com/device",
  expires_in: 600,
  interval: 5
}

Клиент должен соблюдать указанный интервал опроса и корректно обрабатывать окончание срока.


Refresh Token Grant

OAuth-клиент может использовать refresh-токен для получения нового access-токена:

async function refreshAccessToken(
  refreshToken,
) {
  const response = await fetch(
    "https://auth.example.com/token",
    {
      method: "POST",
      headers: {
        "Content-Type":
          "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: refreshToken,
        client_id: "example-client",
      }),
    },
  );

  if (!response.ok) {
    throw new Error(
      "Не удалось обновить токен",
    );
  }

  return response.json();
}

Authorization Server может вернуть новый refresh-токен. В таком случае старый необходимо заменить.


Потоки, которых следует избегать

Implicit Flow

Implicit Flow исторически возвращал access-токен через браузерное перенаправление без обмена authorization code.

Для новых приложений предпочтителен:

Authorization Code Flow + PKCE

Он лучше контролирует обмен и не передаёт access-токен через fragment URL как основную часть потока.

Resource Owner Password Credentials

В password grant приложение напрямую получало логин и пароль пользователя и обменивало их на токен.

Такой подход:

Для нового приложения следует использовать Authorization Code Flow с PKCE или другой подходящий современный поток.


OAuth и собственная система токенов

OAuth не нужен только потому, что API использует Bearer-токены.

Собственная схема:

POST /login
email + password
      ↓
собственные access/refresh tokens

не становится OAuth автоматически.

OAuth оправдан, если требуется:

Для простого приложения с одним frontend и backend может быть достаточно серверной сессии.


RBAC

RBAC (Role-Based Access Control) — управление доступом на основе ролей.

Пользователю назначаются роли:

const user = {
  id: "user-42",
  roles: ["editor"],
};

Роли получают разрешения:

const rolePermissions = {
  reader: [
    "article:read",
  ],

  editor: [
    "article:read",
    "article:create",
    "article:update",
  ],

  admin: [
    "article:read",
    "article:create",
    "article:update",
    "article:delete",
    "user:read",
    "user:update",
  ],
};

Проверяется не название роли само по себе, а необходимое разрешение.


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

function getUserPermissions(user) {
  const permissions = new Set();

  for (const role of user.roles) {
    const rolePermissionList =
      rolePermissions[role] ?? [];

    for (
      const permission
      of rolePermissionList
    ) {
      permissions.add(permission);
    }
  }

  return permissions;
}

function hasPermission(
  user,
  requiredPermission,
) {
  return getUserPermissions(user).has(
    requiredPermission,
  );
}

Использование:

if (
  !hasPermission(
    request.user,
    "article:delete",
  )
) {
  return response.status(403).json({
    error: "forbidden",
    message: "Недостаточно прав",
  });
}

Middleware RBAC

function requirePermission(permission) {
  return function authorize(
    request,
    response,
    next,
  ) {
    if (!request.user) {
      return response.status(401).json({
        error: "authentication_required",
        message:
          "Требуется аутентификация",
      });
    }

    if (
      !hasPermission(
        request.user,
        permission,
      )
    ) {
      return response.status(403).json({
        error: "forbidden",
        message: "Недостаточно прав",
      });
    }

    return next();
  };
}

Использование в маршруте:

router.delete(
  "/articles/:articleId",
  authenticate,
  requirePermission("article:delete"),
  articleController.delete,
);

Порядок важен:

Аутентификация
      ↓
Проверка разрешения
      ↓
Выполнение операции

Проверка принадлежности ресурса

Одних ролей часто недостаточно.

Например, редактор может менять только собственные статьи:

async function canUpdateArticle({
  user,
  article,
}) {
  if (
    hasPermission(
      user,
      "article:update:any",
    )
  ) {
    return true;
  }

  if (
    hasPermission(
      user,
      "article:update:own",
    ) &&
    article.authorId === user.id
  ) {
    return true;
  }

  return false;
}

Сервис:

class UpdateArticle {
  constructor(articleRepository) {
    this.articleRepository =
      articleRepository;
  }

  async execute({
    currentUser,
    articleId,
    changes,
  }) {
    const article =
      await this.articleRepository.findById(
        articleId,
      );

    if (!article) {
      throw new Error(
        "Статья не найдена",
      );
    }

    const isAllowed =
      await canUpdateArticle({
        user: currentUser,
        article,
      });

    if (!isAllowed) {
      throw new Error(
        "Недостаточно прав",
      );
    }

    return this.articleRepository.update(
      articleId,
      changes,
    );
  }
}

Авторизацию необходимо проверять на сервере. Скрытая кнопка в интерфейсе не является защитой:

if (!userCanDelete) {
  deleteButton.hidden = true;
}

Злоумышленник может отправить HTTP-запрос напрямую.


Роли в JWT

Роли или permissions можно включить в access-токен:

const payload = {
  sub: "user-42",
  roles: ["editor"],
  permissions: [
    "article:read",
    "article:update:own",
  ],
};

Преимущество — API может принимать решение без дополнительного запроса к базе.

Недостаток — данные могут устареть.

Например:

12:00 — пользователю выдан JWT с ролью admin
12:05 — роль admin удалена
12:10 — старый JWT всё ещё содержит admin

Возможные решения:

Для высокорисковых операций полезно проверять актуальное состояние пользователя, даже если токен подписан корректно.


Роли и permissions

Проверка только ролей:

if (user.roles.includes("admin")) {
  // ...
}

может привести к большому количеству условий:

if (
  role === "admin" ||
  role === "moderator" ||
  role === "owner"
) {
  // ...
}

Гибче проверять конкретное разрешение:

if (
  hasPermission(
    user,
    "comment:delete:any",
  )
) {
  // ...
}

Роли становятся группами разрешений:

const rolePermissions = {
  moderator: [
    "comment:read",
    "comment:delete:any",
  ],

  author: [
    "comment:read",
    "comment:delete:own",
  ],
};

Иерархия ролей

Иногда старшая роль наследует возможности младшей:

const roleHierarchy = {
  reader: [],

  editor: [
    "reader",
  ],

  admin: [
    "editor",
  ],
};

Но иерархия может усложнить анализ прав. В больших системах понятнее хранить явный набор permissions для каждой роли или вычислять его централизованно.

Не следует считать роль строкой с магическим уровнем:

if (user.role >= "admin") {
  // Некорректное сравнение
}

Полный пример аутентификации

Ниже приведена упрощённая структура:

POST /auth/register
POST /auth/login
POST /auth/refresh
POST /auth/logout
GET  /profile
DELETE /users/:id

TokenService

import { SignJWT, jwtVerify } from "jose";
import {
  createHash,
  randomBytes,
  randomUUID,
} from "node:crypto";

class TokenService {
  constructor({
    accessTokenSecret,
    issuer,
    audience,
  }) {
    this.accessTokenSecret =
      new TextEncoder().encode(
        accessTokenSecret,
      );

    this.issuer = issuer;
    this.audience = audience;
  }

  async createAccessToken(user) {
    return new SignJWT({
      roles: user.roles,
      tokenType: "access",
    })
      .setProtectedHeader({
        alg: "HS256",
        typ: "JWT",
      })
      .setSubject(user.id)
      .setIssuer(this.issuer)
      .setAudience(this.audience)
      .setIssuedAt()
      .setExpirationTime("15m")
      .setJti(randomUUID())
      .sign(this.accessTokenSecret);
  }

  async verifyAccessToken(token) {
    const { payload } = await jwtVerify(
      token,
      this.accessTokenSecret,
      {
        issuer: this.issuer,
        audience: this.audience,
        algorithms: ["HS256"],
      },
    );

    if (
      payload.tokenType !== "access"
    ) {
      throw new Error(
        "Некорректный тип токена",
      );
    }

    return {
      id: payload.sub,
      roles: payload.roles ?? [],
      tokenId: payload.jti,
    };
  }

  createRefreshToken() {
    return randomBytes(48)
      .toString("base64url");
  }

  hashRefreshToken(token) {
    return createHash("sha256")
      .update(token)
      .digest("hex");
  }
}

AuthService

import bcrypt from "bcrypt";
import { randomUUID } from "node:crypto";

class AuthService {
  constructor({
    userRepository,
    refreshTokenRepository,
    tokenService,
    passwordCost = 12,
  }) {
    this.userRepository = userRepository;
    this.refreshTokenRepository =
      refreshTokenRepository;
    this.tokenService = tokenService;
    this.passwordCost = passwordCost;
  }

  async register(input) {
    const email = input.email
      .trim()
      .toLowerCase();

    const existingUser =
      await this.userRepository.findByEmail(
        email,
      );

    if (existingUser) {
      throw new Error(
        "Пользователь уже существует",
      );
    }

    validatePassword(input.password);

    const passwordHash =
      await bcrypt.hash(
        input.password,
        this.passwordCost,
      );

    const user =
      await this.userRepository.create({
        email,
        passwordHash,
        roles: ["user"],
      });

    return {
      id: user.id,
      email: user.email,
      roles: user.roles,
    };
  }

  async login(input) {
    const email = input.email
      .trim()
      .toLowerCase();

    const user =
      await this.userRepository.findByEmail(
        email,
      );

    if (!user) {
      throw new Error(
        "Неверный email или пароль",
      );
    }

    const passwordIsValid =
      await bcrypt.compare(
        input.password,
        user.passwordHash,
      );

    if (!passwordIsValid) {
      throw new Error(
        "Неверный email или пароль",
      );
    }

    if (user.status !== "active") {
      throw new Error(
        "Учётная запись недоступна",
      );
    }

    return this.createTokenPair(user);
  }

  async createTokenPair(user) {
    const accessToken =
      await this.tokenService
        .createAccessToken(user);

    const refreshToken =
      this.tokenService
        .createRefreshToken();

    const familyId = randomUUID();

    await this.refreshTokenRepository.create({
      id: randomUUID(),
      familyId,
      userId: user.id,
      tokenHash:
        this.tokenService
          .hashRefreshToken(refreshToken),
      expiresAt: new Date(
        Date.now() +
          30 * 24 * 60 * 60 * 1000,
      ),
      revokedAt: null,
    });

    return {
      accessToken,
      refreshToken,
      expiresIn: 15 * 60,
    };
  }
}

AuthController

class AuthController {
  constructor(authService) {
    this.authService = authService;
  }

  login = async (
    request,
    response,
    next,
  ) => {
    try {
      const result =
        await this.authService.login({
          email: request.body.email,
          password: request.body.password,
        });

      setRefreshTokenCookie(
        response,
        result.refreshToken,
      );

      return response.status(200).json({
        accessToken:
          result.accessToken,
        expiresIn:
          result.expiresIn,
      });
    } catch (error) {
      return next(error);
    }
  };

  logout = async (
    request,
    response,
    next,
  ) => {
    try {
      const refreshToken =
        request.cookies[
          "__Host-refresh_token"
        ];

      await this.authService.logout(
        refreshToken,
      );

      clearRefreshTokenCookie(
        response,
      );

      return response
        .status(204)
        .end();
    } catch (error) {
      return next(error);
    }
  };
}

Аутентификация и RBAC в маршрутах

const authenticate =
  createAuthenticationMiddleware({
    verifyToken: (token) => {
      return tokenService
        .verifyAccessToken(token);
    },
  });

router.get(
  "/profile",
  authenticate,
  profileController.getCurrent,
);

router.delete(
  "/users/:userId",
  authenticate,
  requirePermission("user:delete"),
  userController.delete,
);

Но request.user из токена содержит роли, а requirePermission() ожидает permissions. Их нужно вычислить в одном централизованном месте:

function enrichUserWithPermissions(user) {
  return {
    ...user,
    permissions: [
      ...getUserPermissions(user),
    ],
  };
}

Или использовать hasPermission() непосредственно на основе ролей.


Восстановление пароля

Сброс пароля не должен отправлять пользователю старый пароль. Сервер не должен его знать.

Безопасная схема:

Пользователь запрашивает восстановление
             ↓
Сервер создаёт случайный одноразовый токен
             ↓
В базе хранится хеш токена и срок жизни
             ↓
Пользователь получает ссылку
             ↓
Предъявляет токен и новый пароль
             ↓
Сервер проверяет токен
             ↓
Хеширует новый пароль через bcrypt
             ↓
Отзывает активные сессии или refresh-токены

Создание токена:

import {
  createHash,
  randomBytes,
} from "node:crypto";

function createPasswordResetToken() {
  const rawToken = randomBytes(32)
    .toString("base64url");

  const tokenHash = createHash("sha256")
    .update(rawToken)
    .digest("hex");

  return {
    rawToken,
    tokenHash,
  };
}

Токен должен быть:

Ответ endpoint восстановления не должен раскрывать существование email:

return {
  message:
    "Если учётная запись существует, инструкции отправлены",
};

Многофакторная аутентификация

Пароль является одним фактором — знанием.

Дополнительными факторами могут быть:

MFA особенно полезна для:

Для критичной операции можно потребовать недавнюю аутентификацию:

function requireRecentAuthentication(
  authenticationTime,
  maxAgeMilliseconds,
) {
  const age =
    Date.now() -
    authenticationTime.getTime();

  if (age > maxAgeMilliseconds) {
    throw new Error(
      "Требуется повторная аутентификация",
    );
  }
}

Наличие действительного access-токена не всегда означает, что чувствительную операцию следует разрешить без дополнительного подтверждения.


Журналирование событий безопасности

Следует журналировать:

Пример:

logger.info("Authentication succeeded", {
  userId: user.id,
  requestId: request.id,
  ipAddress: request.ip,
});

Не следует записывать:

Нежелательно:

logger.info("Login request", {
  body: request.body,
  authorization:
    request.headers.authorization,
});

Предпочтительно:

logger.info("Login request", {
  emailHash: hashForAudit(
    request.body.email,
  ),
  requestId: request.id,
});

Даже email может быть персональными данными, поэтому журналирование необходимо проектировать с учётом срока хранения и доступа.


Частые ошибки

Хранение пароля в открытом виде

Нельзя:

await database.users.insert({
  email,
  password,
});

Нужно:

const passwordHash =
  await bcrypt.hash(password, 12);

await database.users.insert({
  email,
  passwordHash,
});

Использование обычного SHA-256 для пароля

Нежелательно:

const passwordHash = createHash("sha256")
  .update(password)
  .digest("hex");

SHA-256 слишком быстр для хеширования пользовательских паролей. Для паролей нужна специализированная медленная функция, например bcrypt или Argon2id.

SHA-256 подходит для хеширования случайного токена с высокой энтропией, но не слабого пользовательского пароля.


JWT без проверки срока

Нельзя просто декодировать payload:

const payload = decodeJwt(token);

request.user = payload;

Необходимо криптографически проверить токен:

const user =
  await verifyAccessToken(token);

Доверие к роли от клиента

Нельзя принимать роль из тела запроса:

const role = request.body.role;

if (role === "admin") {
  // Разрешить действие
}

Роль должна поступать из доверенного источника:


Долгоживущий access-токен

Нежелательно выдавать access-токен на несколько месяцев без механизма отзыва.

Лучше использовать:

короткий access-токен
+
отзываемый refresh-токен

Один JWT для всех назначений

Нельзя без проверки использовать refresh-токен как access-токен.

Полезно указывать тип:

{
  tokenType: "access"
}

или:

{
  tokenType: "refresh"
}

И проверять его:

if (
  payload.tokenType !== "access"
) {
  throw new Error(
    "Некорректный тип токена",
  );
}

Секрет в исходном коде

Нежелательно:

const jwtSecret =
  "production-secret";

Секрет должен поступать из защищённой конфигурации:

const jwtSecret =
  process.env.ACCESS_TOKEN_SECRET;

Но переменная окружения сама по себе не решает все задачи. Необходимо также контролировать:


Проверка прав только во frontend

Нельзя полагаться на скрытие кнопки:

if (!isAdmin) {
  deleteButton.remove();
}

Сервер обязан повторно проверить право:

router.delete(
  "/users/:id",
  authenticate,
  requirePermission("user:delete"),
  deleteUser,
);

Frontend-проверка улучшает интерфейс, но не обеспечивает безопасность.


Отсутствие ограничения входа

Без ограничений злоумышленник может многократно проверять пароли.

Необходимы:

Блокировка только по IP может затронуть множество пользователей общей сети и обходиться распределёнными запросами. Обычно учитывается сочетание IP, учётной записи, устройства и поведения.


Рекомендуемая архитектура

Controller
    ↓
AuthService / Use Case
    ↓
UserRepository
RefreshTokenRepository
PasswordHasher
TokenService
AuditLogger

Контракты можно разделить:

class AuthService {
  constructor({
    userRepository,
    passwordHasher,
    accessTokenService,
    refreshTokenRepository,
  }) {
    this.userRepository = userRepository;
    this.passwordHasher = passwordHasher;
    this.accessTokenService =
      accessTokenService;
    this.refreshTokenRepository =
      refreshTokenRepository;
  }
}

Обёртка над bcrypt:

import bcrypt from "bcrypt";

class BcryptPasswordHasher {
  constructor(cost = 12) {
    this.cost = cost;
  }

  hash(password) {
    return bcrypt.hash(
      password,
      this.cost,
    );
  }

  compare(password, hash) {
    return bcrypt.compare(
      password,
      hash,
    );
  }
}

Тестовая реализация:

class FakePasswordHasher {
  async hash(password) {
    return `hashed:${password}`;
  }

  async compare(password, hash) {
    return hash === `hashed:${password}`;
  }
}

Это позволяет тестировать сценарии без реального bcrypt, если в конкретном тесте проверяется бизнес-логика, а не криптографическая библиотека.


Практический выбор подхода

Для обычного серверного веб-приложения:

Server-side session
+
HttpOnly Secure SameSite cookie
+
CSRF-защита

часто является простым и надёжным решением.

Для SPA с собственным API возможна схема:

Короткоживущий access-токен в памяти
+
Refresh-токен в HttpOnly cookie
+
ротация refresh-токенов
+
CSRF-защита refresh endpoint

Для нескольких API и централизованной идентификации:

OAuth 2.0 / OpenID Connect
+
Authorization Code Flow with PKCE
+
короткоживущие access-токены

Для межсервисного взаимодействия без пользователя:

Client Credentials Flow
+
минимальные scopes
+
ротация клиентских ключей

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


Краткая памятка

Аутентификация и авторизация:

Authentication — кто пользователь?
Authorization  — что ему разрешено?

Статусы:

401 — аутентификация отсутствует или не прошла
403 — аутентификация прошла, но доступ запрещён

Пароли:

Пароль → bcrypt → passwordHash

Основные правила bcrypt:

Структура JWT:

header.payload.signature

JWT необходимо проверять по:

signature
algorithm
exp
nbf
iss
aud
token type

JWT не обеспечивает:

шифрование payload
автоматический отзыв
защиту украденного токена
актуальность ролей

Токены:

Access token  — короткий срок, доступ к API
Refresh token — длительный срок, обновление access token

Рекомендуемый refresh-механизм:

случайный непрозрачный токен
+
хеш токена в базе
+
ротация
+
обнаружение повторного использования
+
возможность отзыва

OAuth 2.0:

Authorization Code + PKCE — вход пользователя и делегированный доступ
Client Credentials        — сервис-сервис
Device Authorization      — устройства с ограниченным вводом
Refresh Token             — обновление доступа

Для аутентификации пользователя поверх OAuth применяется:

OpenID Connect

RBAC:

User → Roles → Permissions → Action

Основные правила безопасности: