1. Полный auth flow целиком
Пока вы не увидите весь путь аутентификации, Spring Security будет казаться «чёрным ящиком». Кажется, будто фреймворк «сам решает», а вы только наблюдаете. На деле всё вполне последовательно: данные запроса превращаются в Authentication, затем проходят через manager/provider, а результат попадает в SecurityContext текущего запроса.
Важная мысль: если вы знаете этот путь, вы уже можете мысленно отладить половину проблем, даже не открывая IDE. Вы начинаете задавать себе правильные вопросы: «где сформировалась попытка?», «дошло ли до AuthenticationManager?», «какой provider взяли?», «что вернулось?», «попало ли это в SecurityContext?». А это и есть тот самый момент, когда security перестаёт быть магией и становится инженерией.
Схема auth flow
Чтобы мозг не пытался держать 15 классов одновременно, полезно иметь одну «картинку», к которой вы будете возвращаться каждый раз, когда что-то не работает. Ниже — базовая схема без лишнего low-level: только то, что нам нужно на уровне Junior. Обратите внимание: контроллеры вообще не участвуют в проверке логина/пароля, они приходят уже после того, как security-слой сформировал текущего пользователя (или не сформировал).
flowchart TD
R["HTTP request"] --> FC["Spring Security Filter Chain"]
FC --> F1["Authn filter извлекает credentials"]
F1 --> A1["Authentication attempt principal+credentials"]
A1 --> AM["AuthenticationManager authenticate()"]
AM --> PM["ProviderManager"]
PM --> AP["AuthenticationProvider (DaoAuthenticationProvider)"]
AP --> UDS["UserDetailsService loadUserByUsername()"]
AP --> PE["PasswordEncoder matches()"]
AP --> A2["Authentication result authenticated principal+authorities"]
A2 --> SC["SecurityContext setAuthentication()"]
SC --> NEXT["дальше по chain → MVC"]
Если вам удобнее «пощупать» это как таблицу, вот тот же смысл в компактном виде:
| Что происходит | Кто делает | Чем это выражено в коде |
|---|---|---|
| Из запроса достали данные для входа | security-фильтр (концептуально) | строка username + строка password (или другой формат) |
| Упаковали данные в объект | security-фильтр | Authentication attempt (например, ) |
| Запустили проверку | AuthenticationManager | authenticate(attempt) |
| Подобрали реализацию проверки | ProviderManager | поиск provider по |
| Реально проверили логин/пароль | DaoAuthenticationProvider | UserDetailsService + |
| Сформировали итог | provider | Authentication result (уже authenticated) |
| Сохранили «текущего пользователя» | security-слой | SecurityContext.setAuthentication(result) |
Эту схему мы сейчас разберём пошагово, но цель не в том, чтобы вы зазубрили имена. Цель — чтобы вы умели восстановить цепочку в голове и понимать, где какая ответственность.
2. От запроса к Authentication attempt
Представьте, что к вашему Secure Content Platform API пришёл запрос к защищённому endpoint’у. Где-то в этом запросе есть «доказательство личности» пользователя: иногда это логин/пароль (в том или ином виде), иногда вообще ничего нет — и тогда пользователь остаётся anonymous. Первый важный шаг: security-слой не таскает по коду две строки username и password как случайные аргументы; он складывает их в объект Authentication.
На уровне нашего сегодняшнего понимания удобно смотреть на Authentication как на конверт. В этот конверт кладём principal («кто?») и credentials («чем подтверждает?»). Пока конверт не прошёл проверку, это именно attempt — попытка. Она обычно пустая по authorities и имеет isAuthenticated() == false. Это помогает отличать «пришли данные на проверку» от «пользователь уже подтверждён».
Ниже — очень маленький, но показательный пример того, как выглядит создание попытки для username/password:
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
// Создаём "попытку аутентификации": данным ещё нельзя доверять
Authentication attempt =
UsernamePasswordAuthenticationToken.unauthenticated("alice", "qwerty");
// Важно: attempt ещё НЕ аутентифицирован
System.out.println(attempt.isAuthenticated()); // false
// Principal здесь "сырой" (часто строка), а не UserDetails из базы
System.out.println(attempt.getPrincipal()); // alice
Обратите внимание на важную психологическую деталь: здесь “alice” — это ещё не «наш пользователь из базы», не доменная сущность и даже не UserDetails. Это просто сырой principal, которому мы пока не доверяем. В этом месте нельзя делать выводов вроде «ну раз principal == alice, значит это точно Alice». Пока это заявление, а не факт.
3. Проверка: manager и providers
AuthenticationManager.authenticate(...)
Теперь у нас есть попытка, и нужно получить ответ: «верю / не верю». В Spring Security этот момент выражается очень чётко: вызов AuthenticationManager.authenticate(...). Это центральная точка дня, и полезно воспринимать её как контракт: на вход — попытка, на выход — либо подтверждённый результат, либо отказ через исключение. Никаких “maybe”, никаких «ну как-нибудь потом».
Полезно держать в голове такую модель: AuthenticationManager не обязан знать, как именно проверять пользователя. Его задача — организовать проверку и вернуть итог в правильном формате. Обычно реальной реализацией выступает ProviderManager, который просто перебирает providers и отдаёт работу тому, кто умеет проверять данный тип Authentication.
Мини-скетч того, как это выглядит в коде (без привязки к конкретному контроллеру и без попытки «написать свой Spring Security»):
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
Authentication login(AuthenticationManager manager, String username, String password) {
// 1) Упаковали входные данные в attempt
Authentication attempt = UsernamePasswordAuthenticationToken.unauthenticated(username, password);
// 2) Передали attempt в manager: дальше он выберет provider и проверит
// Важно: при ошибке здесь прилетит AuthenticationException (например, BadCredentialsException)
return manager.authenticate(attempt);
}
Если аутентификация успешна, вы получите другой объект Authentication, в котором isAuthenticated() уже будет true, principal станет «проверенным» (часто UserDetails), а authorities появятся. Если неуспешна, на этом же месте полетит AuthenticationException (например, BadCredentialsException), и дальше по цепочке запрос пойдёт уже по failure-path.
ProviderManager и выбор провайдера
Внутри ProviderManager есть, по сути, простой, но очень важный механизм: «у меня есть несколько способов проверки, я выберу подходящий». Это выглядит почти как очередь в МФЦ: вы пришли с задачей, а вас направляют к нужному специалисту. Для Spring Security «тип задачи» — это класс входного Authentication.
Ключевой технический механизм — метод supports(...) у AuthenticationProvider. Provider говорит: «я умею работать с такими токенами, а с другими — нет». Благодаря этому одна и та же web-инфраструктура (фильтры, chain) может запускать разные типы аутентификации, но проверка будет делегирована в правильное место.
Вот крошечный пример логики supports(...) (в реальном проекте вы обычно не пишете это руками для DaoAuthenticationProvider, но нам важно понять идею):
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
class DemoSupports {
boolean supportsUsernamePassword(AuthenticationProvider provider) {
// ProviderManager использует supports(...) чтобы понять, "может ли" провайдер обработать токен
return provider.supports(UsernamePasswordAuthenticationToken.class);
}
}
Если вы когда-нибудь будете отлаживать ситуацию «почему вообще не вызывается мой provider», первая мысль должна быть не «Spring сломан», а «мой provider не поддерживает этот тип Authentication, и ProviderManager его игнорирует». Это почти всегда объясняет загадочные «почему не попадает туда, куда я ожидаю».
DaoAuthenticationProvider: загрузка пользователя и пароль
Когда ProviderManager выбрал DaoAuthenticationProvider, начинается самое «земное»: загрузить пользователя и сравнить пароль. И здесь важно не перепутать роли. DaoAuthenticationProvider — не слой данных и не репозиторий. Он использует UserDetailsService, чтобы получить UserDetails. А сравнение введённого пароля с сохранённым делает через PasswordEncoder. Мы не углубляемся в алгоритмы, но фиксируем два факта: пароль сравнивается не через “==”, и provider не хранит пользователей внутри себя.
Очень упрощённо (именно как учебная схема) это можно представить так:
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.crypto.password.PasswordEncoder;
Authentication verify(UserDetailsService uds, PasswordEncoder pe, String username, String rawPassword) {
// 1) Загружаем пользователя (security-представление), а не доменную сущность
UserDetails user = uds.loadUserByUsername(username);
// 2) Сравнение пароля — через PasswordEncoder, а не через "=="
if (!pe.matches(rawPassword, user.getPassword())) throw new BadCredentialsException("Bad credentials");
// 3) Формируем НОВЫЙ authenticated-токен
// credentials обычно "чистят", поэтому здесь null
return UsernamePasswordAuthenticationToken.authenticated(user, null, user.getAuthorities());
}
Здесь есть несколько тонких моментов, которые новичок часто пропускает. Во-первых, UserDetailsService возвращает не «нашего доменного пользователя», а security-представление, где есть username, password hash и authorities. Во-вторых, provider формирует новый Authentication, который уже считается проверенным. В-третьих, credentials в результате обычно «чистятся» (поэтому мы ставим null), чтобы случайно не протащить пароль дальше.
И да: слово dao в названии не означает «обязательно база данных прямо сейчас». В рамках нашего курса это означает «пользователь загружается через отдельный сервис», а не создаётся внутри контроллера. Сегодня нам достаточно именно этого смысла, без погружения в persistence.
4. Результат и SecurityContext
Authentication result vs attempt
Снаружи может казаться, что attempt и result — это «один и тот же объект, только флажок переключили». Но полезнее мыслить иначе: attempt — это заявка («я — alice, вот мой пароль»), а result — это решение системы («мы проверили, это действительно alice, и вот её права»). Поэтому в результате меняется и уровень доверия, и содержимое.
Ниже — маленький пример, который показывает, что после успешной проверки у вас появляется аутентифицированное состояние, и оно принципиально отличается по смыслу:
import org.springframework.security.core.Authentication;
void printResult(Authentication result) {
// Должно быть true, иначе вы смотрите не на result, а на attempt/anonymous
System.out.println(result.isAuthenticated()); // true
// Principal после успеха обычно становится "проверенным" объектом (часто UserDetails)
System.out.println(result.getPrincipal().getClass().getName()); // ...User / ...UserDetails
// Authorities берутся из системы (из UserDetails), а не "присылаются клиентом"
System.out.println(result.getAuthorities().size()); // например, 1 или 3
}
Обратите внимание на главное: principal после успешной аутентификации обычно перестаёт быть «просто строкой» и становится объектом, который Spring Security считает проверенным. Часто это UserDetails (или объект, который его реализует). Authorities появляются не потому, что клиент их прислал, а потому, что security-слой загрузил пользователя и узнал, какими правами он обладает.
Если вам хочется юмора: attempt — это как «я честно-пречестно совершеннолетний», а result — это когда паспорт действительно проверили. В интернете, к сожалению, паспорт проверять приходится чаще, чем хотелось бы.
SecurityContext: текущий пользователь в рамках запроса
Даже если мы идеально аутентифицировали пользователя, это ещё не конец истории. Нужно сделать так, чтобы все следующие шаги обработки запроса понимали: «пользователь уже проверен, вот кто он». Для этого Spring Security использует SecurityContext. На текущем уровне курса его удобно воспринимать как контейнер, который держит Authentication для текущего запроса и делает этот объект доступным остальным security-компонентам дальше по цепочке.
Важно: SecurityContext — это не «глобальная переменная приложения». Он привязан к текущему выполнению запроса (обычно через ThreadLocal-стратегию). Поэтому он отлично подходит для «текущего пользователя в рамках этого запроса», но очень плохо подходит для идеи «давайте положим туда что-то, и оно будет жить вечно».
Мини-схема установки результата в контекст (в реальности это делает security-инфраструктура внутри фильтров, но нам важно увидеть сам жест):
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
void putToContext(Authentication result) {
// С этого момента downstream-части цепочки будут видеть "текущего пользователя" через SecurityContext
SecurityContextHolder.getContext().setAuthentication(result);
}
И вот тут критически полезно связать всё с прошлым днём про anonymous. Даже когда пользователь не вошёл, SecurityContext всё равно может быть не пустым: там может лежать AnonymousAuthenticationToken. Поэтому «контекст есть» не равно «пользователь вошёл». Важны состояние и тип Authentication, а не сам факт существования объекта.
Как SecurityContext влияет на дальнейшую обработку
Как только SecurityContext заполнен, весь остальной Spring Security начинает работать гораздо проще: вместо того чтобы снова проверять пароль или заново искать пользователя, он просто читает Authentication из контекста и принимает решения об ограничениях доступа. Это тот момент, где аутентификация заканчивается, а авторизация начинает «питаться» её результатом: «кто ты» уже известно, теперь решаем «можно ли тебе сюда».
Для нашего проекта это особенно важно концептуально. Мы хотим, чтобы запросы к публичным статьям могли проходить как anonymous, а запросы к личной зоне (например, /api/me) уже опирались на понятие текущего пользователя. Но мы пока не пишем правила доступа и не настраиваем SecurityFilterChain; наша задача сегодня — понять, что без заполненного SecurityContext никакой «текущий пользователь» в приложении не появится, даже если где-то там «есть контроллер».
Когда этот маршрут виден целиком, явная SecurityFilterChain перестаёт выглядеть как набор случайных DSL-вызовов: она просто управляет понятным pipeline, а не придумывает новую систему с нуля.
Чтобы чуть закрепить картину, полезно представить временную шкалу:
sequenceDiagram
participant Client as Клиент
participant Sec as Spring Security filters
participant MVC as "DispatcherServlet + Controllers"
Client->>Sec: "HTTP request + credentials (или без)"
Sec->>Sec: "authenticate() + заполнить SecurityContext"
Sec->>MVC: "запрос уже с current Authentication (или anonymous)"
MVC->>Client: "ответ контроллера (или security-отказ)"
Самый частый инженерный инсайт у новичка после этой схемы такой: «Ага, значит, если я пытаюсь “проверить пользователя” в контроллере, я опоздал: security уже должен был всё сделать до меня». И это правда. Контроллер — плохое место для аутентификации не потому, что так «принято», а потому, что архитектурно она уже произошла (или провалилась) раньше.
5. Failure path в auth flow
Жизнь редко состоит из одних правильных паролей. Иногда credentials отсутствуют, иногда пароль неверный, иногда пользователь не найден, иногда аккаунт не должен проходить проверку. Важно увидеть, что failure path — это не «где-то сломалось», а такой же нормальный путь, просто с другим итогом: контекст не получает authenticated Authentication, и дальше chain приводит к отказу.
В базовой модели есть две большие ситуации, которые по ощущениям похожи («не пустили»), но по внутреннему смыслу разные. Первая — credentials просто не пришли. Тогда аутентификации как попытки может не быть вообще, и запрос остаётся в anonymous-состоянии. Вторая — credentials пришли, но проверка провалилась. Тогда provider кидает AuthenticationException, а security-инфраструктура прекращает обычную обработку и формирует отказ.
Упрощённо это можно представить так:
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
void onAuthFailure() {
// Даже при неуспехе контекст может содержать Authentication (например, anonymous-токен)
Authentication current = SecurityContextHolder.getContext().getAuthentication();
// Поэтому проверка "auth == null" почти никогда не является корректной проверкой входа
System.out.println(current == null); // часто false: может быть anonymous
}
Мы пока не разбираем детально, какой HTTP-код вернётся и почему это иногда бывает редиректом в браузере. Это отдельная важная тема. Но здесь вам нужно увидеть основу: если «успешный result» не дошёл до SecurityContext, то для системы пользователь не аутентифицирован — и всё, дальнейшие решения будут приниматься именно из этого факта.
6. Типичные ошибки при аутентификации
Ошибка №1: пытаться делать аутентификацию в контроллере.
Контроллер работает уже после security-фильтров. Если вы начинаете вручную читать логин и пароль в @PostMapping, вы обходите filter chain и теряете встроенные механизмы Spring Security. Аутентификация — задача security-слоя, а не бизнес-эндпоинта.
Ошибка №2: считать, что наличие Authentication означает успешный вход.
Объект может существовать, но быть anonymous или описывать незавершённую попытку. Проверка вида auth != null — это ловушка. Важно различать «объект есть» и «пользователь аутентифицирован и доверен».
Ошибка №3: проверять пароль внутри UserDetailsService.
UserDetailsService отвечает только за загрузку пользователя. Сравнение пароля — задача AuthenticationProvider через PasswordEncoder. Смешивание этих ролей ломает архитектуру и делает код менее расширяемым.
Ошибка №4: воспринимать ProviderManager как лишний слой.
Кажется, что одного provider достаточно. Но ProviderManager — это координатор, который позволяет иметь несколько способов аутентификации. Даже если сейчас provider один, такая структура делает систему гибкой и предсказуемой.
Ошибка №5: смешивать уровни ответственности в security-потоке.
Когда загрузка пользователя, проверка пароля и принятие решения о доступе оказываются в одном месте, система становится хрупкой. Каждый компонент (UserDetailsService, AuthenticationProvider, filter chain) должен выполнять свою роль — именно в этом и сила Spring Security.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ