1. Этапы вместо списка фильтров
Список фильтров сам по себе почти не помогает. Когда впервые открываешь логи Spring Security или видишь перечень фильтров, легко попасть в ловушку: «Ого, тут 15–20 классов, сейчас выучу их названия — и всё пойму». Это примерно как пытаться стать поваром, выучив названия всех кастрюль на кухне. В этой лекции мы делаем более полезную вещь: переводим «толпу фильтров» в понятные этапы обработки запроса, где каждый этап отвечает на простой вопрос и готовит данные для следующего.
Важно понять цель: нам не нужен «каталог фильтров». Нам нужна схема, по которой вы сможете объяснить и себе, и коллеге: почему запрос дошёл до контроллера, почему не дошёл, почему появился redirect, почему «внезапно 403» и почему один и тот же endpoint ведёт себя по-разному в браузере и в curl.
Модель: запрос как конвейер «ворот»
Представьте аэропорт. Вы входите в здание — это ещё не значит, что вы уже в самолёте. Дальше идут этапы: рамка металлоискателя, проверка документов, досмотр ручной клади, и только потом — выход к гейтам. С безопасностью запросов история похожая: запрос не «телепортируется» в контроллер. Он проходит конвейер, где на каждом шаге либо получает нужный контекст, либо останавливается с понятным ответом.
Технически этот конвейер реализован внутри FilterChainProxy, который делегирует обработку конкретному SecurityFilterChain. Именно эта инфраструктура и создаёт ощущение «магии», если не видеть этапов. Но как только этапы складываются в голове, магия превращается в обычную инженерную механику: «на этапе X мы готовим контекст, на этапе Y пытаемся понять пользователя, на этапе Z принимаем решение по доступу».
Чтобы держать это в голове, будем использовать учебную схему из пяти этапов — это не «официальное название стадий», а удобный учебный разрез:
import java.util.List;
public class SecurityStagesDemo {
public static void main(String[] args) {
// Учебная модель: группируем фильтры не по классам, а по обязанностям (этапам).
List<String> stages = List.of(
// 1) Подготовить место, куда складываем security-состояние запроса
"prepare SecurityContext",
// 2) Попробовать аутентифицировать, если в запросе есть «документы»
"try to authenticate (if request contains credentials)",
// 3) Если пользователь так и не появился — сделать состояние явным через anonymous
"fallback to anonymous (if still no user)",
// 4) Превратить security-исключения в понятный HTTP-ответ (а не в stacktrace)
"translate security exceptions into HTTP response",
// 5) Принять решение по доступу до контроллера: пускать или нет
"authorize request (allow / deny) before controller"
);
// Проверяем, что этапов действительно 5 (чисто для самопроверки схемы).
System.out.println(stages.size()); // 5
}
}
Эта схема специально упрощает реальность. Это учебная группировка обязанностей, а не буквальный инвентарь фильтров и не железный порядок, одинаковый для любого запроса. В реальной цепочке один фильтр может оборачивать следующие, часть этапов может выглядеть иначе, а набор authentication-фильтров зависит от того, как именно клиент предъявляет себя. Но по обязанностям картина всё равно собирается примерно так.
2. Этап 1 — подготовка SecurityContext: «не работаем в пустоте»
Любая серьёзная система начинается с того, что приводит «рабочее место» в порядок. В Spring Security этим «рабочим местом» является SecurityContext: контейнер, в котором живёт текущее security-состояние запроса. На первом этапе цепочка гарантирует, что контекст существует, пусть даже пустой, и что дальше фильтры могут не гадать «а где тут пользователь?», а смотреть в одно место.
На уровне инфраструктуры важно помнить, что FilterChainProxy — центральная точка servlet-безопасности Spring Security. В документации подчёркивается, что это отправная точка и что он делает обязательные вещи вроде очистки SecurityContext, чтобы не было утечек между запросами. Это как «после каждого пассажира протереть стол»: звучит скучно, но иначе очень быстро станет страшно.
В современных версиях Spring Security есть фильтр, который читает SecurityContext из хранилища и заполняет SecurityContextHolder, и дальше весь запрос работает через этот holder. Исторически это был SecurityContextPersistenceFilter, а позже документация описывает движение к SecurityContextHolderFilter, который читает и наполняет контекст, а сохранение делает более явным. Мы пока не углубляемся в то, где именно контекст хранится между запросами. Нам на сегодня важен сам факт: без контекста последующие этапы не могут принимать последовательные решения.
Мини-ощущение этого этапа можно получить даже без понимания деталей хранения:
import org.springframework.security.core.context.SecurityContext;
import org.springframework.security.core.context.SecurityContextHolder;
public class ContextPeek {
public static void peek() {
// SecurityContext обычно хранится в ThreadLocal через SecurityContextHolder:
// это значит, что «текущий контекст» привязан к текущему потоку обработки запроса.
SecurityContext context = SecurityContextHolder.getContext();
// В реальном приложении тут обычно будет Authentication внутри контекста (или пусто).
System.out.println(context); // (выведет объект контекста, формат зависит от реализации)
}
}
Ключевая мысль: вы не создаёте SecurityContext руками в контроллере. Он подготавливается в security-слое, чтобы дальше любой код в рамках запроса мог спросить: «а кто сейчас делает запрос?» — и получить ответ из одного места.
3. Этап 2 — попытка понять пользователя: «данные для входа»
Когда контекст подготовлен, логично задать следующий вопрос: «а этот запрос от кого-то конкретного или просто прохожий?». На этом этапе фильтры смотрят на запрос и проверяют, есть ли в нём признаки того, что клиент пытается представиться. Это могут быть разные виды предъявления документов: заголовок Authorization, cookie, параметры формы логина и так далее. Нам пока важен сам момент появления входных данных, без разбора внутренней проверки логина и пароля.
Важный нюанс для новичка: фильтры на этом этапе не обязаны аутентифицировать пользователя. Они могут только понять, что данных нет, и тогда просто не трогать контекст или оставить его пустым до следующего этапа. А если данные есть, они запускают механизм аутентификации, который уже внутри решит: «верю / не верю». Нам сейчас важно увидеть само место в цепочке, где это вообще происходит.
Ниже для простоты — только примеры с заголовками. Это не значит, что Spring Security всегда ищет именно Authorization: credentials могут приезжать и через form login, и через cookie, и через другие механизмы.
Учебный псевдокод, который иллюстрирует идею, может выглядеть так:
public class CredentialsProbe {
public static boolean hasCredentials(String authorizationHeader) {
// Если заголовка нет — клиент не пытался предъявить «документы».
if (authorizationHeader == null) {
return false;
}
// Самые популярные схемы: Basic (логин/пароль) и Bearer (токен).
// На этом этапе нас интересует только «есть ли попытка представиться».
return authorizationHeader.startsWith("Basic ")
|| authorizationHeader.startsWith("Bearer ");
}
}
Да, слово Bearer нам ещё рано любить, но как пример «в запросе есть попытка представиться» оно подходит. Смысл этапа прост: chain не угадывает пользователя телепатией, он либо находит входные данные, либо честно признаёт: «здесь нечего аутентифицировать».
4. Этап 3 — anonymous: «пусть это будет явное состояние, а не “ничего”»
После попытки аутентификации возможны два сценария: либо в контексте появляется реальный пользователь, либо по-прежнему никого. И вот здесь у Spring Security есть важная философия: «никого» — плохое состояние, потому что оно размазывает логику. Намного удобнее иметь явного anonymous principal, чтобы дальше все проверки работали единообразно.
Именно поэтому в цепочке присутствует AnonymousAuthenticationFilter. Название уже говорит само за себя: он превращает пустоту в технически описанное состояние. Список фильтров, который встречается в документации и JavaDoc, прямо включает AnonymousAuthenticationFilter среди типичных участников цепочки.
Почему это не лишняя бюрократия? Потому что дальше на этапе авторизации можно не писать разные ветки «если Authentication == null…». Вместо этого можно мыслить так: «есть authenticated user или anonymous user». И это аккуратно влияет на семантику вроде «пустить всех» или «пустить только вошедших».
Здесь нам важна сама роль этапа: пустота превращается в явную сущность; детали Authentication и Principal пока не нужны.
5. Этап 4 — перевод исключений в HTTP: ExceptionTranslationFilter
Если этапы 1–3 отвечают за «подготовить состояние запроса», то этап 4 — за «если что-то пошло не так, ответить клиенту нормально». Security-цепочка должна уметь остановить запрос предсказуемо: не упасть stacktrace-ом в ответ и не пустить исключение гулять до случайного обработчика. Для этого в chain есть фильтр, который переводит security-ошибки в HTTP-ответы.
В Spring Security это делает ExceptionTranslationFilter. В документации он прямо описан как механизм, который переводит AuthenticationException и AccessDeniedException в HTTP-ответы, используя AuthenticationEntryPoint для начала аутентификации и AccessDeniedHandler для обработки отказа в доступе.
Обратите внимание на важную механику: этот фильтр вызывает chain.doFilter(...), то есть пропускает запрос дальше, но если дальше кто-то бросит security-исключение, он его перехватит и превратит в ответ. В документации даже приводится псевдокод в духе try doFilter, catch exceptions, а дальше — либо запуск аутентификации, либо отказ в доступе.
Вот учебная мини-версия этой идеи:
public class ExceptionTranslationLike {
public static String handle(boolean authenticated, boolean accessDenied) {
try {
// Симулируем ситуацию «доступ запрещён» где-то глубже по цепочке.
// В реальности это были бы AccessDeniedException / AuthenticationException.
if (accessDenied) {
throw new RuntimeException("AccessDenied"); // упрощаем для примера
}
// Если ошибок нет — цепочка «пропускает запрос дальше».
return "OK";
} catch (RuntimeException ex) {
// Важная развилка: если пользователь не аутентифицирован,
// то ответ обычно будет про «начать аутентификацию».
if (!authenticated) {
return "Start authentication";
}
// Если пользователь аутентифицирован, но прав нет — «доступ запрещён».
return "Access denied";
}
}
}
Это не Spring Security-код, а просто иллюстрация смысла: в одном месте цепочка решает, что сказать клиенту, когда доступ невозможен.
6. Этап 5 — авторизация запроса: «пустить дальше или нет»
Когда у нас уже есть понятное security-состояние — контекст и либо пользователь, либо anonymous, — цепочка подходит к главному практическому вопросу: «можно ли этому запросу идти дальше в DispatcherServlet, контроллеры и бизнес-логику?». Это и есть этап авторизации: проверка доступа.
В современных цепочках роль «проверить доступ» выполняют фильтры уровня авторизации, и в списке типичных фильтров вы встретите AuthorizationFilter, а также исторические элементы вроде FilterSecurityInterceptor. Нам сейчас не важно, как именно вы будете описывать правила, важно понять момент: решение по доступу может быть принято ещё до контроллера.
И вот здесь становится понятным наблюдение из прошлого дня: после подключения security-starter почти всё закрылось. Это произошло не потому, что контроллеры внезапно стали злыми и начали кидаться 403, а потому что на этапе авторизации security-цепочка сказала: «стоп, дальше нельзя» — и запрос закончился раньше MVC.
На уровне простой модели в голове это можно представить так:
public class AuthorizationDecisionDemo {
public static String decide(String path, boolean authenticated) {
// Условно считаем, что всё под /api/public/ — публичное.
// (Как именно это настраивается в Spring Security — отдельная тема.)
boolean isPublic = path.startsWith("/api/public/");
// Публичное — пропускаем без условий.
if (isPublic) return "ALLOW";
// Не публичное — требуем, чтобы пользователь был «не anonymous».
if (!authenticated) return "DENY (needs authentication)";
// Если пользователь аутентифицирован — разрешаем.
return "ALLOW";
}
}
Мы не обсуждаем, как Spring Security узнаёт, что /api/public/** должно быть public, — это будет отдельная тема. Здесь важно закрепить другое: авторизация — отдельный этап, который использует результаты ранних этапов, то есть контекст и пользователь/anonymous.
7. Порядок этапов критичен: сломать всё шагом
В теории можно придумать десятки этапов и ещё больше красивых стрелочек. Но инженерная ценность появляется в тот момент, когда вы понимаете: порядок этапов — это не эстетика, а зависимость по данным. Поздний этап не может работать, если ранний не подготовил входные данные. Нельзя честно проверять доступ, если вы ещё не привели «текущего пользователя» к понятному состоянию.
Например, если авторизация начнётся раньше, чем цепочка решила «authenticated vs anonymous», то проверка будет либо постоянно видеть null, либо начнёт обрастать ветвлениями и превращаться в кашу. Поэтому идеи «давайте поменяем местами фильтры, чтобы работало» обычно заканчиваются тем, что оно начинает работать… но только в одном браузере и только по вторникам.
Учебная иллюстрация, почему anonymous перед access check логичнее, чем наоборот:
import java.util.List;
public class OrderingDemo {
public static void main(String[] args) {
// Неправильная логика: сначала проверяем доступ, потом «наводим порядок» с anonymous.
List<String> wrongOrder = List.of("authorize", "set anonymous");
// Правильная логика: сначала приводим состояние пользователя к явному виду,
// и только потом принимаем решение по доступу.
List<String> goodOrder = List.of("set anonymous", "authorize");
// Печатаем просто чтобы визуально закрепить идею порядка.
System.out.println(wrongOrder); // [authorize, set anonymous]
System.out.println(goodOrder); // [set anonymous, authorize]
}
}
Если держать в голове именно зависимости этапов, реальный список фильтров перестаёт пугать. Он уже не выглядит случайным и начинает читаться как «много маленьких шагов, каждый из которых делает понятное дело».
8. Типичные ошибки при попытке понять этапы security chain
Ошибка №1: пытаться выучить Spring Security как список классов.
Такой подход часто приводит к эффекту «я всё выучил и всё равно ничего не понимаю». Названия фильтров похожи друг на друга, а реальная логика теряется. Намного полезнее закрепить пять этапов и только потом привязывать к ним конкретные классы вроде AnonymousAuthenticationFilter или ExceptionTranslationFilter.
Ошибка №2: думать, что security-решение принимается в контроллере.
После пары ситуаций вида «почему метод не вызвался?» новичок начинает подозревать, что Spring сломался. На самом деле запрос мог быть остановлен на этапе авторизации или на этапе обработки security-исключений — и до DispatcherServlet он просто не дошёл. Это абсолютно нормальный и правильный дизайн.
Ошибка №3: путать «нет пользователя» и «anonymous пользователь».
Когда кажется, что anonymous = null, начинаются странные проверки и хаос. В Spring Security anonymous — это намеренная модель состояния, чтобы последующие этапы работали единообразно.
Ошибка №4: не понимать роль ExceptionTranslationFilter и ждать, что любой сбой в security будет просто исключением.
Если не знать про этот этап, легко удивиться: «почему я не вижу stacktrace?» или «почему меня редиректит?». Перевод исключений в HTTP-ответы — обязанность security-слоя. Он перехватывает нужные исключения и превращает их в реакцию для клиента.
Ошибка №5: пытаться “пофиксить 403” перестановкой фильтров наугад.
Если менять порядок или набор фильтров без понимания зависимостей этапов, легко получить систему, которая вроде работает, но ломается на других типах запросов: например, в curl всё ок, а в браузере редиректы, или наоборот. Порядок этапов — это зависимость по данным, а не настройка вкуса.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ