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

В 12:00 у сотрудника отозвали доступ: администратор заблокировал учётную запись на сервере аутентификации. В 12:03 тот же сотрудник открывает отчёт в админке — и сервис отвечает 200 OK. Ошибки в коде нет: сервис сделал ровно то, что ему настроили. Почему так вышло и сколько минут это продлится, зависит от того, чем сервис проверяет входящий токен.

Сам токен сервис не выдаёт. Пользователь вошёл на сервере аутентификации — в этой фазе это Keycloak, — получил access token по Authorization Code Flow и прикладывает его к каждому запросу в заголовке Authorization: Bearer …. Эта статья — про то, что происходит с токеном внутри вашего сервиса; следующие восемь статей фазы — про другую сторону, как Keycloak его выпускает.

Главное решение — проверять токен подписью на месте или спрашивать сервер аутентификации в каждом запросе. Разница видна ровно в момент отзыва доступа:

12:00 — доступ отозван на сервере аутентификации такт 1 · JWT, TTL 15 мин такт 2 · JWT 5 мин + refresh такт 3 · opaque + introspection клиент сервис auth-сервер 12:03 GET /admin/reports · Bearer вызова нетподпись — ключ из кэша JWKSexp 12:07 впереди → валиден вызова нетподпись — ключ из кэша JWKSexp 12:04 впереди → валиден POST /introspect{"active": false} 200 OK — доступ уже отозван 200 OK — окно ещё открыто 401 — на первом же запросе 12:04 exp наступил → POST /token (refresh) → отказ: отзыв уже виден окно после отзыва: до 15 мин (здесь 7) · вызовов из сервиса к auth: 0 окно: до 5 мин · вызовов из сервиса к auth: 0 — окно закрыл refresh окно: 0 мин · цена: 1200 вызовов в минуту к auth-серверу

Стрелка между сервисом и auth-сервером — и есть цена вопроса. Её нет — сервис ничего не узнает про отзыв и принимает токен до exp: при TTL 15 минут в худшем случае все 15. Короткий TTL окно не закрывает, а укорачивает; закрывает его refresh, потому что за новым токеном клиент идёт к auth-серверу. Introspection закрывает окно на первом же запросе — ценой 1200 сетевых вызовов в минуту, то есть по одному на каждый запрос к сервису.

Обязательно

Цепочка фильтров: проверка до контроллера

Если бы каждый контроллер проверял токен сам, одна забытая строка открывала бы дыру, и искать её пришлось бы перебором всех обработчиков. Поэтому Spring Security ставит проверку перед контроллером — на уровне сервлет-фильтров, через которые идёт любой запрос, даже к несуществующему адресу. Цепочка называется SecurityFilterChain, и вся настройка безопасности — выбор, какие фильтры в ней стоят и по каким правилам работают.

HTTP-запрос CorsFilter CORS-заголовки для браузера CsrfFilter выключен: токен не в cookie BearerTokenAuthenticationFilter Bearer → подпись, exp, iss, aud ExceptionTranslationFilter ловит AccessDenied → 401 или 403 AuthorizationFilter правила authorizeHttpRequests контроллер · @PreAuthorize ваш код выполняется только здесь 401 invalid_token токен есть, но не прошёл 401 · токена нет аноним упёрся в правило 403 · прав не хватает токен есть, роли нет AccessDeniedException

Ответ 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 и ролях этого токена — сервис заказов, и сервис цен авторизует именно его.

сервис заказов auth-сервер сервис цен 1 · POST /token: client_id + secret 2 · access_token сервиса, exp +5 мин 3 · POST /quote · Bearer <токен сервиса> 4 · 200 OK — подпись по JWKS, в aud — сервис заказов следующие 4 мин токен берётся из кэша менеджера; новый — за 60 с до exp

Сервис цен ни с кем не связывается: подпись он сверяет по своему кэшу 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 — в статье про вызовы сервис-сервису.

Пользователь вошёл в банк, в браузере лежит cookie сессии. В соседней вкладке он открывает чужую страницу, а на ней форма, которая сама отправляет POST https://bank.example/transfer. Браузер прикладывает cookie банка, потому что так устроены cookie: они уходят на свой домен, с какой бы страницы ни ушёл запрос. Сервер видит настоящую сессию и выполняет перевод. Это и есть CSRF — подделка запроса с чужой страницы; работает она только там, где удостоверение прикладывает браузер сам.

аутентификация — cookie сессии чужая страница авто-POST /transfer браузер сам прикладывает cookie ваш сервер сессия настоящая → 200 аутентификация — Bearer в заголовке чужая страница fetch POST /transfer браузер Authorization не приложен ваш сервер аноним → 401 cookie уходит на свой домен с любой страницы; токен из JS чужой страницы не прочитать

Разница не в сервере, а в том, кто прикладывает удостоверение: 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; у Keycloak aud по умолчанию 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 раз в проекте для всего пути.

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