Когда пользователь отправляет запрос, приложение должно ответить на три разных вопроса:
- Кто это вообще? — токен настоящий, не истёк, не подделан?
- Может ли он обратиться к этому endpoint? — у него есть нужная роль?
- Может ли он работать именно с этим ресурсом? — это его заказ, или чужой?
Это три разных вопроса, и каждый из них задаётся на своём уровне. Если смешать их в одном месте — получается либо дублирование с риском расхождений, либо дыры: один endpoint проверяет всё, другой — ничего.
Вот эти три вопроса на одном запросе маркетплейса — и три разных места, где запрос может не пройти.
Первые две проверки отвечают по самому токену и потому дёшевы. Третья требует сходить в базу за 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, и никакие внутренние сервисы не вызываются.
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, где живёт агрегат.
Сравните две строки: внизу шлюз идёт за 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(), тестом-обходом всех маршрутов и архитектурным тестом на аннотацию, а не вниманием.
Что почитать дальше
- ABAC: владение ресурсом — как реализовать проверку владения через
@access-бин или внутри Handler. - JWT validation — стандартный Spring Security flow для проверки токенов.
- RBAC: маппинг ролей — как настроить
@PreAuthorizeи каталог ролей. - Service-to-service — как сервисы аутентифицируют друг друга.
- Кейс: маркетплейс — сквозной пример сайта: пути, роли и проверки владения из этой статьи разобраны на нём.