JavaRush /Курси /Spring Security /username або email: ідентифікатор входу

username або email: ідентифікатор входу

Spring Security
Рівень 15 , Лекція 4
Відкрита

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) → CustomUserDetailsServiceUserDetailsDaoAuthenticationProvider + PasswordEncoderAuthenticationSecurityContext. У цей момент БД уже стає головним сховищем користувачів застосунку, а старі 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 або аналогічний підхід), а порожні ролі мають бути або заборонені, або оброблені як окремий усвідомлений сценарій.

1
Опитування
Аутентифікація на основі БД, рівень 15, лекція 4
Недоступний
Аутентифікація на основі БД
Користувачі, ролі, авторизація
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ