1. Ручная проверка пароля в login endpoint
Когда вы впервые пишете login endpoint, мозг автоматически предлагает самый прямой путь: “схожу в базу, найду пользователя, возьму passwordHash, сравню — и если совпало, выдам токен”. Это звучит так логично, что аж опасно. Проблема в том, что Spring Security уже построил для этой задачи целую инфраструктуру: с учётом PasswordEncoder, состояний аккаунта (enabled, locked), правил обработки ошибок и единых точек расширения. Если вы начинаете “проверять руками”, вы не упрощаете код — вы начинаете заново писать мини-Spring Security внутри своего сервиса, только хуже и без тестов.
В нашем проекте Secure Content Platform API это особенно критично, потому что логин — это не просто “вход для пользователя”. Это единая точка, через которую в stateless-ветке клиент получает право дальше ходить в зоны /api/me/**, /api/drafts/**, а для редактора — в /api/editor/**, и так далее. Ошибка в логине быстро превращается либо в “никто не может войти”, либо в “войти может кто угодно”, и оба варианта одинаково неприятны.
Правильный подход здесь — доверить проверку пары login/password стандартной инфраструктуре Spring Security, то есть AuthenticationManager. Он умеет делегировать проверку в AuthenticationProvider (в нашем курсе это чаще всего DaoAuthenticationProvider), а тот уже вызывает UserDetailsService, применяет PasswordEncoder и учитывает состояния аккаунта. Вам остаётся только правильно “упаковать” входные данные в объект Authentication и корректно обработать результат.
2. Authentication как “конверт”
Почти все странности вокруг login flow исчезают, если принять одну простую мысль: Authentication — это универсальный контейнер, который Spring Security носит по своей инфраструктуре. В него кладут то, что нужно для проверки пользователя (principal + credentials), а после успешной проверки — в него же кладут результат (подтверждённый principal + authorities). Как будто вы отправили конверт на проверку в службу безопасности, и вам вернули тот же конверт, но уже со штампом “проверено”.
Важно: Authentication — это не “наш пользователь из БД” и не “JWT”. Это объект, который живёт в границе security-инфраструктуры. Он удобен тем, что одинаково работает и в stateful-модели (form login), и в stateless-модели (наш JSON login endpoint), и даже в других сценариях. Мы просто используем его как правильный “формат обмена” со Spring Security.
Чтобы было проще запомнить, можно держать в голове такую табличку:
| Свойство в Authentication | До проверки (request) | После проверки (result) |
|---|---|---|
| principal | то, что пользователь предъявил как идентификатор (обычно строка логина) | подтверждённый principal (часто UserDetails, иногда просто имя) |
| credentials | сырой пароль (да, прямо так) | обычно “стерты” (null) или недоступны |
| authorities | отсутствуют | заполнены (ROLE_USER, draft:read:own, …) |
| isAuthenticated() | false | true |
Обратите внимание на самый “неуютный” момент: сырой пароль действительно попадает в Authentication на входе. Это нормально, потому что пароль нужен ровно для одного шага — проверки — но из этого следует железное правило: не логировать этот объект, не класть его в поля класса и не сохранять “на всякий случай”. Пароль — как горячая картошка: держим аккуратно и недолго.
UsernamePasswordAuthenticationToken: unauthenticated vs authenticated
В username/password сценарии Spring Security использует конкретную реализацию Authentication: UsernamePasswordAuthenticationToken. Это тот самый “конверт” для логина и пароля. И у него есть два состояния: “ещё не проверен” и “уже проверен”. В современном стиле кода это выражается через фабричные методы unauthenticated(...) и (реже) authenticated(...).
Нам почти всегда нужен именно unauthenticated(...): мы создаём запрос на проверку и отдаём его AuthenticationManager. А вот authenticated(...) руками создавать обычно не надо — его создаёт провайдер, когда проверка прошла успешно.
Мини-пример создания объекта для проверки:
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
Authentication authRequest =
UsernamePasswordAuthenticationToken.unauthenticated(
"alice", // principal: то, что пользователь предъявляет как логин/идентификатор
"qwerty" // credentials: сырой пароль (поэтому не логируем и не храним дольше нужного)
);
Этот authRequest ещё не говорит “я вошёл”. Он говорит другое: “вот логин и пароль, пожалуйста, проверьте, кто это”. Дальше мы не пытаемся сами сравнивать пароли — мы передаём этот объект на вход AuthenticationManager.
3. Как работает authenticate(...)
Снаружи вызов authenticationManager.authenticate(...) выглядит скучно, почти как обычный метод сервиса. И это хорошо: нам нужен простой “пульт управления”, который скрывает сложность внутри. Под капотом, конечно, происходит целый спектакль: AuthenticationManager (обычно это ProviderManager) перебирает подходящие AuthenticationProvider-ы, и один из них берёт на себя проверку. В нашем курсе этот провайдер чаще всего DaoAuthenticationProvider, который работает через UserDetailsService и PasswordEncoder.
Практическая ценность в том, что вы получаете всю безопасность “из коробки”: правильную проверку PasswordEncoder.matches(...), проверку enabled/locked, единообразные исключения на ошибках, и возможность сменить источник пользователей (in-memory → DB-backed) без переписывания login endpoint. То есть ваш login endpoint становится тонким и стабильным, а не “каждый новый чекпоинт — переписать логин на новый лад”.
Ниже — схема, как это выглядит на уровне проекта, без лишнего low-level:
sequenceDiagram
participant C as AuthController
participant S as AuthService
participant AM as AuthenticationManager
participant P as DaoAuthenticationProvider
participant UDS as UserDetailsService
participant PE as PasswordEncoder
participant TS as TokenService
C->>S: "login(LoginRequest)" %% (8)
S->>AM: "authenticate(unauthenticated token)" %% (8)
AM->>P: delegate
P->>UDS: "loadUserByUsername(login)" %% (8)
P->>PE: "matches(rawPassword, passwordHash)" %% (8)
P-->>AM: authenticated Authentication
AM-->>S: authResult
S->>TS: "issueAccessToken(authResult)" %% (8)
TS-->>S: IssuedAccessToken
S-->>C: AuthResponse
Обратите внимание, что в этой схеме контроллер вообще не знает, где лежит пользователь, какой encoder используется и как устроены account states. И это не “магия”, а аккуратное разделение ответственности. Контроллеру и не положено это знать: он всего лишь HTTP-граница.
Успех: что лежит в Authentication после проверки
После успешной аутентификации вы получаете Authentication authResult, который уже считается authenticated (isAuthenticated() == true). Внутри него есть минимум, который крайне полезен для дальнейших шагов: имя пользователя (getName()), набор прав (getAuthorities()), и “principal” (часто это UserDetails). Это ровно то, что нам нужно, чтобы начать выдачу access token: мы уже знаем, “кто это”, и “какие у него права”.
Важно помнить ещё один практический нюанс: Spring Security часто стирает credentials после успешной проверки (это сделано специально, чтобы пароль не жил дольше, чем нужно). Поэтому после успеха authResult.getCredentials() обычно будет null. Это не баг, а забота о вашем будущем.
Мини-пример “посмотреть, что пришло” (и не залогировать лишнего):
import org.springframework.security.core.Authentication;
// Берём только безопасные для использования поля: имя и флаг authenticated
String username = authResult.getName();
boolean ok = authResult.isAuthenticated();
// Важно: не печатаем credentials и не логируем весь объект authResult целиком
System.out.println("username = " + username); // username = alice
System.out.println("authenticated = " + ok); // authenticated = true
В реальном коде вы, скорее всего, не будете печатать это в консоль, а используете логгер. Но смысл важный: authResult — это подтверждённый пользователь для текущего запроса. Именно его мы передаём дальше в TokenService, а не “сырые” данные из LoginRequest.
Неуспех: какие исключения ждать и почему это 401
Когда аутентификация не удалась, AuthenticationManager.authenticate(...) не возвращает “пустой результат”. Он бросает исключение из иерархии AuthenticationException. Это удобно: провал логина — это не “обычный результат”, это ветка ошибки, и она должна обрабатываться отдельно.
Внутри этой ветки могут быть разные причины: неверный пароль (BadCredentialsException), выключенный аккаунт (DisabledException), заблокированный (LockedException) и так далее. Детали зависят от того, как у вас устроены account states (мы это уже обсуждали раньше, когда добавляли enabled/locked в модель).
С точки зрения HTTP-семантики провал логина — это 401 Unauthorized, потому что клиент не смог подтвердить личность. 403 Forbidden — это “личность подтверждена, но нельзя”, а здесь личность не подтверждена. Эта разница не декоративная: клиенты (и люди) по-разному реагируют на эти статусы.
Пример кода, который явно показывает идею (но без лишней драматургии):
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
try {
// Аутентификация: либо вернёт authenticated Authentication, либо бросит AuthenticationException
Authentication authResult = authenticationManager.authenticate(authRequest);
// Успех — можно выдавать токен, используя подтверждённого principal/authorities
// tokenService.issueAccessToken(authResult);
} catch (AuthenticationException e) {
// Провал логина — это 401-сценарий; исключение уйдёт в согласованную обработку ошибок
throw e;
}
Здесь мы не “проглатываем” исключение и не превращаем его в null. Мы даём ему попасть в нашу согласованную систему обработки ошибок, чтобы клиент получил предсказуемый JSON-ответ с правильным статусом.
4. AuthenticationManager в AuthService
На этом месте обычно наступает момент облегчения: вся механика действительно сводится к небольшой связке кода. Но важно не превратить это в “спагетти на радостях”. Наша цель — чтобы login flow читался сверху вниз, как рецепт: “прими данные → аутентифицируй → выдай токен → верни ответ”. Для этого мы держим контроллер тонким, а логику — в AuthService, который координирует шаги.
В проекте Secure Content Platform API это выглядит особенно естественно, потому что у нас уже есть разграничение пакетов под security: security.auth для login flow, security.jwt для токенов, и так далее. AuthenticationManager — это зависимость именно security-сервиса, а не чего-то “общего по приложению”. Вы не хотите, чтобы случайный ContentService внезапно начал аутентифицировать пользователей (это почти всегда означает, что границы поплыли).
Мини-версия метода login, где виден весь каркас:
import com.example.securecontent.security.jwt.IssuedAccessToken;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.Authentication;
public AuthResponse login(LoginRequest request) {
// Упаковываем входные данные в "конверт" Authentication и отдаём на проверку инфраструктуре
Authentication authResult = authenticationManager.authenticate(
UsernamePasswordAuthenticationToken.unauthenticated(
request.getLogin(), // principal: логин/идентификатор
request.getPassword() // credentials: сырой пароль (не сохраняем нигде)
)
);
// Токен выдаём только на основе уже подтверждённого Authentication (principal + authorities)
IssuedAccessToken token = tokenService.issueAccessToken(authResult);
// Возвращаем уже зафиксированный token response; пароль к этому моменту больше не нужен
return new AuthResponse(token.value(), "Bearer", token.expiresInSeconds());
}
Обратите внимание, что пароль живёт здесь буквально одну строку — внутри unauthenticated(...). Мы не сохраняем его в переменные класса, не логируем и не пытаемся “на всякий случай” передать куда-то ещё. После вызова authenticate(...) нам пароль больше не нужен.
Откуда взять AuthenticationManager как bean
Иногда студенты упираются не в логику, а в “как мне вообще получить AuthenticationManager?”. Это нормальная трудность: AuthenticationManager — часть security-инфраструктуры, и мы хотим получить его из контекста Spring, а не создавать вручную. Самый простой путь — попросить его у AuthenticationConfiguration, который знает, как собрать правильный manager на основе ваших provider-ов.
Ниже — минимальный и читаемый вариант (в стиле Spring Boot / Spring Security 7):
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
@Configuration
public class SecurityBeansConfig {
@Bean
AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
// Забираем готовый AuthenticationManager из Spring Security конфигурации,
// чтобы он использовал ваши provider-ы, UserDetailsService и PasswordEncoder
return config.getAuthenticationManager();
}
}
Это решение хорошо тем, что оно не привязано к вашему user store напрямую. Сегодня у вас DB-backed users с CustomUserDetailsService — manager будет работать с ними. Завтра вы переключились на другой источник — manager останется корректным, потому что он собирается из актуальной конфигурации security-контекста.
5. Схема login flow и самопроверка
Полезно один раз увидеть весь flow в виде “коридора”, чтобы потом не запутаться в классах и DTO. Мы не обсуждаем сейчас состав claims и подпись токена — это отдельные темы. Здесь нам важна только механика “credentials → authenticated principal”, потому что именно на этом шаге чаще всего возникают странные самописные проверки и хрупкие костыли.
Схема ниже показывает, что в login endpoint мы не “входим в систему через сессию”, а просто подтверждаем пользователя и выдаём ему строку-токен:
flowchart TD
A["LoginRequest
login + password"] --> B["UsernamePasswordAuthenticationToken.unauthenticated(...)"]
B --> C["AuthenticationManager.authenticate(...)"]
C -->|success| D["Authentication authResult
principal + authorities"]
D --> E["TokenService.issueAccessToken(authResult)"]
E --> F["AuthResponse
accessToken + expiresIn"]
C -->|failure| G["AuthenticationException
401 JSON error"]
Если вы поймаете себя на мысли “а почему я не сравниваю пароль сам?”, возвращайтесь к этой схеме. Ваша зона ответственности — правильно построить flow, а не повторить внутренности DaoAuthenticationProvider.
6. Типичные ошибки при login flow
В этой теме ошибки обычно появляются не из-за “незнания аннотаций”, а из-за неправильной mental model. Когда разработчик не доверяет security-инфраструктуре, он начинает добавлять ручные проверки, и они постепенно конфликтуют с тем, что Spring Security делает сам. Поэтому лучше заранее знать самые популярные грабли — хотя бы чтобы наступать на них осознанно и с чувством собственного достоинства.
Ошибка №1: ручная проверка пароля через PasswordEncoder в сервисе логина.
Очень соблазнительно написать “вот user из БД, вот passwordEncoder.matches(...), я сам всё проверю”. Но этим вы выбрасываете из картины AuthenticationProvider, account states, единую обработку ошибок и расширяемость механизма. Ваш сервис внезапно начинает знать слишком много о security, и это почти всегда заканчивается рассинхроном поведения между login endpoint и остальными security-потоками.
Ошибка №2: выдача токена до успешного authenticate(...).
Иногда встречается “оптимизация”: сначала собрать токен “на всякий случай”, потом проверить пароль. Это не оптимизация, это логическая ошибка. Токен — это результат успешной аутентификации, и он появляется только после того, как AuthenticationManager вернул authenticated Authentication. Если поменять порядок, вы неизбежно получите путь, в котором токен будет выдан не тому или не тогда.
Ошибка №3: хранить/логировать raw password (или весь authRequest) где-то “для отладки”.
В authRequest лежит сырой пароль. Если вы логируете его в debug, сохраняете в поле класса, отправляете в другой сервис или кладёте в исключение — вы делаете утечку credentials своими руками. Отладка — это важно, но пароль в логах — это уже не отладка, а будущий инцидент, который будет стыдно разбирать.
Ошибка №4: считать любой провал логина “403 Forbidden”.
403 означает, что пользователь уже подтверждён, но ему нельзя. Провал логина означает, что подтверждения личности не произошло. Это 401. Если перепутать, клиентская логика (и люди) начнут реагировать неправильно: вместо “попросить ввести пароль заново” получится “у вас нет прав”, хотя прав могло бы быть достаточно.
Ошибка №5: пытаться “обойти” account states и чинить их в логине.
Если пользователь locked/disabled, это должно отражаться в стандартном authentication flow. Не нужно добавлять в login endpoint свои проверки “а давайте я вручную посмотрю enabled и верну другое сообщение”. Вы получите два источника истины. Правильнее позволить AuthenticationManager/AuthenticationProvider-ам сделать свою работу и отдать ошибку в согласованном формате.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ