1. Ідентифікатор входу: одне правило
До цього моменту в нас уже зібрано майже всі деталі DB-backed auth: UserAccount із БД, CustomUserDetailsService, мапінг у UserDetails, DaoAuthenticationProvider і розуміння того, що roles мають приїхати в auth-шлях вчасно. Залишилася остання змінна, через яку ланцюжок усе ще може розповзтися: який рядок вважати ідентифікатором входу і яке імʼя користувача потім опиниться в 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 IDEA». А нам потрібно інженерно: що буде простіше підтримувати, що стабільніше, які помилки ми зловимо за місяць, коли забудемо, що тут узагалі відбувалося. У бекенді ідентифікатор входу — це не лише 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() і в будь-яких повʼязаних місцях у вас почне фігурувати новий рядок. Іноді це нормально, іноді — несподівано дратує.
У навчальному проєкті нам особливо важливо, щоб модель була передбачуваною і не вимагала десяти пояснень на тему «чому вчора в логах був 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 умов і трьох гілок «а раптом це email». Нам потрібно рівно одне: отримати рядок логіна, знайти акаунт, зібрати UserDetails. Усе інше — перевірка пароля, перевірка enabled/locked, побудова Authentication — уже робить Spring Security через DaoAuthenticationProvider.
Нижче — опорні шматки коду. Не як «вставте і моліться», а як орієнтир: де саме в проєкті живе рішення «ми логінимося по username».
Репозиторій для auth і ролі
Для основної гілки проєкту фіксуємо один auth-репозиторій: пошук по username з явним завантаженням ролей. fetch join, @Transactional та інші техніки корисні рівно настільки, наскільки вони приводять до тієї самої гарантії. Але сам знімок проєкту тримаємо простим і однозначним: до моменту 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: один ідентифікатор входу, одне сховище користувачів, ролі приїжджають одразу.
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("Користувача не знайдено: " + 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().
Технічно це робоча альтернатива, але не основний знімок проєкту. Нижче — альтернативний варіант, щоб було видно: реалізувати його нескладно, але зробити потрібно послідовно. Тут нам важливий лише сам auth-шлях: як рядок входу шукається в БД і яке імʼя потрапляє в 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("Користувача не знайдено: " + 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». Але це вже окреме проєктування правил, і у курсі для новачків воно частіше приносить більше шуму, ніж користі.
6. DB-backed auth flow до SecurityContext
Після всіх рішень робочий ланцюжок проєкту виглядає так: UserAccountRepository (username + roles) → CustomUserDetailsService → UserDetails → DaoAuthenticationProvider + PasswordEncoder → Authentication → SecurityContext. У цей момент БД уже стає головним сховищем користувачів застосунку, а старі in-memory користувачі можуть лишитися тільки як спеціально обумовлений тестовий інструмент, але не як паралельна реальність.
Коли у вас уже є сервіс, мапінг і провайдер, дуже хочеться сказати: «ну все, воно десь там усередині Spring Security саме». Але зараз важливий момент: саме тут у вас зʼявляється шанс перестати сприймати вхід як магію. Повний auth-flow — це ланцюжок цілком конкретних кроків: фільтр приймає запит, менеджер делегує провайдеру, провайдер завантажує користувача, перевіряє пароль і прапорці акаунта, після чого кладе результат у SecurityContext. А обраний ідентифікатор входу — це просто рядок, який проходить через увесь цей шлях.
Ось схема — спрощено, але по суті — у форматі sequence diagram:
sequenceDiagram
participant C as Клієнт
participant F as Фільтр автентифікації
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: "облікові дані (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 "Ви: " + authentication.getName(); // Ви: 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 або аналогічний підхід), а порожні ролі мають бути або заборонені, або оброблені як окремий усвідомлений сценарій.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ