В 12:00 у сотрудника отозвали доступ: администратор заблокировал учётную запись на сервере аутентификации. В 12:03 тот же сотрудник открывает отчёт в админке — и сервис отвечает 200 OK. Ошибки в коде нет: сервис сделал ровно то, что ему настроили. Почему так вышло и сколько минут это продлится, зависит от того, чем сервис проверяет входящий токен.
Сам токен сервис не выдаёт. Пользователь вошёл на сервере аутентификации — в этой фазе это Keycloak, — получил access token по Authorization Code Flow и прикладывает его к каждому запросу в заголовке Authorization: Bearer …. Эта статья — про то, что происходит с токеном внутри вашего сервиса; следующие восемь статей фазы — про другую сторону, как Keycloak его выпускает.
Главное решение — проверять токен подписью на месте или спрашивать сервер аутентификации в каждом запросе. Разница видна ровно в момент отзыва доступа:
Стрелка между сервисом и auth-сервером — и есть цена вопроса. Её нет — сервис ничего не узнает про отзыв и принимает токен до exp: при TTL 15 минут в худшем случае все 15. Короткий TTL окно не закрывает, а укорачивает; закрывает его refresh, потому что за новым токеном клиент идёт к auth-серверу. Introspection закрывает окно на первом же запросе — ценой 1200 сетевых вызовов в минуту, то есть по одному на каждый запрос к сервису.
Цепочка фильтров: проверка до контроллера
Если бы каждый контроллер проверял токен сам, одна забытая строка открывала бы дыру, и искать её пришлось бы перебором всех обработчиков. Поэтому Spring Security ставит проверку перед контроллером — на уровне сервлет-фильтров, через которые идёт любой запрос, даже к несуществующему адресу. Цепочка называется SecurityFilterChain, и вся настройка безопасности — выбор, какие фильтры в ней стоят и по каким правилам работают.
Ответ 401 рождается в двух местах, а 403 — в одном: любой отказ по правам, от URL-правил или от @PreAuthorize из глубины сервиса, проходит через ExceptionTranslationFilter. Переопределив его обработчики в exceptionHandling, вы меняете все три исхода разом.
BearerTokenAuthenticationFilter достаёт токен из заголовка и проверяет его; плохой токен он бракует сам, а запрос без заголовка молча пропускает: можно ли сюда без токена, решает не он, а стоящий последним AuthorizationFilter по правилам authorizeHttpRequests. Отказ тот в ответ не пишет, а бросает AccessDeniedException; её ловит стоящий выше ExceptionTranslationFilter и превращает в 401 для анонима или 403 для аутентифицированного без нужной роли. Туда же прилетает отказ из @PreAuthorize в сервисе — поэтому одна правка в exceptionHandling ломает обновление токена у клиента, разобрано в соседней статье.
Правила читаются сверху вниз до первого совпадения: anyRequest() последним, permitAll() для публичного каталога выше него. И деталь, которую видно только в проде: securityMatcher("/api/**") не «защищает /api», а сужает цепочку до префикса. Всё вне него — /actuator/**, корень, страницы ошибок — не попадает ни в одну цепочку и уходит без проверки: запасную цепочку Spring Boot создаёт, только когда вы не объявили ни одной.
Типовая конфигурация: три части
Для REST-сервиса, который принимает JWT от Keycloak, хватает трёх кусков. Первый — сама цепочка:
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**")
.csrf(csrf -> csrf.disable())
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/public/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.anyRequest().authenticated())
.oauth2ResourceServer(o -> o.jwt(jwt -> jwt
.jwtAuthenticationConverter(jwtAuthConverter())))
.build();
}
}
csrf.disable() и STATELESS — про одно и то же: токен ездит в заголовке, а не в cookie, и сессия сервису не нужна; почему это безопасно — в разделах ниже. oauth2ResourceServer(...jwt...) ставит в цепочку BearerTokenAuthenticationFilter с локальной проверкой JWT. В терминах OAuth2 сервис — Resource Server: токены не выдаёт, только проверяет.
Второй кусок — откуда брать ключи и кому токен адресован:
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.example.com/realms/marketplace
spring.security.oauth2.resourceserver.jwt.audiences=orders-service
По issuer-uri Spring находит адрес JWKS и забирает публичные ключи — не при старте, а на первом пришедшем токене; дальше ключи лежат в кэше, и в запросе сетевого вызова нет. Проверяются подпись, срок (exp и nbf) и издатель (iss).
Строки audiences в большинстве руководств нет, и без неё сервис дырявый. Токен, выписанный соседнему приложению того же realm, имеет верную подпись, свой iss и живой exp — и без проверки aud проходит в ваш сервис как родной. Keycloak по умолчанию кладёт в aud значение account, а не имя вашего сервиса; чтобы туда попал orders-service, на клиенте в Keycloak добавляют маппер типа Audience — иначе этой строкой вы закроете сервис от всех, включая своих. Настройка со стороны Keycloak — в статье про проверку токенов.
Третий кусок — перевод ролей. Готовый JwtGrantedAuthoritiesConverter умеет читать роли только из поля верхнего уровня, а Keycloak кладёт их в realm_access.roles, на уровень глубже. Поэтому конвертер пишут лямбдой:
@Bean
public JwtAuthenticationConverter jwtAuthConverter() {
var conv = new JwtAuthenticationConverter();
conv.setJwtGrantedAuthoritiesConverter(jwt -> {
var realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess == null) {
return List.of();
}
@SuppressWarnings("unchecked")
var roles = (List<String>) realmAccess.get("roles");
if (roles == null) {
return List.of();
}
return roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.map(GrantedAuthority.class::cast)
.toList();
});
return conv;
}
Без него прав у пользователя окажется ноль, hasRole("ADMIN") молча даст 403, и ни в одном логе про это не будет ни строки. Client-роли из resource_access этот конвертер тоже не видит — что с ними делать, разобрано отдельно.
В контроллере пользователь уже разобран:
@GetMapping("/api/v1/me/orders")
public List<OrderResponse> myOrders(@AuthenticationPrincipal Jwt jwt) {
UUID customerId = UUID.fromString(jwt.getSubject());
return orderService.findByCustomer(customerId);
}
@AuthenticationPrincipal Jwt — проверенный и разобранный токен, getSubject() — claim sub, неизменный идентификатор учётной записи. SecurityContextHolder.getContext().getAuthentication() даёт то же самое, но контекст привязан к потоку запроса: в @Async-методе, в пуле CompletableFuture или в потоке планировщика там будет null.
Аутентификация и авторизация: 401 и 403
Два ответа сервиса клиент читает по-разному, и путать их дорого. 401 Unauthorized — «я не знаю, кто ты»: токена нет или он не прошёл проверку. Это провал аутентификации. 403 Forbidden — «я знаю, кто ты, и тебе сюда нельзя»: токен настоящий, а роли или владения не хватает. Это провал авторизации.
По коду клиент принимает решение: на 401 грамотный фронтенд идёт за новым токеном по refresh и повторяет запрос, на 403 показывает «доступ запрещён» и повторять не пытается. Отдайте 403 на просроченный токен — и пользователь застрянет: обновляться клиент не станет, а пускать его сервис не пускает.
У 401 есть подсказка в заголовке WWW-Authenticate. Просто Bearer — токена в запросе не было. Bearer error="invalid_token", error_description="… Jwt expired at …" — токен был, и вот что с ним не так. По одному этому заголовку видно, забыл ли клиент приложить токен или тот протух.
Чем сервис проверяет токен: подпись или вопрос серверу
JWT — самодостаточный токен: всё, что нужно для проверки, лежит внутри и подписано сервером аутентификации. Сервис сверяет подпись ключом из кэша JWKS и никого не спрашивает. Плата — окно отзыва из вводной анимации: до exp токен принимают, и закрывает окно не короткий TTL, а refresh, за которым клиент идёт к серверу аутентификации.
Opaque-токен — случайная строка без содержимого; проверить её можно только вопросом к серверу аутентификации, introspection-запросом в каждом запросе:
.oauth2ResourceServer(o -> o.opaqueToken(t -> t
.introspectionUri("https://auth.example.com/realms/marketplace/protocol/openid-connect/token/introspect")
.introspectionClientCredentials("orders-service", secret)))
Отзыв виден на следующем же запросе, окно — ноль. Цена: 1200 запросов в минуту к сервису — это 1200 вызовов в минуту к серверу аутентификации; его минута простоя — минута, когда ваш сервис не пропускает никого. Кэш ответов на 30 секунд снимает нагрузку, но возвращает окно — те же 30 секунд.
Keycloak выдаёт только JWT, но его introspection-эндпоинт принимает тот же JWT, так что opaqueToken можно включить и с ним: сервис перестанет верить подписи и начнёт спрашивать. В одной цепочке режимы взаимоисключающие; нужен мгновенный отзыв админке, а каталогу нет — заводят две цепочки с разными securityMatcher.
Правило выбора: по умолчанию JWT с TTL 5–15 минут и refresh. Introspection — для узкого набора операций, где минуты работы под отозванным доступом стоят дороже вызова к серверу аутентификации: деньги, персональные данные, действия администратора.
Method security: проверка у метода, а не у URL
Правила по URL знают путь и роль. «Покупатель видит только свои заказы» по пути не выразить: /api/v1/orders/{id} одинаков для своего и чужого заказа, а кто владелец, известно только после чтения из базы. Такую проверку ставят на метод:
@Service
public class OrderService {
@PreAuthorize("hasRole('ADMIN') or #request.customerId == authentication.name")
public OrderResponse createForCustomer(CreateOrderRequest request) { ... }
@PostAuthorize("returnObject.customerId == authentication.name")
public OrderResponse get(UUID id) {
return orderRepo.findById(id)
.map(OrderResponse::of)
.orElseThrow(OrderNotFound::new);
}
}
@PreAuthorize вычисляет выражение до вызова: аргументы доступны по имени (#request), пользователь — как authentication. @PostAuthorize — после, с доступом к результату (returnObject). Включает обе @EnableMethodSecurity на конфигурации выше.
Оговорка про authentication.name, без которой оба примера тихо сломаются: там лежит не логин, а claim sub — неизменный идентификатор учётной записи. Сравнение работает, только если customerId у вас — тот же sub. Если в базе лежит логин (preferred_username), оно перестанет сходиться в день, когда человек сменит логин, — а до этого будет выглядеть исправным.
Три вещи про @PostAuthorize, которые обычно понимают после инцидента. Метод уже выполнился: для чтения безобидно, для cancel(orderId) — нет, заказ отменён, и только потом пришёл 403; всё, что меняет состояние, проверяют до вызова — @PreAuthorize("@orderAccess.owns(#id)") с обращением к бину. Ради ответа «нельзя» объект прочитан из базы целиком. И чужой заказ отвечает 403, а несуществующий — 404: по разнице кодов перебором номеров узнают, какие заказы существуют; как это закрывают — в статье про владение ресурсом.
@PostFilter("filterObject.customerId == authentication.name") выкидывает чужие элементы из возвращённой коллекции — загрузив из базы все заказы, чтобы оставить десять. Условие владельца кладут в сам запрос (where customer_id = ?), а @PostFilter оставляют коллекциям, которые и так целиком в памяти.
Ставить аннотации — на сервис, не на контроллер: контроллер лишь один из входов, тот же метод вызывают из слушателя очереди и из планировщика. Отсюда характерное падение: ночной @Scheduled-пересчёт вызывает метод с @PreAuthorize и получает AuthenticationCredentialsNotFoundException — в потоке планировщика SecurityContext пуст. Лечат не снятием аннотации, а отдельным входом для планировщика без проверки пользователя: пользователя там и нет.
Под капотом — Spring AOP-прокси (см. AOP), и ловушки те же: только public-методы, только бины Spring, вызов через this внутри класса идёт мимо прокси и мимо проверки.
Сервис вызывает сервис: свой токен, а не чужой
Сервис заказов идёт к сервису цен за расчётом. Чей токен приложить? Первое, что приходит в голову, — пробросить токен пользователя, он ведь уже есть в запросе. Работает ровно до ночного пересчёта цен: пользователя в нём нет, токена — тоже. И даже днём сервис цен видит в токене права покупателя, а не сервиса заказов, и не отличает «заказы просят цену» от «покупатель полез напрямую».
Поэтому сервис ходит от своего имени. Он зарегистрирован на сервере аутентификации как клиент с секретом и получает собственный токен по client credentials grant: «вот мой идентификатор и секрет, дай токен». В aud и ролях этого токена — сервис заказов, и сервис цен авторизует именно его.
Сервис цен ни с кем не связывается: подпись он сверяет по своему кэшу JWKS, а кто пришёл, читает из токена. Шаг 1 повторяется не на каждый вызов — токен живёт в менеджере на стороне сервиса заказов.
Настройка — регистрация клиента и адрес, где выдают токен:
spring.security.oauth2.client.registration.pricing-service.client-id=orders
spring.security.oauth2.client.registration.pricing-service.client-secret=${PRICING_SECRET}
spring.security.oauth2.client.registration.pricing-service.authorization-grant-type=client_credentials
spring.security.oauth2.client.provider.pricing-service.token-uri=https://auth.example.com/realms/marketplace/protocol/openid-connect/token
@Component
@RequiredArgsConstructor
public class PricingClient {
private final WebClient webClient;
private final OAuth2AuthorizedClientManager clientManager;
public Price quote(QuoteRequest request) {
var authorizedClient = clientManager.authorize(
OAuth2AuthorizeRequest.withClientRegistrationId("pricing-service")
.principal("orders")
.build());
return webClient.post()
.uri("https://pricing.internal/quote")
.headers(h -> h.setBearerAuth(authorizedClient.getAccessToken().getTokenValue()))
.bodyValue(request)
.retrieve()
.bodyToMono(Price.class)
.block();
}
}
clientManager.authorize(...) — шаги 1–2 схемы: менеджер отдаёт токен из кэша, а за новым идёт сам, за 60 секунд до exp. Ловушка почти для всех: бин OAuth2AuthorizedClientManager Spring Boot не создаёт — автоконфигурации под него нет, без ручного объявления приложение не поднимется с NoSuchBeanDefinitionException:
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository registrations,
OAuth2AuthorizedClientService clients) {
var manager = new AuthorizedClientServiceOAuth2AuthorizedClientManager(registrations, clients);
manager.setAuthorizedClientProvider(
OAuth2AuthorizedClientProviderBuilder.builder().clientCredentials().build());
return manager;
}
Проброс пользовательского токена бывает правильным, когда сосед обязан проверить владение именно этим пользователем — «мои заказы» через BFF; тогда сосед получает и пользователя, и срок его токена. Сравнение с mTLS и дробление scope — в статье про вызовы сервис-сервису.
CSRF: когда браузер сам приложит cookie
Пользователь вошёл в банк, в браузере лежит cookie сессии. В соседней вкладке он открывает чужую страницу, а на ней форма, которая сама отправляет POST https://bank.example/transfer. Браузер прикладывает cookie банка, потому что так устроены cookie: они уходят на свой домен, с какой бы страницы ни ушёл запрос. Сервер видит настоящую сессию и выполняет перевод. Это и есть CSRF — подделка запроса с чужой страницы; работает она только там, где удостоверение прикладывает браузер сам.
Разница не в сервере, а в том, кто прикладывает удостоверение: cookie — браузер, для любой страницы; заголовок Authorization — ваш JavaScript, и только он.
С токеном в заголовке атаки нет: чужая страница не прочитает ваш токен из-за политики одного источника, а без токена запрос приходит анонимным и получает 401. Поэтому csrf.disable() для stateless REST — точное описание модели, а не упрощение. Положите тот же токен в cookie — и защита нужна снова.
Нужна она SPA, которое живёт на cookie-сессии, — частая схема с BFF. Тогда токен CSRF выдают так, чтобы прочитать его мог только свой JavaScript:
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()))
Сервер пишет cookie XSRF-TOKEN, фронтенд читает её и кладёт значение в заголовок X-XSRF-TOKEN каждого изменяющего запроса; CsrfFilter сверяет заголовок с cookie. Чужая страница cookie прочитать не может — значит, и заголовок ей не собрать; withHttpOnlyFalse — условие работы, а не оплошность. Две детали Spring Security 6: токен ленивый — cookie появится, только когда кто-то обратился к токену, — и по умолчанию маскируется от атаки BREACH, так что в заголовке ожидается не сырое значение. SpaCsrfTokenRequestHandler из документации закрывает и то и другое; без него фронтенд шлёт правильный заголовок, а сервер отвечает 403 на каждый POST.
Сессии: почему STATELESS — не просто флаг
Сессия — состояние пользователя в памяти одного экземпляра. Поставьте второй за балансировщиком — и следующий запрос того же пользователя прилетает туда, где его сессии нет: 401 и повторный вход. Лечат липкими сессиями (упал экземпляр — вышли все его пользователи) или общим хранилищем, обычно Spring Session на Redis. Тогда Redis стоит на пути каждого запроса: при 20 000 запросов в секунду это 20 000 чтений в секунду, а его отказ — отказ входа всем сразу.
С токеном состояние едет в самом запросе: любой экземпляр проверяет подпись и знает, кто пришёл. Ради этого и ставят STATELESS: Spring Security сам никогда не создаст HttpSession и не станет искать в ней пользователя, JSESSIONID в ответах не появится, а Authentication живёт до конца запроса и забывается — следующий запрос обязан принести токен снова. Остальные политики — для приложений с формой входа: IF_REQUIRED (по умолчанию) создаёт сессию, когда её кто-то попросил, ALWAYS — на каждый запрос, NEVER — не создаёт, но существующей пользуется.
Обратная сторона — «выйти со всех устройств» из сервиса не сделать: удалять нечего. Делают это через сервер аутентификации: короткий access token и отзыв refresh-сессии — окно закрывается за один TTL, как во вводной анимации. Если и такого окна нельзя, заводят чёрный список токенов в том же Redis — и хранилище на горячем пути возвращается.
Как проверить правила до прода
Ошибка в правилах не роняет старт и не пишет в лог: сервис работает, просто пускает не тех. Единственное место, где её ловят до прода, — тест на цепочку. Библиотека spring-security-test даёт post-processor jwt(), который кладёт в контекст готовый JwtAuthenticationToken, не трогая ни сеть, ни JWKS:
@WebMvcTest(ReportController.class)
@Import(SecurityConfig.class)
class ReportControllerSecurityTest {
@Autowired
MockMvc mvc;
@Test
void withoutTokenIs401() throws Exception {
mvc.perform(get("/api/v1/admin/reports"))
.andExpect(status().isUnauthorized());
}
@Test
void userRoleIs403() throws Exception {
mvc.perform(get("/api/v1/admin/reports")
.with(jwt().authorities(new SimpleGrantedAuthority("ROLE_USER"))))
.andExpect(status().isForbidden());
}
@Test
void adminIs200() throws Exception {
mvc.perform(get("/api/v1/admin/reports")
.with(jwt().jwt(j -> j.subject("42"))
.authorities(new SimpleGrantedAuthority("ROLE_ADMIN"))))
.andExpect(status().isOk());
}
}
@Import(SecurityConfig.class) обязателен: срез @WebMvcTest поднимает фильтры Spring Security, но ваш класс конфигурации не сканирует — без импорта тест проверит запасную цепочку Spring Boot. issuer-uri в тестовых настройках нужен, чтобы контекст собрался; в сеть никто не идёт — за ключами Spring Boot 3 ходит лениво, а jwt() подкладывает аутентификацию мимо декодера. @WithMockUser здесь не подходит: у него другой тип Authentication, и @AuthenticationPrincipal Jwt окажется null.
Первый тест — страж от «отключил security для отладки»: краснеет в день, когда защиту сняли.
Стандартные ошибки
Отключили security «на время отладки», и оно так и осталось. В логах этого не видно — в том и беда. Признаки косвенные: у API за сутки ни одного 401 и 403 в метриках; в SecurityContext — AnonymousAuthenticationToken с именем anonymousUser; @AuthenticationPrincipal Jwt вдруг null, и первый getSubject() падает с NullPointerException. Страж — тест на 401 выше.
Правила не в том порядке. requestMatchers("/api/**").permitAll() выше hasRole("ADMIN") открывает админку всем: правила читаются до первого совпадения, а /api/** совпадает первым. Симптом — 200 на /api/v1/admin/** без заголовка Authorization. Публичного должно быть мало и конкретно: один префикс, остальное закрыто anyRequest().authenticated().
Забыли permitAll() на публичный путь. Каталог отвечает 401 с пустым телом и заголовком WWW-Authenticate: Bearer без error — токена в запросе не было, а правило его требует. Тот же ответ с error="invalid_token" — другая история: токен был, и он плохой.
В логе старта — Using generated security password. Стартер на classpath есть, а вашей цепочки контекст не увидел: класс конфигурации вне сканируемого пакета или не импортирован. Spring Boot поставил запасную цепочку с формой входа и Basic-паролем из лога — запросы без токена получают переадресацию на /login или WWW-Authenticate: Basic вместо Bearer.
Ключ подписи в настройках открытым текстом. При схеме с Keycloak у сервиса секрета для подписи и нет — он проверяет чужую подпись открытым ключом из JWKS. Секрет появляется, когда приложение подписывает токены само; его утечка равна выдаче любых токенов кому угодно, место ему — в хранилище секретов.
Выход, который разлогинивает только у вас. Фронтенд забыл токен, а сессия на сервере аутентификации жива — следующая переадресация на вход возвращает пользователя без пароля: «я вышел, а меня снова впустило». Выход делают через сервер аутентификации: у OIDC для этого есть end-session endpoint.
Глубже: каждый маршрут закрыт: denyAll по умолчанию, обход маршрутов и настоящий Keycloak в тестерасширенное
Тест на цепочку выше проверяет три ответа одного маршрута. Ошибка, которая доходит до прода, другая: новый контроллер добавили, правило для него забыли, и он получил то, что даёт anyRequest(). Поэтому проверку строят в три слоя.
Умолчание закрыто. anyRequest().authenticated() в конце цепочки означает, что новый маршрут доступен любому вошедшему; anyRequest().denyAll() означает, что новый маршрут не доступен никому, пока для него не написано правило, и разработчик узнаёт об этом первым же запросом на стенде. Второе умолчание безопаснее, и его цена это одна строка на каждый новый маршрут. Публичные маршруты (/health, документация) при этом перечислены явно и первыми.
Обход всех маршрутов. Один тест берёт список всех маршрутов из RequestMappingHandlerMapping, вызывает каждый без токена и требует 401, кроме списка публичных, который лежит в самом тесте. Новый контроллер попадает в обход автоматически, и тест краснеет, если маршрут открыт. Дополняет его тест на метод-уровень с ArchUnit: у каждого метода в классах @RestController (или у каждого обработчика команды, если проверка живёт там) стоит @PreAuthorize либо аннотация-пометка «публично», о чём говорит раздел про method security.
Разные способы подложить пользователя. jwt() из примера выше кладёт готовый Jwt и подходит для проверки правил по claim и ролям. @WithMockUser(roles = "ADMIN") проще и годится для method security без разбора токена, но не проверяет конвертер claim в роли, а именно в нём чаще всего ошибка. Подмена JwtDecoder бином-заглушкой (@MockitoBean JwtDecoder) позволяет прогнать настоящий заголовок Authorization: Bearer ... через настоящий фильтр с токеном, который вы собрали в тесте; так проверяют audiences, iss и обработку истёкшего токена, чего jwt() не делает, потому что обходит декодер.
Настоящий сервер аутентификации. Раз в проекте, а не в каждом тесте, поднимают Keycloak в Testcontainers (KeycloakContainer из dasniko/testcontainers-keycloak) с realm из файла, получают настоящий токен и проходят весь путь: JWKS, подпись, aud, роли из realm_access. Это единственный тест, который ловит расхождение между тем, что Keycloak кладёт в токен, и тем, что читает конвертер, и его запускают в CI, а не локально по настроению.
Что не проверяет ни один из этих тестов: ошибку в самом правиле, когда hasRole("ADMIN") написали там, где нужен был владелец. Это ловит только тест на ABAC из статьи про владение ресурсом и ревью с вопросом «а чужой заказ?».
Коротко
SecurityFilterChainстоит перед контроллером;securityMatcherсужает цепочку до префикса — всё вне его идёт без проверки вовсе.- 401 — плохой токен (
BearerTokenAuthenticationFilter) или аноним у правила; 403 — аутентифицирован без прав. Оба отказа по правам, и от URL, и от@PreAuthorize, идут черезExceptionTranslationFilter. WWW-Authenticate: Bearerбезerror— токена не было; сinvalid_token— был и плохой.- Без
audiencesсервис принимает любой токен своего realm; у Keycloakaudпо умолчаниюaccount, имя сервиса добавляет маппер Audience. - JWT: ноль вызовов к серверу аутентификации, окно отзыва до
exp; introspection: окно ноль, вызов в каждом запросе. По умолчанию JWT с TTL 5–15 минут. @PostAuthorizeпроверяет после выполнения — побочные эффекты остаются. Изменяющее состояние — под@PreAuthorize; для списков условие в запрос, а не@PostFilter; в потоке планировщикаSecurityContextпуст.- Сервис к сервису ходит со своим токеном по client credentials;
OAuth2AuthorizedClientManagerобъявляют руками. - CSRF бьёт там, где удостоверение прикладывает браузер: Bearer в заголовке —
csrf.disable(), SPA на cookie —XSRF-TOKENплюсSpaCsrfTokenRequestHandler. STATELESS: ни сессии, ниJSESSIONID; «выйти отовсюду» — через сервер аутентификации или чёрный список.- Правила проверяют тестом:
@WebMvcTest+@Import(SecurityConfig)+jwt()— без токена 401, чужая роль 403, своя 200. УмолчаниеanyRequest().denyAll(), тест-обход всех маршрутов без токена, ArchUnit на аннотацию у каждого обработчика;jwt()для правил, заглушкаJwtDecoderдля проверкиaudи истечения, Keycloak в Testcontainers раз в проекте для всего пути.
Что почитать дальше
- Authorization Code Flow и PKCE — откуда у клиента берётся тот токен, который сервис здесь проверяет.
- Keycloak и Spring Security: проверка токенов — тот же Resource Server по шагам: JWKS,
audiences, ответ 401 и как его случайно превращают в 403. - Роли и доступ: RBAC и ABAC с Keycloak — client-роли из
resource_access, владение ресурсом и что проверять по роли, а что по данным. - Вызовы сервис-сервису — client credentials против mTLS, дробление scope и что делать с утёкшим секретом.