Перейти к содержанию

Spring Security

Spring Security - набор servlet-фильтров, которые стоят перед Spring MVC и на каждом запросе решают две задачи: кто ты (аутентификация) и что тебе можно (авторизация). Плюс защита вызовов методов и защита от типовых атак: CSRF, заголовки, CORS.


Архитектура

Spring Security стоит перед DispatcherServlet: запрос сначала проходит цепочку security-фильтров и только потом попадает в контроллер.

HTTP request
    > Servlet Container
-------------------------------
    > DelegatingFilterProxy
    > FilterChainProxy
    > SecurityFilterChain
    > Security Filters
        > SecurityContext (load)
        > HeaderWriterFilter
        > CorsFilter / CsrfFilter
        > LogoutFilter
        > Authentication filter
            > AuthenticationManager
            > AuthenticationProvider
            > UserDetailsService / внешний источник
        > AnonymousAuthenticationFilter
        > ExceptionTranslationFilter
        > AuthorizationFilter
        > SecurityContext (save / clear)
    > DispatcherServlet
    > Controller
    > Method Security
    > Service
    > HTTP response

DelegatingFilterProxy - мост между servlet-контейнером и Spring-контекстом.

  • servlet-контейнер умеет регистрировать только обычный Filter и ничего не знает о Spring-бинах
  • DelegatingFilterProxy зарегистрирован как обычный фильтр, но работу передаёт Spring-бину
  • этот бин обычно FilterChainProxy с именем springSecurityFilterChain

FilterChainProxy - центральный фильтр Spring Security, стартовая точка всей web-защиты.

  • принимает запрос
  • находит подходящую SecurityFilterChain по matcher'ам
  • выполняет её фильтры в правильном порядке
  • применяет HttpFirewall
  • очищает SecurityContext после запроса

Security-фильтры регистрируются именно через него, а не в servlet-контейнере напрямую.


SecurityFilterChain - правило: если запрос подходит под matcher, к нему применяется этот набор security-фильтров.

@Bean
SecurityFilterChain apiChain(HttpSecurity http) { ... }      // /api/**

@Bean
SecurityFilterChain webChain(HttpSecurity http) { ... }      // всё остальное
  • FilterChainProxy выбирает первую подходящую цепочку
  • цепочки могут быть разными для /api/**, /admin/**, actuator, статики
  • у каждой свой matcher и свои правила

HttpSecurity - DSL-билдер, которым настраивают SecurityFilterChain. Через него добавляют правила авторизации, form login, basic auth, JWT resource server, logout, session management, CSRF, CORS и свои фильтры.

HttpSecurity        -> строит SecurityFilterChain
SecurityFilterChain -> используется FilterChainProxy
FilterChainProxy    -> реально исполняет защиту на запросе

Фильтры

Фильтр Роль
SecurityContextHolderFilter Загружает и готовит SecurityContextHolder на запрос
HeaderWriterFilter Security-заголовки
CorsFilter CORS
CsrfFilter Защита от CSRF
LogoutFilter Обработка logout
UsernamePasswordAuthenticationFilter Form login, логин и пароль
BasicAuthenticationFilter HTTP Basic
BearerTokenAuthenticationFilter OAuth2 Resource Server, Bearer-токен
AnonymousAuthenticationFilter Создаёт анонимную аутентификацию, если пользователь не вошёл
ExceptionTranslationFilter Переводит security-исключения в HTTP-ответ
AuthorizationFilter Проверка доступа, по умолчанию последний в цепочке

Порядок фильтров важен:

  • аутентификация идёт до авторизации
  • ExceptionTranslationFilter стоит так, чтобы поймать исключения следующих за ним фильтров
  • анонимная аутентификация ставится после реальной попытки входа

Чем security-фильтр отличается от обычного servlet-фильтра и interceptor'а MVC - в Фильтрах и interceptors.


Абстракции

Authentication - центральная сущность Spring Security. Бывает в двух состояниях:

  1. до проверки - несёт credentials, но ей ещё не доверяют
  2. после проверки - несёт principal, authorities и флаг authenticated = true
Authentication
  principal      -> кто пользователь
  credentials    -> пароль / токен / secret
  authorities    -> роли и права
  details        -> детали запроса
  authenticated  -> подтверждена ли личность

Реализации:

  • UsernamePasswordAuthenticationToken - логин и пароль, и до проверки, и после
  • BearerTokenAuthenticationToken - Bearer-токен до проверки
  • JwtAuthenticationToken - проверенный JWT
  • AnonymousAuthenticationToken - анонимный пользователь
  • RememberMeAuthenticationToken - вход по remember-me cookie

SecurityContext - хранилище текущего Authentication. SecurityContextHolder - статическая точка доступа к SecurityContext текущего потока. По умолчанию хранит его в ThreadLocal.

SecurityContextHolder
    > SecurityContext
        > Authentication
            > principal
            > authorities
            > credentials
Authentication auth = SecurityContextHolder.getContext().getAuthentication();

В servlet-приложении контекст живёт на время запроса и привязан к потоку (см. SecurityContext в течение запроса).


AuthenticationManager - фасад аутентификации. Сам не знает, как проверять все виды логина, и делегирует проверку провайдерам.

Authentication authenticate(Authentication authentication);

ProviderManager - самая частая реализация AuthenticationManager. Опрашивает список провайдеров:

  • провайдер умеет такой тип Authentication - пытается аутентифицировать
  • получилось - возвращает authenticated-объект
  • не умеет - вызов идёт к следующему, если никто не справился - исключение
ProviderManager
  -> Provider 1 supports this token? yes/no
  -> Provider 2 supports this token? yes/no
  -> Provider 3 supports this token? yes/no

AuthenticationProvider - конкретная стратегия проверки credentials.

  • DaoAuthenticationProvider - логин и пароль через UserDetailsService
  • JwtAuthenticationProvider - проверка JWT
  • LDAP-провайдер - проверка через LDAP
  • pre-auth провайдер - пользователя уже аутентифицировал внешний proxy или SSO

DaoAuthenticationProvider - типовой провайдер для логина и пароля. Шаги:

  1. берёт username из входного токена
  2. вызывает UserDetailsService, получает UserDetails
  3. сравнивает пароль через PasswordEncoder
  4. проверяет флаги: locked, disabled, expired
  5. создаёт authenticated Authentication

UserDetailsService - загрузчик пользователя для сценария логин-пароль: находит его в БД и возвращает логин, хеш пароля, роли и флаги blocked, expired, locked.

UserDetails loadUserByUsername(String username);

Warning

UserDetailsService не проверяет пароль. Он только загружает данные. Пароль сравнивает AuthenticationProvider через PasswordEncoder.


PasswordEncoder - стратегия хранения и сравнения паролей.

  • пароли не хранятся в открытом виде, сравнение идёт через хеш
  • современные варианты - bcrypt, argon2, pbkdf2, scrypt
  • NoOpPasswordEncoder - только для демо и тестов

BCrypt и соль. BCryptPasswordEncoder генерирует уникальную соль на каждое хеширование и встраивает её в результирующую строку хеша. matches(raw, encoded) достаёт соль из хеша и хеширует введённый пароль с ней же. Поэтому код регистрации выглядит «без соли», и это правильно:

user.setPassword(passwordEncoder.encode(request.getPassword()));   // соль уже внутри результата

AuthorizationManager - стратегия решения «пустить или нет». Учитывает роли (ROLE_ADMIN), authorities (order:write), HTTP-метод, URL, matcher, выражение, объект вызова метода. Работает и в web-защите через AuthorizationFilter, и в method security через interceptor'ы.

http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/admin/**").hasRole("ADMIN")
    .requestMatchers(HttpMethod.GET, "/orders/**").hasAuthority("order:read")
    .anyRequest().authenticated()
);

Аутентификация

Почти всегда проходит по одной схеме:

  1. Фильтр достаёт credentials из запроса.
  2. Создаёт Authentication, ещё не authenticated.
  3. Передаёт его в AuthenticationManager, тот - в подходящий AuthenticationProvider.
  4. Провайдер проверяет пользователя и credentials.
  5. Возвращается новый authenticated Authentication.
  6. Он кладётся в SecurityContextHolder.
  7. Запрос идёт дальше как от вошедшего пользователя.

Какой фильтр достаёт credentials, зависит от механизма: логин и пароль, Bearer-токен, Basic-заголовок, сессия и cookie, ответ OAuth2 login.


Form login:

POST /login
  username=alice
  password=secret

UsernamePasswordAuthenticationFilter
  -> создаёт UsernamePasswordAuthenticationToken (unauthenticated)
  -> AuthenticationManager.authenticate(...)
      -> ProviderManager
          -> DaoAuthenticationProvider
              -> UserDetailsService.loadUserByUsername("alice")
              -> PasswordEncoder.matches(raw, encoded)
              -> возвращает authenticated token
  -> SecurityContextHolder.getContext().setAuthentication(authenticatedToken)

Basic Auth:

Authorization: Basic base64(username:password)

BasicAuthenticationFilter
  -> достаёт заголовок, декодирует username и password
  -> создаёт Authentication
  -> AuthenticationManager -> ProviderManager / DaoAuthenticationProvider
  -> SecurityContext

JWT Bearer Token:

Authorization: Bearer <jwt>

BearerTokenAuthenticationFilter
  -> достаёт токен из заголовка
  -> AuthenticationManager -> JwtAuthenticationProvider
  -> проверка подписи, exp, aud, iss, claims
  -> создаётся JwtAuthenticationToken
  -> SecurityContext

При JWT серверной сессии обычно нет, аутентификация идёт на каждом запросе заново по токену (см. Stateful и Stateless).


Авторизация

После аутентификации Spring Security проверяет, есть ли у пользователя право на этот URL, HTTP-метод или вызов метода.

Web-уровень: AuthorizationFilter

AuthorizationFilter стоит последним в цепочке, поэтому перед ним уже отработали фильтры аутентификации и защиты от атак.

AuthorizationFilter
  -> берёт текущий Authentication из SecurityContextHolder
  -> передаёт его в AuthorizationManager
  -> AuthorizationManager решает grant / deny
  -> grant -> запрос идёт дальше: DispatcherServlet -> HandlerMapping -> HandlerAdapter -> контроллер
  -> deny  -> AccessDeniedException

Проверяется: вошёл ли пользователь, есть ли у него нужная роль или authority, разрешён ли этот URL.


Method Security

Method Security - второй уровень защиты: проверка на вызове метода, а не на URL. URL-защиты мало, потому что сервис могут вызвать не только из одного контроллера.

@Configuration
@EnableMethodSecurity
public class MethodSecurityConfig {
}

После этого работают:

  • @PreAuthorize, @PostAuthorize - проверка выражением до и после вызова
  • @PreFilter, @PostFilter - фильтрация входной и выходной коллекции
  • @Secured, @RolesAllowed - простая проверка ролей
@PreAuthorize("hasRole('ADMIN') or #id == authentication.name")
public UserDto getUser(String id) { ... }

Работает через Spring AOP, поэтому действуют ограничения прокси: вызов из того же класса проверку не проходит.

Вызов метода
  -> security interceptor
  -> AuthorizationManager / вычисление выражения
  -> grant -> вызов метода
  -> deny  -> AccessDeniedException

Обработка security-исключений

ExceptionTranslationFilter - мост между Java-исключениями и HTTP-ответом. Он не аутентифицирует и не авторизует, а ловит AuthenticationException и AccessDeniedException и переводит их в правильный ответ.

Пользователь не вошёл - AuthenticationException:

ExceptionTranslationFilter
  -> очищает SecurityContext
  -> при необходимости сохраняет исходный запрос (RequestCache)
  -> вызывает AuthenticationEntryPoint

AuthenticationEntryPoint решает, как запросить credentials: редирект на страницу логина, 401 Unauthorized, заголовок WWW-Authenticate, JSON-ошибка.

Пользователь вошёл, но доступа нет - AccessDeniedException:

ExceptionTranslationFilter
  -> вызывает AccessDeniedHandler
  -> обычно 403 Forbidden
Компонент Что делает
ExceptionTranslationFilter Переводит security-исключения в HTTP-ответ
AuthenticationEntryPoint Запускает вход или возвращает 401
AccessDeniedHandler Обрабатывает 403
RequestCache Сохраняет исходный запрос до логина

Ошибки контроллеров (@ControllerAdvice) работают позже, в MVC, и security-исключения фильтров не видят (см. Обработку исключений MVC).


SecurityContext в течение запроса

Начало запроса
  -> создать или загрузить SecurityContext
  -> положить в SecurityContextHolder
  -> фильтры и приложение пользуются им
Конец запроса
  -> сохранить, если нужно
  -> очистить SecurityContextHolder

Если контекст не очищать:

  • пользователь одного запроса «утечёт» в другой
  • особенно опасно с пулом потоков, где поток переиспользуется
  • получаются тяжёлые баги безопасности

Поэтому FilterChainProxy и связанные фильтры не только ставят Authentication, но и обязаны очистить контекст.

Режим Где хранится контекст
Stateful web-приложение Сессия + контекст потока на время запроса
Stateless JWT API Только на время текущего запроса
Тесты Часто вручную через test support

Stateful и Stateless

Stateful - form login, после входа есть сессия, SecurityContext восстанавливается из неё между запросами. Удобно для server-side web.

Запрос логина
  -> authenticate
  -> сохранить security state в сессию

Следующий запрос
  -> достать контекст из сессии
  -> пользователь уже вошёл

Stateless - REST API, JWT или Bearer-токен, сессия не источник состояния, каждый запрос сам приносит токен.

Каждый запрос
  -> Bearer-токен
  -> проверка токена
  -> создать Authentication
  -> положить в SecurityContext на время этого запроса
  -> после ответа очистить
Stateful Stateless
Где состояние входа Сессия В токене, вне сервера
Аутентификация на каждом запросе Нет Да
Типичный клиент Server-side web SPA, mobile, публичный API
Масштабирование Сложнее Проще

Настройка на практике

Note

extends WebSecurityConfigurerAdapter - старый способ. В Spring Security 5.7 он помечен deprecated, в 6.x удалён. Сейчас конфигурацию собирают бинами: SecurityFilterChain, UserDetailsService, PasswordEncoder.

Бины аутентификации - типовой конфиг из проекта с JWT:

@Configuration
@RequiredArgsConstructor
public class ApplicationConfig {

    private final UserRepository repository;

    @Bean
    public UserDetailsService userDetailsService() {
        // ищем пользователя по email, нет - исключение, которое Security превратит в 401
        return username -> repository.findByEmail(username)
                .orElseThrow(() -> new UsernameNotFoundException("User not found"));
    }

    @Bean
    public AuthenticationProvider authenticationProvider() {
        DaoAuthenticationProvider authProvider = new DaoAuthenticationProvider();
        authProvider.setUserDetailsService(userDetailsService());
        authProvider.setPasswordEncoder(passwordEncoder());
        return authProvider;
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();   // менеджер, который Spring уже собрал из провайдеров
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

В этом же конфиге проекта был бин AuditorAware для аудита сущностей.

Свой JWT-фильтр - класс extends OncePerRequestFilter: гарантирует один вызов на запрос. Он достаёт токен из заголовка, проверяет его, кладёт Authentication в SecurityContextHolder и передаёт запрос дальше. В цепочку его ставят через http.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class).

Куда пользователь попадает в контроллере: параметры Principal или Authentication метода контроллера (см. Параметры контроллера).


Модули Spring Security

Модуль Артефакт Что внутри
Core spring-security-core Базовые интерфейсы: Authentication, GrantedAuthority, SecurityContext, AuthenticationManager, UserDetails, основа method security
Web spring-security-web Servlet-фильтры, FilterChainProxy, хранение контекста в вебе, CSRF, заголовки, request cache
Config spring-security-config Java DSL (HttpSecurity), SecurityFilterChain, @EnableWebSecurity, @EnableMethodSecurity
OAuth2 Core spring-security-oauth2-core Общие модели и контракты OAuth2 и OIDC
OAuth2 Client spring-security-oauth2-client Вход через Google, GitHub, Keycloak, клиентские OAuth2-потоки
OAuth2 Resource Server spring-security-oauth2-resource-server Проверка JWT и opaque-токенов для API
OAuth2 JOSE spring-security-oauth2-jose JWS, JWE, JWT, интеграция с Nimbus
LDAP spring-security-ldap LDAP и Active Directory
ACL spring-security-acl Права на уровне объектов, используется редко
Test spring-security-test @WithMockUser, SecurityMockMvcRequestPostProcessors, хелперы для тестов