Как делать11 октября 2026 г.7 мин чтения
Spring BootШаг 6 из 7

Авторизация: роли, права и проверка доступа на сервере

Коротко

Авторизация решает, что пользователю можно, и проверяется только на сервере — при каждом запросе. Права удобно описать матрицей «роль → разрешённые действия» и проверять в middleware маршрута, а доступ к конкретной записи — отдельно, ограничивая сам запрос к данным записями, которые пользователь вправе видеть. Без входа сервер отвечает 401, без права на действие — 403, а чужую запись лучше отдавать как 404, чтобы не подтверждать её существование.

РуководствоУровень: СреднийАвторизацияПрактика: ~1–2 ч

После материала вы сможете

  • описать модель ролей и прав (RBAC) матрицей доступа и закодировать её с запретом по умолчанию
  • проверять право на действие в middleware requirePermission, а право на запись — ограниченным поиском findLeadFor
  • выбрать между 401, 403 и 404 для каждого отказа и объяснить выбор
  • написать supertest-тесты на отказ: без входа, без права, чужая запись, подмена полей в теле запроса

Проверено на

Node.js 24 LTS (24.18)Express 5.3express-session 1.19TypeScript 7.0Vitest 5.0supertest 7.3

Сверено с документацией: 11 октября 2026 г.

Содержание
  1. 01Почему проверка на фронтенде не считается
  2. 02RBAC: роли, права и матрица доступа
  3. 03Проверка на маршруте: middleware
  4. 04Проверка на уровне объекта: «это моя запись?» (IDOR)
  5. 05401, 403 или 404: что возвращать
  6. 06Тесты на запрет доступа

Почему проверка на фронтенде не считается

Спрятать кнопку «Удалить» от менеджера — это забота об интерфейсе, а не защита. Кнопка лишь отправляет HTTP-запрос, и тот же запрос можно отправить из curl, консоли браузера или скрипта, подставив любой ID. OWASP Authorization Cheat Sheet формулирует жёстко: на проверки на клиенте нельзя полагаться, решение о доступе принимается на сервере, на шлюзе API или в серверной функции.

Нарушение контроля доступа (Broken Access Control) — категория A01 в OWASP Top 10:2025. В её описании — именно те ошибки, о которых этот урок: доступ к чужому аккаунту по его идентификатору, API без проверок для POST, PUT и DELETE, повышение привилегий, подмена JWT, cookie или скрытого поля. Там же основные меры: всё, кроме публичных ресурсов, запрещено по умолчанию; механизм доступа реализован один раз и переиспользуется; модель доступа учитывает владельца записи; проверки доступа входят в unit- и интеграционные тесты.

RBAC: роли, права и матрица доступа

RBAC (Role-Based Access Control) — модель, в которой права назначаются ролям, а пользователь получает права своей роли. Начинают с матрицы: перечисляют роли, ресурсы и действия и для каждой пары явно решают «можно» или «нельзя». OWASP советует делать это ещё на этапе проектирования и сразу писать тесты, которые проверяют матрицу. Пример — админка заявок с сайта:

Видеть заявки

Администратор
Все
Аналитик
Все
Менеджер
Только назначенные ему

Менять статус

Администратор
Любой заявки
Аналитик
Нет
Менеджер
Только своей заявки

Удалять

Администратор
Да
Аналитик
Нет
Менеджер
Нет

Переназначать исполнителя

Администратор
Да (упражнение 3)
Аналитик
Нет
Менеджер
Нет

В коде матрица превращается в список прав для каждой роли. Обратите внимание на lead:all: это право снимает ограничение «только свои заявки». Так горизонтальное ограничение (своё и чужое у пользователей одной роли) задаётся той же матрицей, что и вертикальное (обычный пользователь и администратор).

src/permissions.ts
import type { NextFunction, Request, Response } from "express";
import type { Role, User } from "./users.ts";

export type Permission = "lead:read" | "lead:update" | "lead:delete" | "lead:all";

// Матрица доступа. Чего здесь нет — запрещено (deny by default).
// lead:all снимает ограничение «только назначенные мне заявки».
const ROLE_PERMISSIONS: Record<Role, readonly Permission[]> = {
  admin: ["lead:read", "lead:update", "lead:delete", "lead:all"],
  analyst: ["lead:read", "lead:all"],
  manager: ["lead:read", "lead:update"],
};

export function can(user: User, permission: Permission): boolean {
  return ROLE_PERMISSIONS[user.role]?.includes(permission) ?? false;
}

// Проверка на уровне функции: разрешено ли это действие вообще.
// Request<Record<string, string>> — чтобы после middleware req.params.id оставался string
export function requirePermission(permission: Permission) {
  return (req: Request<Record<string, string>>, res: Response, next: NextFunction) => {
    const user = res.locals.user;
    if (!user) {
      res.status(401).json({ error: "unauthenticated" });
      return;
    }
    if (!can(user, permission)) {
      res.status(403).json({ error: "forbidden" });
      return;
    }
    next();
  };
}
Матрица ролей и middleware проверки права
  • Проверяйте права, а не названия ролей: can(user, "lead:delete") вместо user.role === "admin". Новая роль — это одна строка в матрице, а не правка десятка условий
  • Запрет по умолчанию: права, которого нет в списке, не существует. Тип Record<Role, …> заставляет TypeScript требовать строку матрицы для каждой роли — забытую роль покажет tsc
  • Минимальные привилегии: аналитику нужно чтение, а не изменение, — OWASP советует давать ровно то, что нужно для работы
  • Роль читается из хранилища при каждом запросе: после понижения роли доступ сужается сразу, а не после повторного входа

Проверка на маршруте: middleware

Первый рубеж — право на действие как таковое. OWASP API Security Top 10 называет его отсутствие Broken Function Level Authorization: обычный пользователь вызывает административную функцию, просто зная URL или сменив метод с GET на DELETE. Поэтому requirePermission стоит на каждом маршруте и срабатывает до того, как код вообще прочитает данные.

src/leads-routes.ts
import { Router } from "express";
import { deleteLead, findLeadFor, LEAD_STATUSES, listLeadsFor, type LeadStatus } from "./leads.ts";
import { requirePermission } from "./permissions.ts";

export const leadsRouter = Router();

leadsRouter.get("/", requirePermission("lead:read"), (req, res) => {
  res.json(listLeadsFor(res.locals.user!));
});

leadsRouter.get("/:id", requirePermission("lead:read"), (req, res) => {
  const lead = findLeadFor(res.locals.user!, req.params.id);
  if (!lead) {
    res.status(404).json({ error: "not_found" });
    return;
  }
  res.json(lead);
});

leadsRouter.patch("/:id", requirePermission("lead:update"), (req, res) => {
  const lead = findLeadFor(res.locals.user!, req.params.id);
  if (!lead) {
    res.status(404).json({ error: "not_found" });
    return;
  }
  const status = req.body?.status;
  if (!LEAD_STATUSES.includes(status)) {
    res.status(400).json({ error: "invalid_status" });
    return;
  }
  // Меняем только разрешённое поле: assigneeId и прочее из тела запроса игнорируются
  lead.status = status as LeadStatus;
  res.json(lead);
});

leadsRouter.delete("/:id", requirePermission("lead:delete"), (req, res) => {
  const lead = findLeadFor(res.locals.user!, req.params.id);
  if (!lead) {
    res.status(404).json({ error: "not_found" });
    return;
  }
  deleteLead(lead.id);
  res.status(204).end();
});
Маршруты заявок: право на действие в middleware, право на запись — в поиске

Роутер подключается к приложению из урока «Аутентификация: сессии, cookies и JWT» одной строкой. requireAuth перед роутером отвечает 401 всем, кто не вошёл, и кладёт пользователя в res.locals.user.

src/app.ts
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,5 +1,6 @@
 import express, { type NextFunction, type Request, type Response } from "express";
 import session from "express-session";
+import { leadsRouter } from "./leads-routes.ts";
 import { findUserById, verifyCredentials, type User } from "./users.ts";
 
 declare module "express-session" {
@@ -108,5 +109,8 @@
     });
   });
 
+  // Все маршруты заявок — только для вошедших; права проверяет сам роутер
+  app.use("/leads", requireAuth, leadsRouter);
+
   return app;
 }
Изменение в app.ts из урока об аутентификации

Проверка входа здесь двойная: requirePermission тоже отвечает 401, если пользователя нет. Мы проверили это, убрав requireAuth из подключения роутера: тесты на 401 продолжили проходить. Такая избыточность — запрет по умолчанию на практике: забытый на одном маршруте guard не превращается в открытый доступ.

Проверка на уровне объекта: «это моя запись?» (IDOR)

Менеджер имеет право lead:update, но только для своих заявок. Если сервер ищет заявку просто по ID из URL, менеджер подставит чужой ID и изменит её. Это IDOR (Insecure Direct Object Reference), в терминах OWASP API Security — Broken Object Level Authorization, первое место в списке API-рисков 2023 года. OWASP описывает три составляющие: объект, ссылка на него (ID, UUID, имя файла) и отсутствующая проверка, что этому пользователю можно работать с этим объектом.

Надёжный приём из OWASP IDOR Prevention Cheat Sheet — искать запись сразу среди доступных пользователю, а не находить и потом проверять. В SQL это одно условие в самом запросе: WHERE id = $1 AND assignee_id = $2 для менеджера. Тогда чужая заявка и несуществующая дают одинаковый результат, и ответ 404 не подтверждает, что запись есть.

src/leads.ts
import { randomUUID } from "node:crypto";
import { can } from "./permissions.ts";
import type { User } from "./users.ts";

export const LEAD_STATUSES = ["new", "in_progress", "done"] as const;
export type LeadStatus = (typeof LEAD_STATUSES)[number];

export type Lead = {
  id: string;
  name: string;
  phone: string;
  status: LeadStatus;
  assigneeId: string;
};

// Учебное хранилище в памяти. В реальном проекте — таблица в базе данных.
const leads = new Map<string, Lead>();

export function addLead(data: Omit<Lead, "id">): Lead {
  const lead: Lead = { id: randomUUID(), ...data };
  leads.set(lead.id, lead);
  return lead;
}

// Единственное место, где решается, чьи заявки видит пользователь.
// В SQL это условие WHERE в самом запросе, а не фильтр после выборки.
function isVisibleTo(user: User, lead: Lead): boolean {
  return can(user, "lead:all") || lead.assigneeId === user.id;
}

export function listLeadsFor(user: User): Lead[] {
  return [...leads.values()].filter((lead) => isVisibleTo(user, lead));
}

// Поиск сразу в границах доступного: чужая заявка неотличима от несуществующей
export function findLeadFor(user: User, id: string): Lead | undefined {
  const lead = leads.get(id);
  return lead && isVisibleTo(user, lead) ? lead : undefined;
}

export function deleteLead(id: string): void {
  leads.delete(id);
}
Хранилище заявок: область видимости задаётся в одном месте
  • Фильтрация в запросе, а не в интерфейсе: список GET /leads для менеджера строится тем же условием isVisibleTo, а не отдаётся целиком в расчёте на фильтр во фронтенде
  • Случайные UUID вместо последовательных номеров затрудняют перебор, но, как подчёркивает OWASP, не заменяют проверку доступа
  • Защита полей: обработчик PATCH меняет только status. Если скопировать тело запроса в запись целиком (Object.assign), менеджер передаст assigneeId и заберёт чужую заявку — это mass assignment из OWASP API3:2023
  • Где возможно, не берите ID из запроса вовсе: «мой профиль» определяется по сессии, а не по userId в URL

401, 403 или 404: что возвращать

Код ответа — часть контракта API: клиент по нему решает, показать ли форму входа или сообщение «недостаточно прав». RFC 9110 разделяет случаи так:

Нет сессии, токен истёк или неверен

Код
401 Unauthorized
Почему
Нет действительных учётных данных; клиенту стоит войти заново. По RFC 9110 ответ должен содержать WWW-Authenticate; для Bearer-токенов это схема из RFC 6750

Вошёл, но роль не разрешает действие

Код
403 Forbidden
Почему
Учётные данные есть, но их недостаточно; повтор того же запроса ничего не изменит

Запись чужая или её нет

Код
404 Not Found
Почему
RFC 9110 разрешает отвечать 404 вместо 403, чтобы скрыть существование ресурса

Тело запроса неверно (например, неизвестный статус)

Код
400 Bad Request
Почему
Отказ не связан с правами — проверка данных после проверки доступа

Порядок проверок важен. Сначала аутентификация (401), затем право на действие (403) — оно не зависит от ID и ничего не раскрывает. Потом поиск записи в доступной области (404) и только после этого проверка данных (400). Если проверять данные раньше доступа, по разнице ответов можно понять, существует ли запись.

Тесты на запрет доступа

Тесты на «можно» пишут все, тесты на «нельзя» — редко, хотя именно они ловят уязвимости. OWASP IDOR Cheat Sheet описывает метод: завести минимум двух пользователей A и B с объектами у каждого, войти как A и пытаться читать, менять и удалять объекты B. Так и устроен тест ниже: четыре пользователя, по одной заявке у двух менеджеров и проверка каждого вида отказа. Помощники login и SAME_ORIGIN — из урока об аутентификации.

test/leads.test.ts
import { randomBytes } from "node:crypto";
import request from "supertest";
import { beforeAll, describe, expect, it } from "vitest";
import { createApp } from "../src/app.ts";
import { addLead, type Lead } from "../src/leads.ts";
import { can, type Permission } from "../src/permissions.ts";
import { createUser, type Role, type User } from "../src/users.ts";
import { login, SAME_ORIGIN } from "./helpers.ts";

const app = createApp({
  sessionSecret: randomBytes(32).toString("hex"),
  appOrigin: "https://app.example.com",
});
const PASSWORD = "long-and-unique-passphrase";
type Who = "admin" | "analyst" | "anna" | "boris";
const cookies = {} as Record<Who, string>;
let anna: User;
let boris: User;
let annaLead: Lead;
let borisLead: Lead;

// Заголовки запроса от имени конкретного пользователя
const as = (who: Who) => ({ ...SAME_ORIGIN, Cookie: cookies[who] });

beforeAll(async () => {
  await createUser("admin@example.com", PASSWORD, "admin");
  await createUser("analyst@example.com", PASSWORD, "analyst");
  anna = await createUser("anna@example.com", PASSWORD, "manager");
  boris = await createUser("boris@example.com", PASSWORD, "manager");
  annaLead = addLead({ name: "Кофейня", phone: "+7 700 000 00 01", status: "new", assigneeId: anna.id });
  borisLead = addLead({ name: "Салон", phone: "+7 700 000 00 02", status: "new", assigneeId: boris.id });
  for (const who of ["admin", "analyst", "anna", "boris"] as const) {
    cookies[who] = (await login(app, `${who}@example.com`, PASSWORD)).cookie!;
  }
});

describe("матрица прав", () => {
  it.each<[Role, Permission, boolean]>([
    ["admin", "lead:delete", true],
    ["manager", "lead:update", true],
    ["manager", "lead:delete", false],
    ["manager", "lead:all", false],
    ["analyst", "lead:all", true],
    ["analyst", "lead:update", false],
  ])("%s → %s: %s", (role, permission, expected) => {
    expect(can({ role } as User, permission)).toBe(expected);
  });
});

describe("401: пользователь не вошёл", () => {
  it("список заявок без cookie", async () => {
    const res = await request(app).get("/leads").set(SAME_ORIGIN);
    expect(res.status).toBe(401);
  });

  it("удаление без cookie", async () => {
    const res = await request(app).delete(`/leads/${annaLead.id}`).set(SAME_ORIGIN);
    expect(res.status).toBe(401);
  });
});

describe("403: роль не разрешает действие", () => {
  it("аналитик не меняет статус", async () => {
    const res = await request(app).patch(`/leads/${annaLead.id}`).set(as("analyst")).send({ status: "done" });
    expect(res.status).toBe(403);
  });

  it("менеджер не удаляет даже свою заявку", async () => {
    const res = await request(app).delete(`/leads/${annaLead.id}`).set(as("anna"));
    expect(res.status).toBe(403);
  });

  it("понижение роли действует со следующего запроса", async () => {
    const vera = await createUser("vera@example.com", PASSWORD, "admin");
    const { cookie } = await login(app, "vera@example.com", PASSWORD);
    vera.role = "manager"; // роль читается из хранилища при каждом запросе
    const res = await request(app).delete(`/leads/${borisLead.id}`).set({ ...SAME_ORIGIN, Cookie: cookie! });
    expect(res.status).toBe(403);
  });
});

describe("IDOR: чужие заявки", () => {
  it("менеджер видит в списке только свои", async () => {
    const res = await request(app).get("/leads").set(as("anna"));
    expect(res.status).toBe(200);
    expect(res.body.map((l: Lead) => l.id)).toEqual([annaLead.id]);
  });

  it("чужая заявка по ID — 404, как несуществующая", async () => {
    const foreign = await request(app).get(`/leads/${borisLead.id}`).set(as("anna"));
    const missing = await request(app).get("/leads/no-such-id").set(as("anna"));
    expect(foreign.status).toBe(404);
    expect(foreign.body).toEqual(missing.body);
  });

  it("чужую заявку нельзя изменить, подставив её ID", async () => {
    const res = await request(app).patch(`/leads/${borisLead.id}`).set(as("anna")).send({ status: "done" });
    expect(res.status).toBe(404);
    const check = await request(app).get(`/leads/${borisLead.id}`).set(as("admin"));
    expect(check.body.status).toBe("new");
  });

  it("assigneeId в теле запроса игнорируется", async () => {
    const res = await request(app)
      .patch(`/leads/${annaLead.id}`).set(as("anna"))
      .send({ status: "in_progress", assigneeId: boris.id });
    expect(res.status).toBe(200);
    expect(res.body).toMatchObject({ status: "in_progress", assigneeId: anna.id });
  });
});

describe("разрешённые действия", () => {
  it("аналитик читает любую заявку", async () => {
    const res = await request(app).get(`/leads/${borisLead.id}`).set(as("analyst"));
    expect(res.status).toBe(200);
  });

  it("администратор удаляет заявку", async () => {
    const lead = addLead({ name: "Студия", phone: "+7 700 000 00 03", status: "done", assigneeId: boris.id });
    const res = await request(app).delete(`/leads/${lead.id}`).set(as("admin"));
    expect(res.status).toBe(204);
    const after = await request(app).get(`/leads/${lead.id}`).set(as("admin"));
    expect(after.status).toBe(404);
  });
});
Матрица прав, 401, 403, IDOR и подмена полей
terminal
$ npx vitest run test/leads.test.ts
 RUN  v5.0.3 ~/auth-demo

 Test Files  1 passed (1)
      Tests  17 passed (17)
17 тестов: 6 строк матрицы и 11 сценариев через HTTP

Чтобы убедиться, что тесты что-то защищают, мы ломали код по одному месту. Без условия владельца в isVisibleTo упали три IDOR-теста. С Object.assign(lead, req.body) вместо присваивания статуса упал тест про assigneeId. С поиском без области видимости в PATCH упал тест про изменение чужой заявки, а с правом lead:read на удалении — тест «менеджер не удаляет даже свою заявку».

  • У каждого маршрута есть явное право; маршрут без requirePermission — повод для вопроса на ревью
  • Каждый маршрут с ID в пути или теле ищет запись в области видимости пользователя
  • Обновление меняет только перечисленные поля; ответ отдаёт только нужные клиенту поля
  • Для каждой роли есть тест хотя бы на одно запрещённое действие; для IDOR — тест «пользователь A против объекта B»
  • Отказы доступа пишутся в журнал, а частые отказы от одного пользователя видны администратору
Как проверено3 · пройденосверено 11 октября 2026 г.
  • запущено, работает

    src/permissions.ts, src/leads.ts, src/leads-routes.ts, diff src/app.ts, test/leads.test.ts (поверх проекта из autentifikaciya-sessii-i-jwt)

    временный проект в scratchpad (копия проекта B09 + новые файлы): tsc -p . (TypeScript 7.0.2) без ошибок; vitest run — 31/31 (в т. ч. test/leads.test.ts 17/17: 6 строк матрицы + 11 HTTP-сценариев); Node.js 24.18.0, Express 5.3.0, @types/express 5.0.6 / express-serve-static-core 5.1.3, express-session 1.19.0, Vitest 5.0.3, supertest 7.3.1. Тип Request<Record<string, string>> в requirePermission подобран после ошибки tsc TS2345 (req.params.id: string | string[] при middleware с типом Request по умолчанию)

  • запущено, работает

    проверка тестов «от обратного» (мутации)

    isVisibleTo → true: 3 IDOR-теста падают; Object.assign(lead, req.body): падает тест assigneeId; поиск без области видимости в PATCH: падает тест «чужую заявку нельзя изменить»; lead:read на DELETE: падает «менеджер не удаляет даже свою заявку» (+ каскад из-за удалённой заявки); без requireAuth в app.use — тесты 401 проходят (двойная проверка в requirePermission), разрешённые сценарии падают; без 401-ветки и без requireAuth — тесты 401 падают. После отката 31/31

  • запущено, работает

    решения упражнений 2 (роль support) и 3 (POST /leads/:id/assign + test/exercises.test.ts)

    упр. 2 отдельно: tsc без ошибок, test/leads.test.ts 20/20; без строки матрицы tsc выдаёт TS2741 Property 'support' is missing (утверждение статьи). Упр. 3 (поверх упр. 2): tsc без ошибок, 35/35. Упражнение 1 совпадает с мутацией isVisibleTo → true

«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

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