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
roles→ROLE_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-ревью.