← назад к разделу

Когда пользователь отправляет запрос, приложение должно ответить на три разных вопроса:

  1. Кто это вообще? — токен настоящий, не истёк, не подделан?
  2. Может ли он обратиться к этому endpoint? — у него есть нужная роль?
  3. Может ли он работать именно с этим ресурсом? — это его заказ, или чужой?

Это три разных вопроса, и каждый из них задаётся на своём уровне. Если смешать их в одном месте — получается либо дублирование с риском расхождений, либо дыры: один endpoint проверяет всё, другой — ничего.

Вот эти три вопроса на одном запросе маркетплейса — и три разных места, где запрос может не пройти.

PATCH /api/seller/cards/8891 {"price": 4990}Authorization: Bearer (токен продавца)Gateway: кто это?подпись по ключам realm marketplaceсрок exp, издатель iss, аудитория aud токен истёк — 401Catalog Service не вызван Слой приложения: есть роль?путь /api/seller/** требует sellerроль уже лежит в разобранном токене роль buyer — 403в базу каталога не ходили Доменный слой: чья карточка?грузим карточку 8891 из базысверяем card.sellerId и sub из токена sub 99, sellerId 42 — 403роль верная, карточка чужая sub 42, sellerId 42 — 200 OKцена карточки 8891 стала 4990 sellerId знает только база каталога — третью проверку выше не поднять

Первые две проверки отвечают по самому токену и потому дёшевы. Третья требует сходить в базу за card.sellerId — поэтому её и нельзя вынести на Gateway.

Обязательно

Gateway — кто стучится в дверь

Первый пропускной пункт — это Gateway (или сам сервис, если Gateway нет). Его задача — ответить на вопрос «кто этот клиент?».

Что Gateway делает:

  • Извлекает токен из заголовка Authorization: Bearer <jwt>.
  • Проверяет подпись токена через JWK Set (публичные ключи IdP).
  • Проверяет срок действия (exp), издателя (iss) и аудиторию (aud).
  • Ограничивает количество запросов (rate limiting).
  • Передаёт identity дальше — через тот же заголовок или через X-User-Id, X-User-Roles.

Последний пункт безопасен ровно настолько, насколько закрыт прямой путь к сервису в обход шлюза. Заголовок X-User-Roles подделать может кто угодно: соседний под, отладочный контейнер, скрипт из внутренней сети. Поэтому такую схему берут только вместе с одним из двух: либо взаимной проверкой сертификатов между сервисами, либо сетевыми правилами, которые не пускают в сервис никого, кроме шлюза. Если ни того, ни другого нет — дальше едет сам токен, и каждый сервис проверяет подпись у себя. Подробнее об этом — в «Частых ошибках» в конце статьи.

Если токен невалиден — запрос получает 401 Unauthorized, и никакие внутренние сервисы не вызываются.

Пользователь POST /orders Authorization: Bearer <токен> Gateway проверяет подпись, срок, издателя токен валиден — иначе 401 order-service знает: пришёл user-42

Gateway отвечает на один вопрос — кто это, и дальше сервис получает готовое имя пользователя, не разбирая токен сам. Цена — доверие к внутренней сети: если в контур можно попасть мимо Gateway, заголовку с именем верить нельзя.

Что Gateway не делает: он не знает, какие endpoint-ы существуют и какие роли нужны. И тем более не знает бизнес-модель — кому принадлежит заказ №12345.

В Spring Boot Gateway — это Spring Cloud Gateway или Istio с JWT-фильтром. Если внешнего Gateway нет, проверку токена делает сам сервис через oauth2ResourceServer в Spring Security — поведение то же самое.

BFF — есть ли право зайти в эту дверь

Допустим, токен валиден. Теперь второй вопрос: «может ли пользователь с его ролью обращаться к этому конкретному endpoint?».

Роль CUSTOMER есть у каждого покупателя, а /admin/orders — не для покупателей. Токен при этом настоящий, подпись сошлась, шлюз его пропустил: отличить покупателя от администратора может только правило «на этот endpoint — только с такой ролью». Такой контроль по ролям называется RBAC (Role-Based Access Control). В Spring правило пишут через @PreAuthorize прямо на контроллере:

@RestController
@RequestMapping("/admin/orders")
public class AdminOrderController {

    @PostMapping("/{id}/refund")
    @PreAuthorize("hasRole('ADMIN')")
    public Order refund(@PathVariable Long id) {
        return dispatcher.dispatch(new RefundOrderCommand(id));
    }
}

@RestController
@RequestMapping("/orders")
public class OrderController {

    @GetMapping("/{id}")
    @PreAuthorize("hasAnyRole('CUSTOMER', 'ADMIN')")
    public OrderResponse get(@PathVariable Long id) {
        return dispatcher.dispatch(new GetOrderByIdQuery(id));
    }
}

Если роль не подходит — Spring Security вернёт 403 Forbidden ещё до того, как запрос дойдёт до бизнес-логики.

Только 403 здесь не единственный возможный ответ. Spring различает два случая: «ты вошёл, но роли не хватает» — это 403, и «ты вообще не представился» — тогда вместо 403 сработает точка входа аутентификации и клиент получит 401. Разница важная: 401 означает «предъяви токен», 403 — «токен принят, но этого тебе нельзя, повторять бессмысленно».

Типичное разграничение:

  • POST /admin/* — только ADMIN.
  • GET /orders/* — CUSTOMER или ADMIN.
  • POST /orders — только CUSTOMER (клиент создаёт заказы сам).

Что RBAC не проверяет: он не знает, чей именно заказ №12345. Это не его задача.

Domain Service — можно ли работать именно с этим объектом

Третий вопрос — самый тонкий: «этот пользователь имеет право читать или менять именно этот ресурс?».

Роль CUSTOMER есть у всех покупателей. Но покупатель не должен видеть чужие заказы. Это нельзя проверить по роли — нужно загрузить объект и сравнить владельца с текущим пользователем.

Такой подход называется ABAC (Attribute-Based Access Control). Он живёт внутри обработчика бизнес-логики:

@UseCase
@RequiredArgsConstructor
public class GetOrderByIdHandler implements UseCaseHandler<GetOrderByIdQuery, Order> {

    private final OrderRepository orderRepository;
    private final AuthenticatedUserProvider userProvider;

    @Override
    @Transactional(readOnly = true)
    public Order handle(GetOrderByIdQuery query) {
        var order = orderRepository.findById(query.orderId())
            .orElseThrow(() -> new OrderNotFoundException(query.orderId()));

        var user = userProvider.current();
        if (!user.isAdmin() && !order.getCustomerId().equals(user.id())) {
            throw new OrderNotFoundException(query.orderId());   // чужой заказ неотличим от несуществующего
        }
        return order;
    }
}

Логика: загрузили заказ №12345, а владелец у него другой покупатель — отказ. Роль CUSTOMER есть, endpoint разрешён, но этот покупатель читает чужой заказ.

Два момента в этом коде стоит проговорить. Первый: личность берут у отдельного компонента, а не разбирают токен на месте. Так проще не ошибиться с типом — sub из токена это строка, и у Keycloak там UUID, на котором Long.valueOf упадёт. Второй: на чужой заказ здесь летит не «доступ запрещён», а «такого заказа нет». Почему именно так — сразу ниже.

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

Подробнее о реализации ABAC — в статье ABAC: владение ресурсом.

Три уровня — три вопроса

Сведём три уровня в одну таблицу:

УровеньВопросЧто проверяет
Gateway / API edgeКто это?Подпись JWT, срок действия, издатель
BFF / Application LayerМожно ли сюда обращаться?Роль пользователя (RBAC)
Domain ServiceМожно ли работать с этим объектом?Владение ресурсом (ABAC)

Три уровня на маркетплейсе: один запрос целиком

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

PATCH /api/seller/cards/8891  {"price": 4990}
Authorization: Bearer <токен продавца>

Gateway. Достаёт токен, проверяет подпись по ключам realm marketplace, срок и издателя. Ничего не знает про карточку 8891 и про то, что такое «продавец». Если токен просрочен — 401, и Catalog Service даже не узнает о запросе.

Catalog Service, слой приложения. Проверяет роль: путь /api/seller/** требует seller. Покупатель с ролью buyer получит 403 здесь — до всякого обращения к базе. Это дёшево: роль уже лежит в разобранном токене.

Catalog Service, доменный слой. Загружает карточку 8891 и сравнивает card.sellerId с sub из токена. Не совпало — 403, и это уже совсем другой отказ: роль правильная, но карточка чужая. Здесь 403 уместен: карточка лежит в открытом каталоге, её существование и так видно всем без токена, прятать нечего. С заказом было бы иначе — там 404.

Ключевой момент: третью проверку нельзя поднять на Gateway. Чтобы узнать, чья это карточка, нужно сходить в базу каталога — а Gateway про базы сервисов ничего не знает и знать не должен. Попытка научить его этому превращает Gateway в место, куда стекается бизнес-логика всех сервисов сразу.

Так же раскладываются и остальные пути маркетплейса:

ЗапросGatewayРольВладение / состояние
GET /api/catalog/8891пропускает без токена——
POST /api/ordersпроверяет токенbuyer—
POST /api/orders/551/cancelпроверяет токенbuyerзаказ мой и ещё не в доставке
POST /api/moderation/cards/8891/approveпроверяет токенmoderator—
POST /api/payouts/77/approveпроверяет токенfinanceвыплату ещё не подтверждал этот же сотрудник

Витрина каталога открыта без токена намеренно: карточки товаров должны попадать в поисковую выдачу. А действия сотрудников площадки закрываются одной ролью — модератор работает с любой карточкой, ему не нужно ничем владеть. Выплаты тут особняком: роли finance мало, потому что у самой выплаты есть состояние — кто её уже подтвердил. Второе подтверждение ставит другой сотрудник, и проверить это можно, только заглянув в запись выплаты.

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

Три уровня выше описаны на запросе к одному объекту: загрузили заказ 12345, сравнили владельца, ответили. У списка объекта нет. GET /orders не содержит идентификатора, сравнивать не с чем — и место проверки смещается: владение в списках выражает сам запрос к базе, а не отдельная проверка после него.

Практически это значит, что идентификатор владельца становится обязательным параметром выборки — findByCustomerId(currentUserId, pageable), — и приходит он из токена, а не из параметров запроса. GET /orders?customerId=99 с идентификатором из адреса открывает ровно ту дыру, от которой защищают три уровня: подставил чужой номер и получил чужие заказы. Если смотреть чужие списки кому-то действительно нужно (поддержка, модератор), это отдельный путь с отдельной ролью и записью в журнале, а не необязательный параметр в общей ручке.

Уровень при этом не меняется: фильтр живёт там же, где и проверка владения, — в доменном сервисе, рядом с репозиторием. Шлюз отфильтровать список не может по той же причине, по которой не может проверить владение: он не знает, кому принадлежат строки. Как это выглядит в коде, с постраничной выдачей и подсчётами, разбирает статья про ABAC.

Почему ABAC нельзя делать на Gateway

Иногда возникает соблазн проверять всё на Gateway — чтобы «отсечь раньше». Но с ABAC это не работает.

Проблема: чтобы Gateway ответил «может ли user-99 читать заказ 12345», ему нужно знать, кому принадлежит заказ. Для этого нужно идти в базу данных или вызывать order-service. То есть Gateway фактически становится ещё одним сервисом, который понимает бизнес-модель.

Что плохого:

  • При изменении модели (например, добавили соавторов заказа) нужно обновлять и order-service, и Gateway.
  • Появляется два источника правды о том, кому принадлежит ресурс.
  • Gateway перегружается логикой, которая ему не принадлежит.

Правило: Gateway делает только аутентификацию. ABAC — только внутри Domain Service, где живёт агрегат.

как надо шлюз: кто это сервис: роль сервис: владелец ABAC на шлюзе шлюз: кто это шлюз в базу каталога модель в двух местах

Сравните две строки: внизу шлюз идёт за sellerId в базу каталога и становится вторым местом, где живёт доменная модель заказа и карточки.

Почему подпись проверяют в каждом сервисе, а не только на шлюзе

Проверять токен во внутреннем сервисе повторно — не лишняя работа, а необходимость. Соблазн велик: раз шлюз уже проверил подпись, зачем делать это второй раз. Но тогда любой, кто попал во внутреннюю сеть — соседний сервис, отладочный под, скомпрометированная библиотека, — сможет обратиться к вашему сервису напрямую и представиться кем угодно. Верить переданной личности можно, только когда сам канал доказывает, что запрос пришёл именно от шлюза: взаимная проверка сертификатов между сервисами или сетевые правила, закрывающие прямой доступ. Без этого проверку подписи в каждом сервисе оставляют.

Тот же запрос, но от сервиса

Три уровня работают и когда запрос пришёл не из браузера, а от соседнего сервиса. Вопросы те же, ответы другие:

УровеньЗапрос от человекаЗапрос от сервиса
Gateway / границачей токен, действителен лисертификат или сервисный токен; внутренние вызовы часто не идут через шлюз вовсе
Слой приложенияесть ли у пользователя рольесть ли у клиента право на эту операцию — клиентская роль system или область payment:charge
Доменный слойего ли это объектот чьего имени действует сервис: от своего (техническая операция) или от имени пользователя, и тогда владение проверяют по исходному пользователю

Отличие сосредоточено на среднем уровне. У сервисного токена нет человека: он выписан на учётную запись клиента, и роли в нём лежат клиентские, а не пользовательские. Проверка hasRole('system') для такого вызова и означает «это наш сервис», а не «это человек с чужим токеном».

Дальше начинается ошибка, которую делают почти все: роль system заводят как «можно всё» и выдают каждому сервису. Тогда один взломанный сервис получает права всех сразу — сервис уведомлений сможет читать заказы, а оплата менять карточки каталога. Право дают узкое: свой клиент на каждый сервис и роль или область под операцию. Как сервис получает такой токен и что проверяет принимающая сторона — в статье про межсервисные вызовы.

Как убедиться, что ни одна дверь не забыта

«Каждый эндпоинт обязан объявить, кто к нему ходит» — правило, которое нельзя выполнять вниманием: ручек сотни, новые добавляют каждую неделю, и забытая проверка ничем не выдаёт себя. Поэтому забытую дверь ловят тремя приёмами.

Запрет по умолчанию. Последним правилом в цепочке ставят anyRequest().denyAll(), а не authenticated() и уж точно не permitAll(). Тогда новый путь без явного правила закрыт, а не открыт: первый же запрос в тестовой среде отвечает 403, и автор видит это сам.

http.authorizeHttpRequests(auth -> auth
        .requestMatchers("/api/catalog/**").permitAll()
        .requestMatchers("/api/seller/**").hasRole("seller")
        .requestMatchers("/api/moderation/**").hasRole("moderator")
        .anyRequest().denyAll());

Обход всех маршрутов тестом. Тест берёт у Spring список зарегистрированных маршрутов (RequestMappingHandlerMapping.getHandlerMethods()), идёт по каждому без токена и требует 401, а затем с токеном без ролей и требует 403. Он закрывает не текущие ручки, а все будущие: появилась новая без правила — тест красный в тот же день.

Архитектурный тест на аннотацию. Если правила живут не в конфигурации, а на методах, то же проверяют по коду: у каждого публичного метода контроллера есть @PreAuthorize либо явная отметка «открыто намеренно». На ArchUnit это правило в десять строк, и работает оно на тех, кто придёт в проект после вас.

И отдельно — по одному тесту на каждую ресурсную ручку: «чужой объект → 404». Три строки на ручку, а закрывают самый частый способ утечки: обращение по чужому идентификатору.

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

Endpoint без @PreAuthorize. Если забыть аннотацию — Spring Security пропустит запрос с любой ролью. Каждый endpoint должен явно объявлять, кто к нему имеет доступ.

Только RBAC без ABAC для ресурсо-ориентированных endpoint-ов. GET /orders/{id} проверяет роль, но не владельца — любой CUSTOMER читает любой заказ.

JWT-проверка внутри Handler. Обработчик бизнес-логики не должен разбирать токен вручную — это работа OAuth2 Resource Server на уровне Edge. Handler получает уже извлечённые данные из SecurityContextHolder.

Дополнительно: при первом чтении можно пропустить

Глубже: граница: TLS, заголовки безопасности и что шлюз срезаетрасширенное

Три уровня выше отвечают на вопрос «кто ты и что тебе можно». У границы есть ещё обязанности, которые к личности не относятся, и без них проверка прав защищает только от вежливых.

Где заканчивается TLS. Шифрование обычно терминируется на балансировщике или шлюзе, и дальше до сервисов идёт обычный HTTP. Из этого два следствия. Сервис видит запрос как http и, если сам строит адреса (Location, ссылки в ответе) или ставит атрибут Secure на cookie, обязан знать, что снаружи было https: это заголовок X-Forwarded-Proto от шлюза и настройка server.forward-headers-strategy в Spring, чтобы он ему верил. И «внутри сеть доверенная» верно ровно до тех пор, пока в неё не попал кто-то ещё; между сервисами включают mTLS, о чём статья про межсервисные вызовы.

Что шлюз срезает и что подставляет. Заголовки X-Forwarded-For, X-Forwarded-Proto, X-Real-IP клиент может прислать сам, и если шлюз их не перезапишет, лимиты по адресу и журнал будут верить подделке. Шлюз обязан выставлять их заново от себя, а сервисы верить им только от адресов шлюза. Точно так же срезают всё, что похоже на внутренние заголовки: X-User-Id, X-Tenant, X-Internal-*, которыми сервисы обмениваются между собой; иначе пользователь подставит чужой идентификатор и пройдёт мимо проверки, которая читает его из заголовка. Заголовок Authorization наружу от сервисов и внутрь между несвязанными сервисами тоже не гуляет просто так, о чём говорит статья про подпись.

Заголовки безопасности в ответах. Strict-Transport-Security: max-age=31536000; includeSubDomains ставит тот, кто терминирует TLS: браузер после первого визита год не пойдёт по http даже по ссылке. X-Content-Type-Options: nosniff запрещает браузеру угадывать тип ответа, и для JSON-API это обязательный минимум. Content-Security-Policy относится к HTML-страницам, то есть к фронтенду и к страницам входа, а не к API: она перечисляет, откуда странице можно грузить скрипты, и это главная защита от последствий XSS; для API вместо неё достаточно Cache-Control: no-store на всём, что содержит токены или личные данные. X-Frame-Options: DENY или frame-ancestors в CSP защищают страницы от встраивания в чужой сайт. Spring Security ставит несколько из них сам, и отключать блок headers() целиком, «потому что мешает», это отдельная ошибка.

CSRF остаётся частью этой границы для всего, что ходит с cookie; для API с токеном в заголовке его выключают осознанно, и это разобрано в статье про Spring Security.

Глубже: дыры помимо доступа: инъекции, SSRF, обход пути и десериализациярасширенное

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

Инъекции. Строка запроса, собранная склейкой, "... WHERE email = '" + email + "'", выполняет всё, что пришло в email. Это относится не только к SQL: JPQL, Cypher, запросы к Elasticsearch и MongoDB, команды оболочки, LDAP-фильтры и шаблоны (Thymeleaf, FreeMarker) с подстановкой пользовательского текста в сам шаблон. Лечение везде одно: данные передают параметрами, а не текстом. PreparedStatement и ? в jOOQ и JPA, параметры $name в Cypher, объекты вместо строк в запросах Mongo, ProcessBuilder со списком аргументов вместо строки. Правило ревью: любое место, где строка запроса собирается из кусков, требует объяснения, почему иначе нельзя.

SSRF. Сервис принимает адрес от пользователя (вебхук, «загрузить по ссылке», превью страницы) и делает запрос сам. Пользователь присылает http://169.254.169.254/ (метаданные облака с ключами доступа), http://localhost:9200/ или адрес соседнего сервиса без аутентификации, и ваш сервер сходит туда от своего имени. Лечение: разрешать только https, разрешать имена по списку, где это возможно, а иначе резолвить имя и отказывать приватным диапазонам (включая IPv6), не следовать перенаправлениям, ходить с отдельного хоста без доступа во внутреннюю сеть.

Обход пути. Ручка «скачать файл» с именем от пользователя и Paths.get(base, name): имя ../../etc/passwd уходит выше базы. Лечение: base.resolve(name).normalize().startsWith(base) до чтения, а лучше не принимать имена вовсе, а выдавать идентификаторы из базы, по которым сервис сам находит путь.

Десериализация. Java-сериализация недоверенных данных (ObjectInputStream на теле запроса, сообщении из очереди, cookie) даёт выполнение кода через цепочки гаджетов в зависимостях, и её не используют для внешних данных вообще. У JSON свои варианты: Jackson с включённым полиморфизмом по умолчанию (enableDefaultTyping, @JsonTypeInfo с class) позволяет клиенту выбрать класс для создания; типы перечисляют явно, а YAML от пользователя читают только безопасным загрузчиком.

Массовое присваивание. Тело запроса разбирается прямо в сущность, и клиент присылает поле role или balance, которого в форме не было. Лечится отдельным DTO команды с перечисленными полями, о чём подробно в статье про границу валидации.

Общее у всех пяти: ни одна не ловится проверкой прав, потому что пользователь имеет право вызвать эту ручку. Ловятся они на ревью по слову «склейка», статическим анализом в сборке и списком OWASP Top 10, который стоит перечитывать раз в год.

Коротко

  • Auth — это три разных проверки, не одна: аутентификация, RBAC, ABAC.
  • Gateway проверяет JWT: подпись, срок, издатель. Невалидный токен → 401, дальше запрос не идёт.
  • BFF / Controller проверяет роль через @PreAuthorize. Нет роли → 403.
  • Domain Service проверяет владение конкретным ресурсом. Чужой объект → 404, если само его существование не публично, и 403, если публично.
  • ABAC нельзя делать на Gateway: Gateway не знает доменную модель.
  • Каждый endpoint должен иметь явную RBAC-аннотацию — без неё дверь открыта всем.
  • Граница отвечает не только за личность: TLS терминируется на шлюзе, и сервис верит X-Forwarded-* только от него; шлюз срезает внутренние заголовки от клиента; HSTS и nosniff обязательны, CSP для страниц, no-store для ответов с токенами.
  • Инъекции, SSRF, обход пути, десериализация и массовое присваивание проходят любую проверку прав: параметры вместо склейки, список разрешённых адресов и отказ приватным диапазонам, normalize().startsWith(base), никакой Java-сериализации внешних данных, отдельный DTO команды.
  • Списки закрывают не проверкой, а фильтром в самом запросе, и владелец в нём — из токена, а не из параметров адреса.
  • Забытую проверку ловят anyRequest().denyAll(), тестом-обходом всех маршрутов и архитектурным тестом на аннотацию, а не вниманием.

Что почитать дальше