Auth. JWT, OAuth 2.0 и управление доступом
Auth обычно объединяет несколько связанных, но разных задач:
- идентификацию пользователя;
- проверку его личности;
- выдачу учётных данных;
- проверку прав доступа;
- завершение сеанса;
- обновление и отзыв токенов;
- защиту паролей и токенов.
Упрощённый сценарий входа:
Пользователь вводит email и пароль
↓
Сервер находит пользователя
↓
Сервер проверяет хеш пароля
↓
Сервер создаёт сессию или выдаёт токены
↓
Клиент передаёт учётные данные в новых запросах
↓
Сервер проверяет личность и права доступаВажно разделять:
Аутентификация — кто выполняет запрос?
Авторизация — что этому субъекту разрешено?Содержание
- Аутентификация и авторизация
- Хеширование и шифрование
- Хеширование пароля в JavaScript
- Cost factor bcrypt
- Регистрация пользователя
- Вход пользователя
- Ограничения bcrypt
- Pepper
- Обновление старого хеша
- Структура JWT
- JWT не шифрует payload
- Подпись JWT
- Стандартные claims JWT
- Создание JWT в JavaScript
- Проверка JWT
- Извлечение Bearer-токена
- JWT и отзыв токена
- Access token
- Refresh token
- Refresh token не обязан быть JWT
- Семейство токенов
- Упрощённая ротация
- Хранение токенов в браузере
- CSRF и cookie
- Создание сессии
- Проверка сессии
- Сравнение сессий и JWT
- Участники OAuth 2.0
- Scope
- Создание PKCE-параметров
- Начало Authorization Code Flow
- Обработка callback
- Потоки, которых следует избегать
- Проверка разрешений
- Middleware RBAC
- Проверка принадлежности ресурса
- Роли в JWT
- Роли и permissions
- Иерархия ролей
- TokenService
- AuthService
- AuthController
- Аутентификация и RBAC в маршрутах
- Хранение пароля в открытом виде
- Использование обычного SHA-256 для пароля
- JWT без проверки срока
- Доверие к роли от клиента
- Долгоживущий access-токен
- Один JWT для всех назначений
- Секрет в исходном коде
- Проверка прав только во frontend
- Отсутствие ограничения входа
Аутентификация и авторизация
Аутентификация
Аутентификация подтверждает личность пользователя, приложения или сервиса.
Примеры способов аутентификации:
- email и пароль;
- логин и пароль;
- одноразовый код;
- аппаратный ключ;
- сертификат;
- токен внешнего провайдера;
- биометрия;
- API-ключ.
После успешной аутентификации система может определить субъекта:
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В таком значении содержатся:
- версия алгоритма;
- cost factor;
- соль;
- результат хеширования.
Исходный пароль из такого значения непосредственно получить нельзя.
Хеширование пароля в 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 — формат токена;
- access-токен — назначение токена;
- access-токен может быть JWT или непрозрачной случайной строкой.
JWT выглядит как три части, разделённые точками:
header.payload.signatureПример:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9
.
eyJzdWIiOiJ1c2VyLTQyIiwicm9sZSI6ImVkaXRvciJ9
.
signatureОбычно токен записывается одной строкой без переносов.
Структура JWT
Header
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
Подпись обеспечивает:
- проверку целостности;
- подтверждение того, что токен выпустила сторона с нужным ключом.
Подпись не обеспечивает:
- конфиденциальность payload;
- защиту токена после кражи;
- автоматический отзыв;
- подтверждение актуальности ролей;
- безопасное хранение на клиенте.
Симметричная подпись
При 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,
};
}Проверять необходимо не только подпись, но и:
- разрешённый алгоритм;
exp;nbf, если используется;iss;aud;- назначение токена;
- обязательные claims.
Нельзя выбирать алгоритм только на основании недоверенного header без ограничений:
const { alg } = decodedHeader;Библиотеке следует передать явный список разрешённых алгоритмов:
{
algorithms: ["HS256"]
}Извлечение Bearer-токена
Клиент отправляет access-токен:
Authorization: Bearer access-tokenJavaScript-функция извлечения:
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.
Подходы к отзыву:
- короткий срок жизни access-токена;
- блок-лист по
jti; - версия токенов пользователя;
- проверка состояния пользователя в базе;
- смена ключа подписи;
- непрозрачные токены с серверной проверкой.
Пример версии токенов:
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 tokenAccess token
Access-токен обычно:
- имеет короткий срок жизни;
- отправляется в
Authorization; - используется часто;
- содержит минимально необходимые claims;
- проверяется API;
- может быть JWT или непрозрачной строкой.
GET /api/profile HTTP/1.1
Authorization: Bearer access-tokenAccess-токен не следует помещать в URL:
Нежелательно:
https://api.example.com/profile?access_token=secretURL может попасть:
- в историю браузера;
- в журналы сервера;
- в аналитику;
- в заголовок
Referer; - в снимки экрана;
- в историю прокси.
Refresh token
Refresh-токен обычно:
- живёт дольше access-токена;
- используется только на endpoint обновления;
- должен храниться особенно защищённо;
- должен отзываться;
- желательно заменяется после использования;
- не должен предоставлять прямой доступ к бизнес-API.
Пример запроса:
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,
};
}Операция отзыва старого токена и создания нового должна выполняться атомарно, например внутри транзакции. Иначе два параллельных запроса могут успешно обновить один токен.
Хранение токенов в браузере
Нет одного способа, подходящего для всех приложений.
HttpOnly cookie
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 подходящему серверу.
Преимущества:
- JavaScript не может непосредственно прочитать токен;
- снижается риск прямой кражи токена через XSS.
Ограничения:
- XSS всё равно может выполнять действия от имени пользователя;
- автоматическая отправка cookie требует защиты от CSRF;
- необходимо правильно настроить
Secure,SameSite,Pathи CORS; - cookie имеет ограничения по домену и размеру.
Префикс __Host- требует:
Secure;Path=/;- отсутствия атрибута
Domain.
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.
CSRF и cookie
Если аутентификационные данные отправляются автоматически в cookie, сервер должен учитывать CSRF.
Возможные меры:
SameSite=LaxилиSameSite=Strict, если сценарий это допускает;- CSRF-токен;
- проверка
Origin; - проверка
Refererкак дополнительная мера; - отказ от изменения состояния через
GET; - подтверждение чувствительных операций;
- повторная аутентификация.
Пример проверки 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 и распределённых систем |
Когда удобны сессии
Сессии часто подходят, если:
- клиентом является одно веб-приложение;
- сервер контролирует frontend и backend;
- нужен простой немедленный выход;
- права часто меняются;
- уже есть надёжное хранилище сессий;
- не требуется автономная проверка токена несколькими сервисами.
Когда удобны токены
Токены могут быть удобны, если:
- API используют разные клиенты;
- есть мобильные приложения;
- несколько сервисов проверяют access-токен;
- применяется OAuth 2.0;
- требуется делегированный доступ;
- границы сервисов явно определены.
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:
- подпись;
- издателя;
- audience;
- срок действия;
nonce;- другие обязательные требования провайдера.
Для реализации входа через внешнего поставщика предпочтительно использовать проверенную 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 оправдан, если требуется:
- делегированный доступ;
- несколько независимых клиентов;
- сторонние интеграции;
- централизованный Authorization Server;
- scopes;
- вход через внешнего поставщика;
- единая система идентификации нескольких приложений.
Для простого приложения с одним 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Возможные решения:
- короткий срок жизни access-токена;
- проверка критических прав в базе;
- версия ролей или разрешений;
- отзыв активных сессий;
- непрозрачные токены с introspection;
- минимальный набор claims.
Для высокорисковых операций полезно проверять актуальное состояние пользователя, даже если токен подписан корректно.
Роли и 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/:idTokenService
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,
};
}Токен должен быть:
- одноразовым;
- случайным;
- ограниченным по времени;
- привязанным к пользователю;
- аннулированным после использования;
- переданным только по HTTPS.
Ответ endpoint восстановления не должен раскрывать существование email:
return {
message:
"Если учётная запись существует, инструкции отправлены",
};Многофакторная аутентификация
Пароль является одним фактором — знанием.
Дополнительными факторами могут быть:
- устройство или аппаратный ключ;
- одноразовый код;
- приложение-аутентификатор;
- биометрическая характеристика.
MFA особенно полезна для:
- административного доступа;
- финансовых операций;
- смены email;
- управления ключами;
- просмотра чувствительных данных;
- восстановления учётной записи.
Для критичной операции можно потребовать недавнюю аутентификацию:
function requireRecentAuthentication(
authenticationTime,
maxAgeMilliseconds,
) {
const age =
Date.now() -
authenticationTime.getTime();
if (age > maxAgeMilliseconds) {
throw new Error(
"Требуется повторная аутентификация",
);
}
}Наличие действительного access-токена не всегда означает, что чувствительную операцию следует разрешить без дополнительного подтверждения.
Журналирование событий безопасности
Следует журналировать:
- успешные и неуспешные входы;
- смену пароля;
- восстановление доступа;
- включение и отключение MFA;
- выдачу и отзыв refresh-сессий;
- изменение ролей;
- подозрительное повторное использование refresh-токена;
- административные действия.
Пример:
logger.info("Authentication succeeded", {
userId: user.id,
requestId: request.id,
ipAddress: request.ip,
});Не следует записывать:
- пароль;
- access-токен;
- refresh-токен;
- cookie целиком;
- секрет клиента;
- приватный ключ;
- код восстановления.
Нежелательно:
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;Но переменная окружения сама по себе не решает все задачи. Необходимо также контролировать:
- доступ к окружению;
- журналы;
- резервные копии;
- ротацию;
- конфигурацию CI;
- права процесса.
Проверка прав только во frontend
Нельзя полагаться на скрытие кнопки:
if (!isAdmin) {
deleteButton.remove();
}Сервер обязан повторно проверить право:
router.delete(
"/users/:id",
authenticate,
requirePermission("user:delete"),
deleteUser,
);Frontend-проверка улучшает интерфейс, но не обеспечивает безопасность.
Отсутствие ограничения входа
Без ограничений злоумышленник может многократно проверять пароли.
Необходимы:
- rate limiting;
- задержки или временные ограничения;
- мониторинг;
- уведомления о подозрительных входах;
- MFA;
- защита от распределённых атак.
Блокировка только по 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:
- не хранить исходный пароль;
- не использовать быстрый SHA-256 вместо password hasher;
- использовать уникальную соль;
- выбирать cost factor по измерениям;
- сравнивать через
bcrypt.compare(); - ограничивать частоту входа;
- не возвращать
passwordHashклиенту.
Структура JWT:
header.payload.signatureJWT необходимо проверять по:
signature
algorithm
exp
nbf
iss
aud
token typeJWT не обеспечивает:
шифрование payload
автоматический отзыв
защиту украденного токена
актуальность ролейТокены:
Access token — короткий срок, доступ к API
Refresh token — длительный срок, обновление access tokenРекомендуемый refresh-механизм:
случайный непрозрачный токен
+
хеш токена в базе
+
ротация
+
обнаружение повторного использования
+
возможность отзываOAuth 2.0:
Authorization Code + PKCE — вход пользователя и делегированный доступ
Client Credentials — сервис-сервис
Device Authorization — устройства с ограниченным вводом
Refresh Token — обновление доступаДля аутентификации пользователя поверх OAuth применяется:
OpenID ConnectRBAC:
User → Roles → Permissions → ActionОсновные правила безопасности:
- передавайте пароли и токены только через HTTPS;
- не помещайте токены в URL;
- не записывайте секреты в журналы;
- не храните серверные секреты во frontend;
- выдавайте минимально необходимые права;
- проверяйте разрешения на сервере;
- используйте короткоживущие access-токены;
- отзывайте и ротируйте refresh-токены;
- защищайте cookie-запросы от CSRF;
- защищайте приложение от XSS;
- не доверяйте данным о роли из тела запроса;
- проверяйте
issuer,audienceи алгоритм JWT; - используйте одинаковое сообщение для неизвестного пользователя и неверного пароля;
- отзывайте активные сессии после критических изменений;
- применяйте MFA для чувствительных операций;
- повторяйте критические проверки непосредственно перед выполнением операции.