1. formLogin: відсутні частини
Коли ви вперше вмикаєте formLogin, виникає приємне відчуття: «О, зʼявилася сторінка входу, отже безпека вже налаштована». Це трохи схоже на те, ніби ви поставили вхідні двері й одразу оголосили квартиру неприступною фортецею. Двері, звісно, важливі. Але якщо в них немає нормального замка, ручки й розуміння, що робити, коли «ключ не підходить», життя швидко перетворюється на серіал: кожен епізод — нова дивна ситуація на порозі.
Сторінка входу та POST /login — це лише вхідна частина. Не менш важливо зрозуміти, як система розрізняє успіх, помилку та офіційний вихід.
У реальному застосунку сценарій входу — це завжди набір сценаріїв, а не один «щасливий» шлях. Користувач може ввести правильний пароль, може помилитися, може натиснути «назад», може спробувати ввійти в зону, яка йому не належить. І окремо — він має вміти вийти із системи так, щоб сервер перестав вважати його автентифікованим. Тому сьогодні ми фіксуємо три базові події: login success, login failure і logout.
2. Модель станів login/logout
Якщо дивитися на formLogin як на «сторінку», легко втратити головне: Spring Security оперує не HTML-формою, а станом безпеки запиту. До входу запит іде як anonymous. Після успішного входу запити йдуть як authenticated (із конкретним користувачем). Помилка входу — це не «виняток у контролері», а просто гілка, у якій стан не змінюється: користувач залишається anonymous. Logout — це зворотний перехід, коли ми повертаємося в anonymous-стан.
Корисно уявити це як маленький автомат станів (не хвилюйтеся, це не «теорія автоматів», а просто картинка «куди ми переходимо»):
stateDiagram-v2
[*] --> Anonymous
Anonymous --> Authenticated: успішний вхід
Anonymous --> Anonymous: помилка входу
Authenticated --> Anonymous: вихід
Тепер додамо «людську» таблицю, щоб не плутатися, що ми налаштовуємо і що спостерігаємо:
| Подія | Що ввів користувач | Що стало зі станом | Що зазвичай бачить браузер | Чим керуємо в конфігурації |
|---|---|---|---|---|
| Успішний вхід | коректні username/password | anonymous → authenticated | перенаправлення в застосунок | defaultSuccessUrl(...) |
| Помилка входу | неправильні облікові дані | залишається anonymous | перенаправлення на сторінку входу з ознакою помилки | failureUrl(...) |
| Вихід | натиснув кнопку logout | authenticated → anonymous | перенаправлення на сторінку «ви вийшли» | logoutUrl(...), logoutSuccessUrl(...) |
Ключова думка: усі ці переходи відбуваються в шарі безпеки, а не у ваших контролерах. Тобто ви не пишете @PostMapping("/login") і не робите if(password.equals(...)). За перевірку відповідає вже знайома зв’язка AuthenticationManager → AuthenticationProvider → UserDetailsService + PasswordEncoder. А ми тут керуємо тим, як користувачеві пояснити результат: куди його відправити в разі успіху або провалу та як «офіційно» виконати вихід.
3. Успішний вхід і defaultSuccessUrl(...)
Коли вхід успішний, у новачка часто виникає таке очікування: «Ну все, я увійшов, тепер я в застосунку». Але в браузера є важливе UX-питання: у якій саме точці застосунку має опинитися користувач після логіну? Найпростіший варіант — завжди вести в одну точку, наприклад в «особистий кабінет». Саме для цього і існує defaultSuccessUrl.
У Spring Security це налаштовується в formLogin(...). Приклад — показати після успішного входу зону поточного користувача /api/me:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
// Налаштовуємо ланцюжок фільтрів Spring Security
http.formLogin(form -> form
// Куди перенаправляти користувача після успішного входу
.defaultSuccessUrl("/api/me")
);
return http.build();
}
Тут важливо зрозуміти одну тонкість, через яку в багатьох виникає «дивна поведінка»: Spring Security часто намагається бути розумнішим за нас. Якщо користувач спочатку спробував зайти на захищений URL (наприклад, відкрив /api/me напряму), то шар безпеки зазвичай запам’ятовує, куди саме він хотів потрапити, відправляє його на логін, а після успішного входу повертає туди, куди він ішов. Це логічно: ви не хочете після логіну опинитися у випадковому місці, ви хочете потрапити туди, що намагалися відкрити.
І ось тут з’являється другий варіант defaultSuccessUrl із прапорцем alwaysUse:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.formLogin(form -> form
// alwaysUse = true: після логіну ЗАВЖДИ ведемо в одну точку,
// навіть якщо користувач до цього намагався відкрити інший URL
.defaultSuccessUrl("/api/me", true)
);
return http.build();
}
Прапорець true означає: «після успішного входу завжди веди на /api/me, навіть якщо користувач намагався відкрити щось інше».
Чому це корисно в навчальному проєкті? Бо так простіше спостерігати результат: увійшли — і стабільно потрапили в одну й ту саму точку, де можна переконатися, що вхід справді відбувся. Це зменшує «шум» і кількість загадок.
Але є й зворотний бік, який важливо побачити вже зараз. Уявіть, що користувач намагався відкрити /api/admin/users, його відправили на логін, він увійшов як звичайний USER, а ви не примусили alwaysUse = true. Тоді після входу він потрапить назад на /api/admin/users і… отримає відмову в доступі. Це чудовий демонстраційний момент: успішна автентифікація не означає, що доступ дозволено. Вхід відповідає на запитання «хто ви», а правило доступу — на «що вам можна».
4. Помилка входу і failureUrl
Невдалий логін — це не «неприємний крайовий випадок», а абсолютно штатна ситуація. Люди помиляються. Люди забувають пароль. Люди друкують із увімкненим Caps Lock (а потім клянуться, що «точно вводили правильно»). І в security-моделі важливо, щоб застосунок реагував передбачувано: не падав із 500, не показував дивний HTML, не розкривав зайвих деталей і, головне, не «впускав помилково».
За замовчуванням, коли логін не вдався, Spring Security зазвичай повертає користувача на сторінку входу з параметром ?error. Ми можемо зробити цю поведінку явною та зрозумілою через failureUrl(...):
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.formLogin(form -> form
// Куди перенаправляти користувача, якщо автентифікація не пройшла
.failureUrl("/login?error")
);
return http.build();
}
Якщо ви використовуєте вбудовану login page Spring Security, то параметр error для неї — зрозумілий сигнал: «покажи користувачеві повідомлення про те, що вхід не вдався». Тобто ваш сценарій стає спостережуваним: ввели неправильний пароль — повернулися на /login?error, і ви бачите, що це саме «помилка логіну», а не «контролер зламався».
Чому я так наполягаю на спостережуваності? Бо без неї новачок починає діагностувати проблеми методом «шаманський бубон + Stack Overflow». А нам потрібен інженерний підхід: бачимо, де ми перебуваємо (який URL), бачимо, що сталося (успіх чи помилка), розуміємо, яке правило цим керує.
Є ще один момент, який корисно тримати в голові: помилка логіну не має повідомляти зайвих деталей. Навіть у навчальному проєкті краще звикати до нейтрального формулювання «Невірний логін або пароль», а не «користувача не знайдено» чи «пароль неправильний». Це вже не про красу тексту — це про те, щоб не давати зловмиснику зручний спосіб вгадувати облікові записи.
І останнє: failureUrl — це про «куди відправити». Воно не змінює саму механіку перевірки пароля. Неважливо, наскільки красивий у вас failureUrl: якщо пароль перевіряється через PasswordEncoder і UserDetailsService, то помилковий логін буде помилковим. Ми не «покращуємо» безпеку посиланням, ми просто робимо поведінку застосунку зрозумілою людині.
5. Logout: logoutUrl і logoutSuccessUrl
Logout часто сприймають як косметику: «ну, щоб була кнопка вийти». Насправді logout — це офіційна точка, де система каже: «я більше не вважаю цього користувача таким, що увійшов». Тобто це такий самий важливий елемент моделі, як вхід. Якщо вхід — це «прикріпили перепустку на руку», то logout — це «зняли перепустку й викинули». Без другого пункту охорона й далі віритиме, що ви свій.
У Spring Security logout налаштовується окремим блоком logout(...). Мінімальне налаштування, яке корисно зробити явним, — куди вести користувача після виходу:
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.logout(logout -> logout
// Куди перенаправляти користувача після logout
.logoutSuccessUrl("/login?logout")
);
return http.build();
}
Тут важливо не ставитися до logout як до «ще одного посилання в меню». Це дія, що змінює стан: сервер змінює security-стан користувача, а не просто віддає нову сторінку. У гілці браузера й сесії це буквально момент, коли застосунок перестає вважати поточний браузер тим самим користувачем, що увійшов.
Чому /login?logout зручно? Бо це той самий принцип спостережуваності, що й для ?error. Якщо ви використовуєте вбудовану login page, вона розуміє параметр logout і показує повідомлення про те, що ви вийшли. Користувач не лишається в дивному стані «а що сталося?» — він бачить зрозумілий фінал дії.
Якщо ви хочете зробити URL виходу більш виразним, можна керувати logoutUrl(...):
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.logout(logout -> logout
// URL, на який надсилається запит на вихід
.logoutUrl("/logout")
);
return http.build();
}
Технічно це вже й так дефолт, але іноді корисно прописувати явно, щоб у конфігу читалася повна «карта» входу й виходу.
І важлива практична зв’язка (без занурення у внутрішні механізми): після logout ви знову стаєте anonymous. Тобто якщо ви оновите сторінку /api/me, застосунок знову поводитиметься так, ніби ви не входили. У цьому місці у студентів часто виникає здивування: «Я ж щойно був автентифікований, чому тепер мене знову просять увійти?» — і саме це здивування означає, що logout спрацював.
Щоб картинка була повністю цілісною, ось приклад «міні-склейки» (у межах наших зон) — доступ + formLogin + logout. Так, конфігурація трохи довша, але вона читається як історія: публічне відкрито, особисте потребує входу, логін і логаут позначені явно.
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
// Публічна зона доступна всім, навіть anonymous
.requestMatchers("/api/public/**").permitAll()
// Особиста зона доступна лише автентифікованим
.requestMatchers("/api/me/**").authenticated()
// Решту закриваємо повністю, щоб не було "випадково відкритого"
.anyRequest().denyAll()
)
.formLogin(form -> form
// Після успішного логіну завжди ведемо в особисту зону
.defaultSuccessUrl("/api/me", true)
// Після невдалого логіну повертаємо на сторінку входу з ознакою помилки
.failureUrl("/login?error")
// Важливо: login page має бути доступна anonymous, інакше буде редирект-петля
.permitAll()
)
.logout(logout -> logout
// Після виходу повертаємо на сторінку входу з ознакою "вийшли"
.logoutSuccessUrl("/login?logout")
// Важливо: endpoint logout теж має бути доступний, інакше "вийти" не вдасться
.permitAll()
)
.build();
}
Зверніть увагу на одну річ: ми не «відкриваємо все». Ми відкриваємо лише те, що потрібно для входу й виходу (permitAll() на formLogin і logout), і залишаємо решту під нашими правилами. Це дуже здорова звичка: робити вхід досяжним, але не розмивати межі доступу.
6. Типові помилки в login/logout
Помилка №1: плутати «увійшов» і «отримав доступ».
Іноді студент радіє: «Логін успішний!», а потім дивується, що /api/admin/** усе одно не відкривається. Це не баг, це правильна модель. Успішний логін означає, що система визнала вашу особу. А доступ до редакторської або адмінської зони визначають правила hasRole("EDITOR") і hasRole("ADMIN"). Коли ви тримаєте в голові розділення authentication/authorization, такі ситуації перестають здаватися містикою.
Помилка №2: ставити defaultSuccessUrl(...) на endpoint, який підходить не всім ролям.
Часта пастка — вказати defaultSuccessUrl("/api/admin/users", true) і потім логінитися звичайному користувачеві. Логін буде успішним, але одразу після нього ви потрапите в зону, де доступу немає, і отримаєте відмову. У навчальному проєкті краще вести за замовчуванням у нейтральну «особисту» точку на кшталт /api/me, яка доступна будь-якому автентифікованому користувачеві.
Помилка №3: увімкнути defaultSuccessUrl(..., true) і не розуміти, чому «не повертає туди, куди я йшов».
Прапорець alwaysUse = true вимикає «розумну» поведінку повернення до початково запитаного URL. У навчанні це іноді зручно, але якщо ви очікуєте, що після логіну вас поверне на сторінку, яку ви намагалися відкрити, то ви самі цю поведінку й вимкнули. Важливо розуміти зміст прапорця, а не ставитися до нього як до «магічної галочки».
Помилка №4: забути про permitAll() поруч із логіном або логаутом і отримати редирект-петлю.
Якщо сторінка входу або endpoint виходу раптом потребує автентифікації, відбувається сумна комедія: щоб увійти — треба увійти. Браузер починає бігати по колу між захищеною зоною та логіном. Коли ви бачите редирект-петлю, майже завжди варто насамперед перевірити, що точки login/logout справді доступні для anonymous.
Помилка №5: намагатися «обробити помилку логіну» всередині контролера.
Іноді хочеться зробити POST /login контролером і повернути красивий JSON або сторінку. Але в гілці formLogin логін — це частина filter chain. Помилка логіну — це не бізнес-виняток із сервісу, а результат рішення AuthenticationManager. Якщо ви намагаєтеся «перехопити» це на рівні контролера, ви або не потрапите туди взагалі, або отримаєте дивні ефекти й почнете боротися зі Spring Security замість того, щоб використовувати його.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ