httpBasic ( ... ) та BasicAuthenticationFilter

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

1. Шлях HTTP Basic у Spring Security

Формат Authorization: Basic ... уже зрозумілий: облікові дані надходять із кожним запитом, а 401 дає зрозуміти, що сервер не має чим аутентифікувати вас. Але поки що це лише рядок. Тепер лишилося закрити головне інженерне питання: як цей заголовок узагалі перетворюється на «поточного користувача» всередині Spring Security?

Уявіть ситуацію з нашого проєкту Secure Content Platform API. У нас є захищена кінцева точка /api/me, яка має повертати поточного користувача. Ми вже знаємо, що «поточний користувач» у Spring з’являється в SecurityContext, і знаємо, що перевіркою логіна й пароля займається не контролер, а стандартний конвеєр username/password (AuthenticationManagerAuthenticationProviderUserDetailsService + PasswordEncoder). Питання дня: який саме елемент ланцюжка витягує логін і пароль із заголовка Authorization та запускає цей конвеєр?

Відповідь — BasicAuthenticationFilter. І щоб усе «сіло в голову», нам потрібно пройти весь маршрут запиту від заголовка до SecurityContext рівно так, як це робить Spring Security.

2. Увімкнення httpBasic(...) у SecurityFilterChain

Дуже хочеться думати, що HTTP Basic «магічно увімкнено десь усередині Spring», але наш курс якраз про те, щоб магію перетворювати на зрозумілі перемикачі. Тому ми вмикаємо Basic так само явно, як вмикали formLogin, і так само явно, як пишемо authorizeHttpRequests. Це важливо: коли ви бачите конфігурацію через місяць, ви маєте з першого погляду зрозуміти, які механізми аутентифікації взагалі доступні.

У нашому проєкті конфігурація зазвичай живе в пакеті com.example.securecontent.security.config. Припустімо, у нас уже є правила доступу за зонами: публічні ендпоінти відкриті, /api/editor/** вимагає роль EDITOR, /api/admin/** — роль ADMIN, а все інше вимагає принаймні аутентифікації. Тоді увімкнення HTTP Basic виглядає так:

package com.example.securecontent.security.config;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
public class SecurityConfig {

    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            // 1) Спочатку описуємо правила доступу: куди можна, а куди не можна
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/public/**").permitAll() // публічна зона
                .requestMatchers("/api/editor/**").hasRole("EDITOR") // зона редактора
                .requestMatchers("/api/admin/**").hasRole("ADMIN") // зона адміністратора
                .anyRequest().authenticated() // все інше — лише для тих, хто увійшов
            )
            // 2) Далі вмикаємо спосіб аутентифікації: як саме входити
            .httpBasic(Customizer.withDefaults()); // вмикає BasicAuthenticationFilter і виклик 401/WWW-Authenticate

        return http.build(); // збираємо ланцюжок фільтрів
    }
}

Зверніть увагу на думку, яку ми тут акцентуємо: httpBasic(...) не замінює authorizeHttpRequests. Він відповідає лише на питання «як аутентифікуватися?», а не «куди впускати?». Дозволи (permitAll, authenticated, hasRole) залишаються вашою головною мовою «що можна».

Якщо ви забудете додати .httpBasic(...), то й надалі можете мати ідеально написані правила запитів — але Basic-клієнт не отримає очікуваної поведінки. Це дуже типова помилка: «Я ж надіслав заголовок, чому не працює?». Тому що ви не увімкнули механізм, який цей заголовок читає.

3. Роль BasicAuthenticationFilter у ланцюжку

Зараз буде важливий момент: BasicAuthenticationFilter — це не «якийсь окремий модуль» і не «контролер усередині Spring». Це фільтр у межах вашої SecurityFilterChain, тобто він працює до потрапляння запиту в DispatcherServlet і ваші контролери. І це пояснює 90 % ситуацій на кшталт «чому мій метод у контролері навіть не викликався».

У спрощеному вигляді BasicAuthenticationFilter робить таку логіку: він дивиться на вхідний HTTP-запит, шукає заголовок Authorization. Якщо заголовка немає або в ньому не схема Basic, фільтр зазвичай просто пропускає запит далі: аутентифікуватися немає чим. Якщо заголовок є і він схожий на Basic, фільтр намагається витягнути з нього username і password, створити об’єкт UsernamePasswordAuthenticationToken і передати його в AuthenticationManager. Якщо перевірка успішна, фільтр кладе результат у SecurityContext, і далі застосунок уже вважає запит таким, що надійшов від конкретного користувача.

Щоб було легше пов’язати це з тим, що ви вже проходили на Дні 3 і Дні 4, ось схема (спрощена, але чесна):

flowchart TD
    A["HTTP-запит"] --> B["SecurityFilterChain"]
    B --> C["BasicAuthenticationFilter"]
    C --> D{"Є Authorization: Basic ... ?"}
    D -- "ні" --> E["Запит іде далі як anonymous"]
    E --> F["Правила авторизації: authenticated? hasRole?"]
    F --> G["Якщо потрібен вхід: 401 + WWW-Authenticate"]

    D -- "так" --> H["Декодуємо Base64 -> username:password"]
    H --> I["UsernamePasswordAuthenticationToken"]
    I --> J["AuthenticationManager.authenticate(...)"]
    J --> K["DaoAuthenticationProvider"]
    K --> L["UserDetailsService + PasswordEncoder"]
    L --> M["Authentication (authenticated=true)"]
    M --> N["SecurityContext"]
    N --> O["Контролер отримує Principal/Authentication"]

Ця діаграма важлива тим, що показує дві різні гілки: гілку, де облікових даних немає, і гілку, де вони є. І в обох випадках правила запитів (authenticated(), hasRole(...)) залишаються тим самим «охоронцем біля дверей», який вирішує, що робити далі.

4. Розбір Authorization у UsernamePasswordAuthenticationToken

Після другої лекції у вас уже має бути відчуття, що Basic-заголовок — це просто рядок. Тепер ми робимо наступний крок: пояснюємо, як Spring перетворює цей рядок на об’єктну модель, з якою вміє працювати механізм безпеки. У Spring Security все обертається навколо об’єкта Authentication, і Basic — не виняток: він просто створює Authentication із заголовка.

На вході у нас HTTP-запит із заголовком виду:

Authorization: Basic ZWRpdG9yOnNlY3JldA==

Далі фільтр робить дві послідовні речі. Спочатку він перевіряє, що схема дійсно Basic. Потім він декодує Base64, отримує початковий рядок username:password і ділить його на дві частини за двокрапкою. Після цього створюється UsernamePasswordAuthenticationToken, який є стандартним контейнером для аутентифікації за username/password.

Ви вже зустрічали цей клас в поясненні DaoAuthenticationProvider. Хороша новина: в HTTP Basic немає «іншого світу перевірки». Це все той самий конвеєр, просто джерело username/password — не HTML-форма, а заголовок.

Щоб вам було простіше побачити це вживу, можна подивитися на мінімальний шматок коду «як би зробив фільтр» (це не те, що ви вставляєте в проєкт; це навчальна ілюстрація):

import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;

// Те, що фільтр зрештою хоче отримати з заголовка: username і сирий пароль
String username = "editor";
String password = "secret";

// На цьому етапі токен ще НЕ підтверджений (credentials просто перенесли в об’єкт)
UsernamePasswordAuthenticationToken authRequest =
        UsernamePasswordAuthenticationToken.unauthenticated(username, password);

Тут ключове слово — unauthenticated(...). На цьому етапі в токена є principal (username) і credentials (password), але він ще не підтверджений. Це важлива логіка Spring Security: «поки не перевірили — нікому не віримо».

Далі цей об’єкт іде в AuthenticationManager, і вже там вирішується, чи стане він authenticated=true і які в нього будуть roles/authorities.

5. AuthenticationManager та DaoAuthenticationProvider

Зараз ми зберемо в одну картину два блоки курсу: архітектуру аутентифікації (рівень 4) і HTTP Basic (рівень 10). Дуже часто новачки думають, що form login — один механізм, basic — інший механізм, отже й перевірка пароля десь інша. Насправді різниться лише те, як ми отримали логін і пароль, а далі все йде тією самою дорогою.

BasicAuthenticationFilter бере UsernamePasswordAuthenticationToken і робить приблизно такий виклик:

import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.core.Authentication;

// У реальності AuthenticationManager буде впроваджений/сконфігурований Springʼом.
// Тут null — лише щоб підкреслити: вручну його зазвичай НЕ створюють.
AuthenticationManager authenticationManager = null;

 // Передаємо запит на аутентифікацію "в двигун" Spring Security
Authentication result = authenticationManager.authenticate(authRequest);

// Якщо все гаразд — result буде authenticated=true і з заповненими authorities

У реальному застосунку ви не створюєте AuthenticationManager вручну. Spring збирає його з набору AuthenticationProviderів, і типова реалізація — ProviderManager. Головний для нас provider на цьому етапі — DaoAuthenticationProvider, тому що він уміє перевіряти користувача через UserDetailsService і пароль через PasswordEncoder.

Схема перевірки виглядає так: provider запитує користувача за username (наприклад, з InMemoryUserDetailsManager), отримує UserDetails із passwordHash (закодованим паролем), потім через PasswordEncoder.matches(...) порівнює пароль із заголовка з тим, що зберігається в системі. Якщо збіглося, він збирає новий Authentication, де вже лежать authorities (наприклад, ROLE_EDITOR) і прапорець «аутентифіковано».

Це «та сама механіка», яку ви проходили, коли розбирали DaoAuthenticationProvider, і це дуже хороша новина: ваш мозок не має запускати два різні «двигуни» в голові. Один двигун, два різні входи: форма або заголовок.

Щоб пов’язати це з нашим проєктом, нагадаю: in-memory користувачів ми створювали так, щоб пароль був закодований, а не сирий. І це правило нікуди не зникає, коли ви вмикаєте Basic. Приклад:

import org.springframework.context.annotation.Bean;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.provisioning.InMemoryUserDetailsManager;
import org.springframework.security.core.userdetails.UserDetailsService;

@Bean
UserDetailsService userDetailsService(PasswordEncoder encoder) {
    // Пароль зберігаємо в закодованому вигляді, навіть якщо це in-memory і "навчальний" приклад
    UserDetails editor = User.withUsername("editor")
            .password(encoder.encode("secret")) // raw password -> encoded password
            .roles("EDITOR") // Spring сам перетворить на ROLE_EDITOR
            .build();

    // InMemoryUserDetailsManager передасть цього користувача DaoAuthenticationProviderʼу
    return new InMemoryUserDetailsManager(editor);
}

HTTP Basic не дає підстав для підходу «ну це ж просто header, давайте покладемо пароль прямо рядком». Ні. Пароль зберігається в закодованому вигляді, а сирий пароль існує лише мить — у момент перевірки.

6. SecurityContext: що бачить контролер

Коли аутентифікація пройшла успішно, Spring Security має зробити головну річ: позначити поточний запит аутентифікованим. Це відбувається через SecurityContext. Простими словами, SecurityContext — це «сховище» в потоці обробки, де зберігається інформація про те, хто зараз виконує запит.

І ось тут відбувається дуже приємний момент: ваш контролер узагалі не зобов’язаний знати, яким саме способом користувач аутентифікувався. Це може бути formLogin, це може бути httpBasic, а сам контролер усе одно працює вже з готовою інформацією про користувача, тому що фільтри до нього зробили свою роботу.

У нашому проєкті є кінцева точка /api/me. Давайте подивимося на простий варіант, який показує ім’я користувача:

package com.example.securecontent.me;

import java.security.Principal;

import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class MeController {

    @GetMapping("/api/me")
    String me(Principal principal) {
        // Principal заповниться лише якщо запит пройшов аутентифікацію
        return principal.getName(); // username з SecurityContext
    }
}

Якщо запит прийшов із коректним Basic-заголовком і в користувача є право на цю кінцеву точку (за нашими правилами — просто «бути аутентифікованим»), то principal буде не null, і principal.getName() поверне username.

Якщо хочеться побачити більше деталей саме для навчання, іноді зручно прийняти Authentication і подивитися authorities:

package com.example.securecontent.me;

import org.springframework.security.core.Authentication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
class MeDebugController {

    @GetMapping("/api/me/debug-auth")
    String debugAuth(Authentication authentication) {
        // Authentication — більш "детальна" версія поточного користувача
        return authentication.getName() + " " + authentication.getAuthorities();
    }
}

Це проста «навчальна лупа»: ви наочно побачите, що Spring Security реально поклав у контекст. І так, якщо все налаштовано правильно, authentication.getAuthorities() покаже щось на кшталт [ROLE_EDITOR] (точний формат залежить від реалізації та toString()).

Головна думка розділу: за успішної аутентифікації Basic-фільтр не «повертає відповідь сам». Він просто заповнює SecurityContext, а далі все працює як звичайно: правила доступу перевіряються, контролер викликається, сервіс виконує роботу.

7. Невдала аутентифікація: 401 і WWW-Authenticate

Сам 401 тут не дивина. Цікавіше інше: всередині Spring Security він може народитися двома різними шляхами. Ззовні обидва зазвичай закінчуються однаково — 401 Unauthorized і WWW-Authenticate — але ланцюжок до цього рішення запит проходить по-різному.

У першому випадку ви прийшли взагалі без заголовка Authorization. Тоді BasicAuthenticationFilter просто не зміг нічого аутентифікувати, запит пішов далі як anonymous, а вже на перевірці authenticated() або hasRole(...) з’ясувалося, що вхід усе-таки потрібен.

У другому випадку заголовок є, але облікові дані неправильні. Тоді BasicAuthenticationFilter встигає створити UsernamePasswordAuthenticationToken, передає його в AuthenticationManager, і проблема виникає вже на етапі перевірки користувача або пароля. Для клієнта зовні це все одно 401, але для налагодження різниця принципова: в одному випадку облікові дані взагалі не надійшли, в іншому — надійшли, але не пройшли конвеєр аутентифікації.

Щоб побачити це вручну, достатньо двох запитів із curl. Перший — без заголовка:

curl -i http://localhost:8080/api/me

Зазвичай ви побачите HTTP/1.1 401 і WWW-Authenticate. Другий — з неправильним паролем:

curl -i -u editor:WRONG http://localhost:8080/api/me

І знову 401, але вже з розумінням: облікові дані були, та перевірку вони не пройшли.

І є третій запит — щасливий:

curl -i -u editor:secret http://localhost:8080/api/me

І ось тут ви побачите 200 OK і тіло відповіді з username.

Зверніть увагу на одну важливу річ: контролер у невдалих сценаріях може взагалі не викликатися. І це нормально. Security-рішення приймається на рівні фільтрів, тобто раніше. Тому «поставлю брейкпоінт у контролері» не завжди допомагає: іноді запит не доходить до MVC.

8. Debug-логи: як побачити Basic-потік

Дуже легко прочитати схему, кивнути та забути. Тому корисно зробити невелику перевірку: «А я справді бачу, що відбувається?». Ми вже вчилися читати debug-логи ланцюжка фільтрів, і HTTP Basic — чудовий привід повторити цю навичку без зайвих нових тем.

Найпростіший варіант — тимчасово увімкнути налагоджувальне логування Spring Security в application.yml:

logging:
  level:
    # Увімкнути докладні логи щодо фільтрів і аутентифікації
    org.springframework.security: DEBUG

Далі ви робите один запит без заголовка і один запит із заголовком, і в логах побачите, як Spring вибирає фільтри та як змінюється «стан аутентифікації».

Сенс вправи не в тому, щоб ви напам’ять запам’ятали назви всіх фільтрів (їх багато, і це нормально). Сенс у тому, щоб ви навчилися відповідати на три практичні питання: чи побачив фільтр заголовок, чи намагався він аутентифікувати, і чим закінчилася спроба. Це рівно ті питання, які ви ставитимете собі при будь-якому «чому в мене 401?».

9. Типові помилки

Помилка № 1: «Я надіслав заголовок, отже Spring зобов’язаний мене пустити».
Новачки іноді очікують, що наявність Authorization: Basic ... автоматично означає доступ до кінцевої точки. Насправді заголовок відповідає лише за спробу аутентифікації, а далі вступають у дію ваші правила authorizeHttpRequests. Ви можете успішно пройти аутентифікацію, але все одно отримати відмову в доступі через hasRole("ADMIN") на admin-зоні.

Помилка № 2: забули .httpBasic(...) у конфігурації, але впевнені, що Basic працює.
Це класика. Особливо якщо ви до цього налаштовували formLogin і бачили, що «щось про логін» у застосунку вже є. Але Spring Security не зобов’язаний вмикати всі механізми одразу. Якщо в SecurityFilterChain немає httpBasic(Customizer.withDefaults()), то Basic-аутентифікація може не ввімкнутися так, як ви очікуєте, і заголовок буде просто марним рядком у запиті.

Помилка № 3: спроба перевіряти пароль у контролері вручну.
Іноді хочеться зробити «швидко й зрозуміло»: прийняти Authorization у контролері, декодувати Base64, порівняти пароль рядком і повернути 200. Це звучить як план, доки не згадаєте, що ви тим самим викидаєте за борт AuthenticationManager, DaoAuthenticationProvider, UserDetailsService, PasswordEncoder і весь сенс Spring Security як інфраструктури. Результат — крихкий самописний фрагмент захисту, який ламається при першій же новій ролі, логуванні або потребі єдиних помилок.

Помилка № 4: зберігання in-memory пароля «як є», без PasswordEncoder.
HTTP Basic не робить пароль менш «справжнім». Якщо ви кладете пароль рядком і обходите encoder, ви закріплюєте неправильну модель і ризикуєте потім перенести її на DB-гілку. Правильна звичка залишається тією самою: пароль зберігається encoded, а сирий пароль існує лише як вхід для PasswordEncoder.matches(...).

Помилка № 5: логування заголовка Authorization повністю.
Здається, що «ну я ж у dev, мені треба налагодити». Але Authorization: Basic ... містить облікові дані, які можна відновити. Одного дня ви забудете вимкнути логування, заллєте логи кудись у спільний збирач, і вийде маленький навчальний витік із великими наслідками. Якщо потрібно логувати, логуйте факт наявності заголовка, але не його вміст.

1
Задача
Spring Security, 10 рівень, 2 лекція
Недоступна
Хто потрапив у SecurityContext після HTTP Basic
Хто потрапив у SecurityContext після HTTP Basic
1
Задача
Spring Security, 10 рівень, 2 лекція
Недоступна
Відкритий endpoint і поточний користувач в одному контролері
Відкритий endpoint і поточний користувач в одному контролері
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ