JavaRush /Курсы /Spring Security /Полный путь аутентификации

Полный путь аутентификации

Spring Security
4 уровень , 4 лекция
Открыта

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 (например,
UsernamePasswordAuthenticationToken
)
Запустили проверку AuthenticationManager authenticate(attempt)
Подобрали реализацию проверки ProviderManager поиск provider по
supports(...)
Реально проверили логин/пароль DaoAuthenticationProvider UserDetailsService +
PasswordEncoder
Сформировали итог 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.

1
Задача
Spring Security, 4 уровень, 4 лекция
Недоступна
Полный успешный auth flow до SecurityContext
Полный успешный auth flow до SecurityContext
1
Задача
Spring Security, 4 уровень, 4 лекция
Недоступна
Неуспешный auth flow без заполнения SecurityContext
Неуспешный auth flow без заполнения SecurityContext
1
Опрос
Аутентификация Spring, 4 уровень, 4 лекция
Недоступен
Аутентификация Spring
Основы аутентификации и авторизации
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ