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

Spring Security — одна из самых сложных частей экосистемы. Хорошая новость: для стандартного REST-сервиса с OAuth2-токенами типичная конфигурация — 20 строк. Эта статья показывает каркас и точки расширения, без энциклопедии всех возможностей.

Базовая архитектура

Запросы проходят через цепочку SecurityFilterChain:

HTTP request
    ↓
[CorsFilter]
[CsrfFilter]
[BearerTokenAuthenticationFilter]   ← извлекает JWT, создаёт Authentication
[AuthorizationFilter]                ← проверяет авторизацию по правилам
[FilterSecurityInterceptor]
    ↓
DispatcherServlet → @RestController

Каждый фильтр имеет конкретную задачу. Конфигурация описывает, какие фильтры в каком порядке + правила для них.

Типовая конфигурация для REST-сервиса с JWT

@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
            .securityMatcher("/api/**")
            .csrf(csrf -> csrf.disable())                  // нет cookies → нет CSRF
            .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();
    }

    @Bean
    public JwtAuthenticationConverter jwtAuthConverter() {
        var conv = new JwtAuthenticationConverter();
        var ga = new JwtGrantedAuthoritiesConverter();
        ga.setAuthoritiesClaimName("roles");
        ga.setAuthorityPrefix("ROLE_");
        conv.setJwtGrantedAuthoritiesConverter(ga);
        return conv;
    }
}
# JWT issuer-uri Spring сам проверит и подгрузит JWKS для верификации подписи
spring.security.oauth2.resourceserver.jwt.issuer-uri=https://auth.example.com/realms/marketplace

Это готовая конфигурация для REST-сервиса с OAuth2-токенами:

  • Stateless: нет сессий, нет CSRF.
  • Resource Server: проверка JWT по подписи, expiration, issuer.
  • Authorities из claim rolesROLE_ADMIN, ROLE_USER и т. д.
  • /api/v1/public/** — без аутентификации, /api/v1/admin/** — только ADMIN.

Аутентификация vs авторизация

Authentication (AuthN)кто вы? Spring проверяет credentials (JWT, Basic, OIDC-токен), создаёт Authentication-объект, кладёт в SecurityContext.

Authorization (AuthZ)что вам можно? Spring проверяет роли/permissions из Authentication против правил конфигурации или @PreAuthorize.

Их разделение полезно для отладки: 401 (Unauthorized) — проблема AuthN, 403 (Forbidden) — проблема AuthZ.

Source of Truth: JWT vs opaque token

JWT (JSON Web Token) — токен самодостаточен: claims внутри подписаны issuer'ом, сервис проверяет подпись через публичный ключ (JWKS) и доверяет claim'ам. Никаких запросов к auth-серверу на каждый request.

Authorization: Bearer eyJhbGciOiJSUzI1NiIs.eyJzdWIiOi...
  • Плюсы: быстро (без сетевого вызова), масштабируется, работает offline.
  • Минусы: нельзя отозвать токен до expiration. Решается коротким TTL (5-15 минут) + refresh token.

Opaque token — это просто строка-идентификатор, сервис делает introspection-запрос к auth-серверу для каждого request:

@Bean
public SecurityFilterChain api(HttpSecurity http) throws Exception {
    return http
        .oauth2ResourceServer(o -> o.opaqueToken(t -> t
            .introspectionUri("https://auth.example.com/introspect")
            .introspectionClientCredentials("client-id", "secret")))
        .build();
}
  • Плюсы: можно отозвать токен немедленно (auth-сервер вернёт active=false).
  • Минусы: сетевой вызов на каждый запрос. Кэширование результата introspection частично помогает.

В UCP-стеке стандарт — JWT с TTL 5-15 минут + refresh token. Introspection — для критичных операций, где revocation важнее latency.

Method security: @PreAuthorize, @PostAuthorize

SecurityFilterChain авторизует на уровне URL. @PreAuthorize — на уровне метода:

@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);
    }
}

@PreAuthorize — проверка до вызова метода. @PostAuthorize — после, имея доступ к возвращаемому объекту (returnObject).

Активация:

@Configuration
@EnableMethodSecurity
public class SecurityConfig { ... }

Под капотом — Spring AOP-прокси (см. AOP). Те же ловушки: только public, только Spring-бины, self-invocation не работает.

SecurityContext в коде

@RestController
public class OrderController {

    @GetMapping("/me/orders")
    public List<Order> myOrders(@AuthenticationPrincipal Jwt jwt) {
        UUID customerId = UUID.fromString(jwt.getSubject());
        return orderService.findByCustomer(customerId);
    }

    @GetMapping("/some")
    public ResponseEntity<?> some() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        // ...
    }
}

@AuthenticationPrincipal Jwt jwt — Spring подкладывает разобранный JWT. Доступ через SecurityContextHolder тоже работает, но только в текущем потоке. Для reactive / async — отдельные механизмы.

OAuth2 Client — получение токена

Если сервис-А вызывает сервис-Б с собственным OAuth2-токеном (через client credentials grant):

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();
    }
}

Spring Security кэширует токен до expiration и автоматически перезапрашивает.

CSRF — когда нужен, когда нет

CSRF (Cross-Site Request Forgery) — атака, эксплуатирующая cookies. Если приложение использует cookies для аутентификации — нужен CSRF-токен.

  • REST-сервис с JWT в Authorization header — CSRF не нужен (атакующий не может прочитать заголовки кросс-доменно).
  • SPA + cookies (session-based) — нужен CSRF, и Spring Security включает его по умолчанию.
  • Server-side rendered с form-based login — нужен.

Правило в SecurityConfig:

.csrf(csrf -> csrf.disable())                              // stateless REST
.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))  // SPA

Session management

.sessionManagement(s -> s
    .sessionCreationPolicy(SessionCreationPolicy.STATELESS))    // REST с JWT
  • STATELESS — Spring никогда не создаёт HttpSession.
  • IF_REQUIRED (default) — создаст, если нужно.
  • ALWAYS — всегда.
  • NEVER — не создавать, но использовать существующую.

Для REST-сервисов с JWT — STATELESS. Это позволяет масштабировать stateless'но и убирает сессионную инвалидацию.

Стандартные ошибки

Отключить security «временно для отладки». Через год оказывается, что в проде так и осталось.

Захардкоженный permitAll() для большого набора endpoints. Обычно достаточно requestMatchers("/api/v1/public/**").permitAll(), всё остальное закрыто.

JWT-secret в БД или property без шифрования. Должен быть только в secret manager.

Использовать SAML/SSO без single-sign-out. После logout пользователь остаётся залогинен в IdP, при следующем запросе автоматически входит.

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

  • Паттерны авторизации — обзорные паттерны OAuth2, OIDC, RBAC, ABAC, mTLS.
  • Spring AOP — @PreAuthorize через AOP, ограничения.
  • Spring MVC — фильтры в общем потоке запроса.
  • Auth Patterns Style Guide — правила работы с авторизацией для AI-ревью.