1. Идентификатор входа: одно правило
К этому месту у нас уже собраны почти все детали DB-backed auth: UserAccount из БД, CustomUserDetailsService, маппинг в UserDetails, DaoAuthenticationProvider и понимание того, что roles должны приехать в auth-путь вовремя. Осталась последняя переменная, из-за которой цепочка всё ещё может расползтись: какую строку считать login identifier и какое имя пользователя потом окажется в SecurityContext.
Когда вы впервые видите метод loadUserByUsername, кажется, что вопрос закрыт: «входим по username, иначе Spring Security расстроится». На деле Spring Security расстроится не от выбора email, а от непоследовательности. Этот метод — просто контракт: ему дают строку входа, а он обязан вернуть UserDetails или честно сказать «не нашёл». Если вы сегодня ищете по username, завтра по email, а UserDetails#getUsername() возвращает вообще третье — у вас получится аутентификация уровня «угадай мелодию».
Ключевая мысль: идентификатор входа — это проектное решение, а не «как в туториале было написано». В Secure Content Platform API у нас есть и username, и email (оба уникальные), и оба выглядят как хорошие кандидаты. Но кандидат должен стать единственным правилом: в репозитории, в CustomUserDetailsService, в логах и в том, что вы видите как authentication.getName().
Чтобы зафиксировать это на уровне кода (и на уровне мозга), удобно даже переменную называть не username, а login, чтобы не гипнотизироваться названием метода:
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
public interface UserDetailsService {
// Важно: параметр — это "строка входа" (login), а не обязательно именно username.
// Какой смысл у этой строки — решаете вы (и затем держите это правило везде одинаковым).
UserDetails loadUserByUsername(String login);
}
Да, интерфейс так не объявлен (там будет String username), но мыслить «это login» — полезно. Spring Security не обидится.
2. username vs email на backend
Выбор между username и email часто превращается в спор уровня «vim vs intellij». А нам нужно инженерно: что будет проще поддерживать, что стабильнее, какие ошибки мы поймаем через месяц, когда забудем, что тут вообще происходило. В backend’е идентификатор входа — это не только UX, это ещё и уникальные индексы, нормализация строк, логирование, переименование пользователя, и даже (не сегодня, но в целом) работа админки.
Ниже мы сравним эти два варианта не как «что удобнее пользователю», а как «что лучше держит форму в коде и данных». И ещё важный момент: оба варианта могут быть корректными. Некорректен обычно третий вариант — «как-то само получилось».
Сведём ключевые отличия в компактную таблицу (не как «плюсы-минусы на салфетке», а как чек-лист для архитектурного решения):
| Критерий | username как login | email как login |
|---|---|---|
| Стабильность идентификатора | Обычно меняется редко (часто никогда) | Может меняться (пользователь сменил почту) |
| Нормализация (регистр/пробелы) | Зависит от правил проекта | Почти всегда нужно trim + lowercase (и договориться, что это значит) |
| Приватность | В логах и ответах меньше PII | Email — это PII, и он чаще «светится» в логах/ошибках |
| Уникальность | Требуется уникальность username | Требуется уникальность email (и обычно это ожидаемо) |
| UX | Пользователю надо помнить ник | Пользователь обычно помнит email лучше |
| Сложность миграций | Обычно проще | Смена email может затрагивать «имя пользователя» в SecurityContext |
Дальше разберём это чуть подробнее — именно те места, где обычно «стреляет», когда проект уже живёт.
Стабильность и жизненный цикл идентификатора
На старте кажется, что идентификатор входа — просто поле, по которому мы делаем findBy.... Но через некоторое время проект начинает жить реальной жизнью: кто-то меняет email, кто-то хочет переименовать username, кто-то получает поддержку от админа «перекиньте мне аккаунт на новую почту». И вот тут внезапно выясняется, что выбранный идентификатор — это не только поиск в БД, это ещё и то, как система «называет» пользователя внутри SecurityContext.
username обычно воспринимается как внутреннее имя аккаунта, которое можно сделать стабильным. Даже если пользователь поменял почту, его username остаётся прежним — и ваши логи, аудит и внутренние связи не «перекрашиваются». В этом есть большой плюс для поддерживаемости: меньше причин что-то менять в security-слое.
email как логин часто удобнее пользователю, но он «по природе своей» более изменчив. А значит, если вы используете email как UserDetails#getUsername(), то в логах, в authentication.getName() и в любых «связанных местах» у вас начнёт фигурировать новая строка. Иногда это нормально, иногда — неожиданно раздражает.
В учебном проекте нам особенно важно, чтобы модель была предсказуемой и не требовала 10 объяснений на тему «почему вчера в логах был alice@mail.com, а сегодня alice@newmail.com — это точно тот же пользователь?».
Уникальность и нормализация
Когда в систему прилетает строка логина, она прилетает не из идеального мира. Люди копируют email с пробелом в конце, вводят ALICE@MAIL.COM, а иногда вообще вставляют «красивую кавычку» (да, такое бывает, и да, это боль). Поэтому идентификатор входа почти всегда требует нормализации, иначе вы получаете «пользователь точно есть в БД, но войти не может».
Для username правила нормализации — это ваше решение. Можно сделать username case-sensitive, можно сделать case-insensitive, можно запретить пробелы, можно разрешить подчёркивание. Главное — чтобы правило было одно.
Для email нормализация почти неизбежна. Как минимум trim и обычно toLowerCase(). И здесь важно: нормализация должна быть консистентной. Нельзя один раз сохранить email как есть, а искать в логине по lowercase, потому что вы начнёте ловить «не найдено» на ровном месте.
Мини-утилита, которая часто становится спасением (и одновременно напоминанием, что строка — это не просто строка):
private String normalizeEmail(String raw) {
// Нормализация — часть "контракта входа", а не косметика.
if (raw == null) return null;
// Минимальный набор: trim + lowercase (дальше можно добавлять более строгие правила).
return raw.trim().toLowerCase(); // " Alice@Mail.com " -> "alice@mail.com"
}
Да, это выглядит слишком просто. Но именно такие «слишком простые» вещи обычно ломают вход на демо в самый торжественный момент.
Приватность и тексты ошибок
Когда вы выбираете email как идентификатор входа, вы автоматически повышаете риск того, что email начнёт «светиться» везде: в сообщениях UsernameNotFoundException, в логах, в трассировке ошибок. Внутри команды это не страшно, но вообще email — это PII, и с ним лучше обращаться аккуратно.
Тут тонкий момент: даже если мы сейчас не строим REST-friendly JSON ошибки, привычки формируются уже сегодня. Если вы везде пишете "User not found: " + login, а login — email, вы сами себе создаёте логи, которые потом придётся чистить.
Для username этот риск обычно меньше, потому что username часто не является персональными данными (хотя бывает и иначе). Поэтому в учебном проекте, где мы хотим ясности и минимум отвлекающих факторов, username часто оказывается более «не токсичным» вариантом.
Это не значит «email нельзя». Это значит: выбирая email, вы берёте на себя дисциплину — нормализация, аккуратные тексты ошибок, аккуратное логирование.
3. Решение для Secure Content Platform API
На этом этапе нам нужно не «идеальное решение для всех продуктов мира», а решение, которое делает учебный проект стабильным, понятным и легко проверяемым. В Secure Content Platform API нам важны роли, состояния аккаунта, корректная загрузка ролей из БД и предсказуемое поведение SecurityContext. Поэтому разумно зафиксировать: в этой основной ветке проекта логин выполняется по username.
Это решение хорошо укладывается в наши цели: username обычно стабильнее, меньше требует нормализации и меньше похож на персональные данные. Email при этом остаётся в модели UserAccount как важное поле профиля/контакта, но не как ключ «кто ты внутри security». Так мы не теряем email вообще, просто не делаем его центром auth-механики.
Важнее самого выбора — консистентность. Вот маленькая «матрица согласованности», которую полезно держать в голове (и которую приятно проверять глазами при code review):
| Элемент | Должен опираться на… (если login = username) |
|---|---|
| UserAccountRepository | поиск по username |
| CustomUserDetailsService | findByUsername(...) |
| UserDetails#getUsername() | возвращает account.getUsername() |
| Логи/ошибки | используют username (или нейтральные формулировки) |
Если эти четыре точки совпадают, ваш auth flow получается скучным. А скучный auth flow — это комплимент.
4. Код логина по username
Когда выбор сделан, код начинает выглядеть почти «подозрительно простым». И это хорошо: мы не хотим, чтобы аутентификация зависела от 15 условий и 3 веток «а вдруг это email». Нам нужно ровно одно: получить строку логина, найти аккаунт, собрать UserDetails. Всё остальное (проверка пароля, проверка enabled/locked, построение Authentication) уже делает Spring Security через DaoAuthenticationProvider.
Ниже — опорные куски кода. Не как «вставьте и молитесь», а как ориентир: где именно в проекте живёт решение «мы логинимся по username».
Репозиторий для auth и роли
Для основной ветки проекта фиксируем один auth-репозиторий: поиск по username с явной загрузкой roles. fetch join, @Transactional и другие техники полезны ровно настолько, насколько они приводят к той же гарантии. Но сам snapshot проекта держим простым и однозначным: к моменту toUserDetails(...) роли уже должны быть доступны.
В auth-сценарии роли нужны прямо сейчас, потому что Spring Security после успешной аутентификации кладёт authorities в Authentication. Если роли ленивые и не подгрузились — вы или получите ошибку, или получите «пользователь без ролей», что почти всегда равнозначно «пользователь без прав».
Поэтому репозиторий для auth лучше делать так, чтобы он возвращал аккаунт сразу с ролями. Самый мягкий для курса способ — @EntityGraph, чтобы не уходить в JPA-экскурсии на 40 минут:
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserAccountRepository extends JpaRepository<UserAccount, Long> {
// Это основной auth-репозиторий проекта: для логина роли должны быть загружены сразу.
@EntityGraph(attributePaths = "roles")
Optional<UserAccount> findByUsername(String username);
}
Обратите внимание: имя метода — findByUsername. Нам не обязательно в названии писать WithRoles, если мы явно обеспечили загрузку ролей аннотацией. Важно другое: в auth-пути этот метод должен быть основным.
Это и есть тот репозиторный baseline, на который дальше опирается CustomUserDetailsService: один login identifier, один user store, роли приезжают сразу.
CustomUserDetailsService и getUsername()
Теперь самое важное место дня: CustomUserDetailsService. В нём нет магии, но он похож на «переводчика на границе»: он переводит ваш UserAccount в язык Spring Security. И именно тут мы обязаны быть честными: если логин — username, то мы ищем по username и возвращаем username как getUsername().
Ключевой метод (минимально и читаемо):
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@Override
public UserDetails loadUserByUsername(String login) {
// login здесь — это username, потому что мы так решили на уровне проекта.
UserAccount account = repository.findByUsername(login)
// В реальном проекте текст исключения и логирование могут быть более нейтральными.
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + login));
// Преобразуем доменную модель в UserDetails (язык Spring Security).
return toUserDetails(account);
}
А вот место, где «строка входа» превращается в «имя пользователя в security-модели»:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
private UserDetails toUserDetails(UserAccount a) {
return User.withUsername(a.getUsername()) // principal name: именно username
.password(a.getPasswordHash()) // в UserDetails кладём хэш, а не raw-пароль
.disabled(!a.isEnabled()) // флаг "можно ли входить"
.accountLocked(!a.isAccountNonLocked()) // флаг блокировки аккаунта
.authorities(roleNames(a)) // роли/права должны приехать из БД
.build();
}
Заметьте психологический эффект: если вы выбрали username, то withUsername(a.getUsername()) выглядит почти как тавтология. И это опять же хороший знак — значит, система не спорит сама с собой.
5. Вариант с email-логином
Иногда продуктово хочется именно email-логин, и это нормально. Но важно понимать: email-логин — это не только «другая колонка в запросе». Это дисциплина нормализации, это влияние на SecurityContext, это «как будет выглядеть principal» в логах и в коде контроллеров/сервисов, которые берут authentication.getName().
Технически это рабочая альтернатива, но не основной snapshot проекта. Ниже — альтернативный вариант, чтобы было видно: реализовать его несложно, но сделать нужно последовательно. Здесь нам важен только сам auth-path: как строка входа ищется в БД и какое имя попадает в UserDetails.
Репозиторий меняется почти незаметно:
import org.springframework.data.jpa.repository.EntityGraph;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserAccountRepository extends JpaRepository<UserAccount, Long> {
// Смысл меняется: теперь под "login" мы подразумеваем email.
@EntityGraph(attributePaths = "roles")
Optional<UserAccount> findByEmail(String email);
}
CustomUserDetailsService начинает явно нормализовать строку входа и искать по email:
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
@Override
public UserDetails loadUserByUsername(String login) {
// Нормализация обязательна, иначе один и тот же email может "не находиться".
String email = normalizeEmail(login);
UserAccount account = repository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + email));
return toUserDetails(account);
}
И самое важное: UserDetails#getUsername() (то есть withUsername(...)) тоже становится email, иначе вы получите внутреннее противоречие:
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
private UserDetails toUserDetails(UserAccount a) {
return User.withUsername(a.getEmail()) // principal name: теперь это email
.password(a.getPasswordHash())
.authorities(roleNames(a))
.build();
}
В результате authentication.getName() будет возвращать email. Это может быть окей, просто это нужно принять как последствия решения, а не «ой, почему везде email вылез».
Двойной логин: username и email
У новичка тут почти всегда появляется соблазн: «а давайте будем искать и по username, и по email — так всем удобно». На практике это превращает auth в лотерею, потому что вы добавляете неоднозначность. Что если username похож на email? Что если у вас в данных есть старый импорт пользователей, где username = email? Что если разные пользователи потенциально могут пересечься строками? И даже если не пересекутся, вы получите проблему поддержки: никто не сможет быстро ответить на вопрос «по чему мы входим?».
Технически это выглядит как «удобный» метод репозитория:
import java.util.Optional;
public interface UserAccountRepository {
// Это добавляет неоднозначность: система сама перестаёт понимать, что является идентичностью.
Optional<UserAccount> findByUsernameOrEmail(String username, String email);
}
Но на уровне модели это означает: «система не знает, что является идентичностью пользователя». А Spring Security как раз любит, когда идентичность чёткая: есть principal (кто ты), есть credentials (чем доказываешь), есть authorities (что можно). Когда вы размываете вход, вы размываете и principal.
Если вам реально нужно поддержать оба варианта, это делается не “OR в запросе”, а отдельной, очень явной политикой. Например: «если строка содержит @, считаем email, иначе username». Но это уже отдельное проектирование правил, и в fundamentals-курсе оно чаще приносит больше шума, чем пользы.
6. DB-backed auth flow до SecurityContext
После всех решений рабочая цепочка проекта выглядит так: UserAccountRepository (username + roles) → CustomUserDetailsService → UserDetails → DaoAuthenticationProvider + PasswordEncoder → Authentication → SecurityContext. В этот момент БД уже становится главным user store приложения, а старые in-memory пользователи могут остаться только как специально оговорённый тестовый инструмент, но не как параллельная реальность.
Когда у вас уже есть сервис, маппинг и провайдер, очень хочется сказать: «ну всё, оно где-то там внутри Spring Security само». Но сейчас важный момент: именно здесь у вас появляется шанс перестать воспринимать вход как магию. Полный auth flow — это цепочка вполне конкретных шагов: фильтр принимает запрос, менеджер делегирует провайдеру, провайдер грузит пользователя, проверяет пароль и флаги аккаунта, после чего кладёт результат в SecurityContext. А выбранный идентификатор входа — это просто строка, которая проходит через весь этот путь.
Вот схема (упрощённо, но по делу) в формате sequence diagram:
sequenceDiagram
participant C as Client
participant F as Auth Filter
participant M as AuthenticationManager
participant P as DaoAuthenticationProvider
participant U as CustomUserDetailsService
participant R as UserAccountRepository
participant E as PasswordEncoder
participant S as SecurityContext
C->>F: "credentials (login + password)"
F->>M: "authenticate(token)"
M->>P: "authenticate(token)"
P->>U: "loadUserByUsername(login)"
U->>R: "findBy...(login)"
R-->>U: "UserAccount (+roles)"
U-->>P: "UserDetails"
P->>E: "matches(raw, hash)"
E-->>P: "true/false"
P-->>M: "authenticated token (authorities)"
M-->>F: "Authentication"
F->>S: "setAuthentication(...)"
Обратите внимание на простую вещь: login-строка попадает в loadUserByUsername(login). То есть выбор username или email — это выбор того, какой смысл несёт параметр login и что вы в итоге пишете в UserDetails.withUsername(...).
А теперь маленький прикладной кусочек, чтобы увидеть последствия выбора без дебага фильтров. В нашем проекте есть endpoint /api/me (личная зона). Можно на время разработки сделать его максимально простым: пусть он возвращает то, что Spring Security считает вашим именем пользователя прямо сейчас.
import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class MeController {
@GetMapping("/api/me")
public String me(Authentication authentication) {
// getName() — это то самое "имя" principal, которое вы положили через withUsername(...)
return "You are: " + authentication.getName(); // You are: alice
}
}
Если вы входите по username, вы увидите alice. Если по email, увидите alice@mail.com. И это не «мелочь отображения»: это ваш principal name, который будет всплывать в логах, в диагностике и вообще во всех местах, где приложение спрашивает «кто делает запрос?».
7. Типичные ошибки при выборе login-идентификатора
Ошибка №1: проект “решил входить по email”, но ищет по username (или наоборот).
Это классика: в БД у пользователя есть email, вы вводите email на логине, но в CustomUserDetailsService всё ещё стоит findByUsername(login). В результате вы получаете UsernameNotFoundException, хотя пользователь «точно существует». Лечится не магией, а тем самым правилом консистентности: репозиторий, сервис и withUsername(...) должны говорить об одном и том же.
Ошибка №2: loadUserByUsername ищет по одному полю, а UserDetails#getUsername() возвращает другое.
Например, вы ищете по email (потому что так удобнее), но в toUserDetails() делаете withUsername(account.getUsername()). Внутри оно, возможно, даже “залогинится”, но дальше вы получите удивительные эффекты: authentication.getName() не совпадает со строкой входа, в логах одно, в базе другое, и вы сами не сможете объяснить, что является “именем пользователя” в системе.
Ошибка №3: email-логин без нормализации.
Если вы не делаете trim и lowercase, пользователь, который ввёл Alice@Mail.com , становится «другим человеком» с точки зрения поиска в БД. Иногда это проявляется только у одного человека из ста — и именно поэтому отлавливать это неприятно. Нормализация — не украшение, а часть контракта входа.
Ошибка №4: попытка “поддержать оба” через OR в запросе без правил.
Пока пользователей мало, это кажется удобным. Потом вы ловите неоднозначность и начинаете бояться собственных данных. Spring Security любит чёткую идентичность principal; OR без политики делает идентичность плавающей. Если уж поддерживать оба, то только по явной, документированной логике, а не «пусть база решит».
Ошибка №5: роли не загружены в auth-сценарии, и пользователь аутентифицируется “без прав”.
Выглядит особенно обманчиво: пароль верный, вход успешный, но доступы не работают. Причина — в UserDetails нет authorities, потому что roles не приехали из БД. Лечится тем, что auth-репозиторный метод обязан возвращать пользователя сразу с ролями (через @EntityGraph или аналогичный подход), а пустые роли должны быть либо запрещены, либо обработаны как отдельный осознанный сценарий.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ