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. Бывает в двух состояниях:
- до проверки - несёт credentials, но ей ещё не доверяют
- после проверки - несёт principal, authorities и флаг
authenticated = true
Authentication
principal -> кто пользователь
credentials -> пароль / токен / secret
authorities -> роли и права
details -> детали запроса
authenticated -> подтверждена ли личность
Реализации:
UsernamePasswordAuthenticationToken- логин и пароль, и до проверки, и послеBearerTokenAuthenticationToken- Bearer-токен до проверкиJwtAuthenticationToken- проверенный JWTAnonymousAuthenticationToken- анонимный пользовательRememberMeAuthenticationToken- вход по remember-me cookie
SecurityContext - хранилище текущего Authentication.
SecurityContextHolder - статическая точка доступа к SecurityContext текущего потока. По умолчанию хранит
его в ThreadLocal.
В servlet-приложении контекст живёт на время запроса и привязан к потоку (см. SecurityContext в течение запроса).
AuthenticationManager - фасад аутентификации. Сам не знает, как проверять все виды логина, и делегирует проверку провайдерам.
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- логин и пароль черезUserDetailsServiceJwtAuthenticationProvider- проверка JWT- LDAP-провайдер - проверка через LDAP
- pre-auth провайдер - пользователя уже аутентифицировал внешний proxy или SSO
DaoAuthenticationProvider - типовой провайдер для логина и пароля. Шаги:
- берёт username из входного токена
- вызывает
UserDetailsService, получаетUserDetails - сравнивает пароль через
PasswordEncoder - проверяет флаги: locked, disabled, expired
- создаёт authenticated
Authentication
UserDetailsService - загрузчик пользователя для сценария логин-пароль: находит его в БД и возвращает логин, хеш пароля, роли и флаги blocked, expired, locked.
Warning
UserDetailsService не проверяет пароль. Он только загружает данные. Пароль сравнивает
AuthenticationProvider через PasswordEncoder.
PasswordEncoder - стратегия хранения и сравнения паролей.
- пароли не хранятся в открытом виде, сравнение идёт через хеш
- современные варианты - bcrypt, argon2, pbkdf2, scrypt
NoOpPasswordEncoder- только для демо и тестов
BCrypt и соль. BCryptPasswordEncoder генерирует уникальную соль на каждое хеширование и встраивает её в
результирующую строку хеша. matches(raw, encoded) достаёт соль из хеша и хеширует введённый пароль с ней же.
Поэтому код регистрации выглядит «без соли», и это правильно:
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()
);
Аутентификация¶
Почти всегда проходит по одной схеме:
- Фильтр достаёт credentials из запроса.
- Создаёт
Authentication, ещё не authenticated. - Передаёт его в
AuthenticationManager, тот - в подходящийAuthenticationProvider. - Провайдер проверяет пользователя и credentials.
- Возвращается новый authenticated
Authentication. - Он кладётся в
SecurityContextHolder. - Запрос идёт дальше как от вошедшего пользователя.
Какой фильтр достаёт 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-защиты мало, потому что сервис могут вызвать не только из одного контроллера.
После этого работают:
@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 |
Переводит 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, хелперы для тестов |