JavaRush /Курси /Spring Security /Успішний вхід, помилка й вихід

Успішний вхід, помилка й вихід

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

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 anonymousauthenticated перенаправлення в застосунок defaultSuccessUrl(...)
Помилка входу неправильні облікові дані залишається anonymous перенаправлення на сторінку входу з ознакою помилки failureUrl(...)
Вихід натиснув кнопку logout authenticatedanonymous перенаправлення на сторінку «ви вийшли» logoutUrl(...), logoutSuccessUrl(...)

Ключова думка: усі ці переходи відбуваються в шарі безпеки, а не у ваших контролерах. Тобто ви не пишете @PostMapping("/login") і не робите if(password.equals(...)). За перевірку відповідає вже знайома зв’язка AuthenticationManagerAuthenticationProviderUserDetailsService + 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 замість того, щоб використовувати його.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ