JavaRush /Курси /Spring Security /EntryPoint: 401 JSON...

EntryPoint: 401 JSON

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

1. Вступ

Проблема вже видима: REST-ендпоінт без автентифікації не повинен надсилати клієнта на /login або годувати його HTML. Різницю між 401 та 403 ми вже розібрали: зараз нас цікавить лише перша гілка — для поточного запиту немає успішної автентифікації.

Отже, потрібен не if у контролері, а компонент рівня безпеки, який спрацює ще в ланцюгу фільтрів і поверне коректний 401 у JSON. Зараз зберемо першу робочу версію цієї гілки: без винесення спільного коду, зате з максимально прозорою механікою.

2. Ментальна модель: AuthenticationEntryPoint

AuthenticationEntryPoint — це точка, у яку Spring Security приходить, коли запит упирається саме у відсутність автентифікації. Вона не перевіряє ролі й не вирішує 403; її завдання простіше: оформити відповідь на запитання «хто ви взагалі?» так, щоб клієнт зрозумів, що спершу потрібно автентифікуватися.

Якщо зовсім коротко, зв’язка тут така: рівень безпеки бачить анонімний запит до захищеного ендпоінта, ExceptionTranslationFilter перехоплює цю ситуацію і викликає entry point. Для 403 є окрема гілка через AccessDeniedHandler, а тут тримаємо фокус лише на 401-гілці.

Мінісхема виклику AuthenticationEntryPoint

Щоб не гадати, хто взагалі його викликає, корисно мати наочну схему. На прикладному рівні, без заглиблення в увесь каталог фільтрів, це виглядає приблизно так:

flowchart TD
    A[HTTP request: GET /api/me] --> B[Ланцюг фільтрів безпеки]
    B --> C["Перевірка доступу: ендпоінт потребує authenticated()"]
    C -->|користувач анонімний| D[ExceptionTranslationFilter]
    D --> E["AuthenticationEntryPoint.commence(...)"]
    E --> F[HTTP 401 + JSON body]

Нам не потрібно знати всі внутрішні деталі ExceptionTranslationFilter на рівні вихідних кодів, але ключова думка така: є фільтр, який перехоплює security-виключення та обирає, як відповісти. Якщо проблема — відсутність автентифікації, він викликає entry point.

3. Реалізація: RestAuthenticationEntryPoint

Зараз ми зробимо максимально прикладний крок: напишемо компонент, який формує 401 і тіло відповіді в JSON. Наша мета — щоб будь-який API-клієнт (Postman, curl, мобільний застосунок, інший бекенд-сервіс) міг спиратися на три речі: коректний статус, Content-Type: application/json і передбачуване тіло.

Беремо найпростіший робочий варіант: окремий DTO та запис JSON прямо всередині commence(...). Цього достатньо, щоб побачити механіку 401-гілки без зайвої абстракції.

Міні-DTO для тіла відповіді

Почнемо з простого: нам потрібен об’єкт, який Jackson зможе серіалізувати в JSON. Поки це локальний DTO саме для 401-відповіді, тому record у Java 25 підходить ідеально — коротко, зрозуміло, без тонни геттерів.

package com.example.securecontent.security.handler;

// Локальний DTO для першої робочої версії 401-відповіді
public record ApiSecurityError(
    int status,    // HTTP-статус (наприклад, 401)
    String error,  // Короткий код помилки (наприклад, UNAUTHORIZED)
    String message,// Зрозуміле для людини повідомлення (без зайвих деталей)
    String path    // Шлях запиту, де сталася помилка
) {
}

Так, поля тут мінімальні: статус, короткий код помилки, повідомлення і path. Нам цього достатньо, щоб клієнт розумів, що сталося, без ручного складання JSON і без зайвої деталізації.

Реалізація AuthenticationEntryPoint

Тепер пишемо компонент. Тіло відповіді теж поки збираємо прямо тут: так добре видно, що саме робить commence(...). Ми впровадимо ObjectMapper від Spring Boot — це зручно, тому що він уже налаштований (модулі, дати, параметри серіалізації), і ви не плодите «лівий» мапер, який в інших місцях проєкту поводиться інакше.

package com.example.securecontent.security.handler;

import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.security.web.AuthenticationEntryPoint;
import org.springframework.stereotype.Component;

// Перша робоча версія: замість HTML/редиректів повертаємо JSON з 401
@Component
public class RestAuthenticationEntryPoint implements AuthenticationEntryPoint {

    private final ObjectMapper objectMapper;

    public RestAuthenticationEntryPoint(ObjectMapper objectMapper) {
        // Беремо ObjectMapper із контексту Spring Boot, щоб серіалізація була єдиною по проєкту
        this.objectMapper = objectMapper;
    }
}

Поки це просто заготовка: Spring побачить @Component, створить бін і зможе використати його в конфігурації.

Тепер додамо метод commence(...). Саме він і формуватиме відповідь.

import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.core.AuthenticationException;

import java.io.IOException;

І реалізація:

@Override
public void commence(HttpServletRequest request,
                     HttpServletResponse response,
                     AuthenticationException ex) throws IOException {

    // 1) Явно встановлюємо HTTP-статус: автентифікації немає -> 401
    response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);

    // 2) Явно повідомляємо клієнту, що тіло — JSON (інакше деякі клієнти «вгадують» неправильно)
    response.setContentType(MediaType.APPLICATION_JSON_VALUE);

    // 3) Формуємо передбачуване тіло відповіді (без розкриття внутрішніх деталей)
    var body = new ApiSecurityError(
        401,
        "UNAUTHORIZED",
        "Потрібна автентифікація",
        request.getRequestURI()
    );

    // 4) Серіалізуємо об’єкт у JSON через ObjectMapper, а не вручну рядком
    objectMapper.writeValue(response.getOutputStream(), body);
}

Зверніть увагу на три речі.

По-перше, ми вручну ставимо статус 401. Не «кидаємо» виключення далі, не сподіваємося, що Spring сам здогадається. Ми — власники контракту, і ми відповідаємо.

По-друге, ми явно ставимо Content-Type. Якщо забути це зробити, клієнт може спробувати інтерпретувати відповідь як текст, HTML чи що завгодно, і ви отримаєте дивну поведінку на боці UI або інтеграції.

По-третє, ми пишемо JSON через ObjectMapper, а не через response.getWriter().println("{...}"). Останнє працює… до першого спецсимволу, до першої потреби екранування, до першої локалізації та до першої зміни структури. Вручну писати JSON — це як вручну будувати літак зі сірників: дивовижно, що він літає, але краще не треба.

4. Підключення в SecurityFilterChain

Один із найприємніших моментів Spring Security — коли ви розумієте, що більша частина «магії» насправді підключається одним рядком, просто його потрібно написати в правильному місці. Entry point задається через exceptionHandling у конфігурації HttpSecurity.

Зробімо це в нашій security-конфігурації. Припустімо, у нас є клас SecurityConfig у пакеті com.example.securecontent.security.config.

Це перша робоча збірка: 401 уже контролюється централізовано на рівні безпеки, без редиректів і без спроб виправити все в контролері.

package com.example.securecontent.security.config;

import com.example.securecontent.security.handler.RestAuthenticationEntryPoint;
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,
                                        RestAuthenticationEntryPoint entryPoint) throws Exception {
    // Важливо: цей @Bean-метод має бути всередині конфігураційного класу (наприклад, @Configuration)
    // Підключаємо першу робочу 401-гілку: за відсутності автентифікації буде наш JSON, а не редирект/HTML
    http.exceptionHandling(ex -> ex.authenticationEntryPoint(entryPoint));

    return http.build();
}

Цей фрагмент сам по собі ще не каже, які ендпоінти захищені. Але він фіксує важливе: коли Spring Security вирішить, що потрібна автентифікація, він не редиректитиме на сторінку входу (якщо ви не залишили типовий entry point), а викличе наш entry point, і ми віддамо JSON.

Щоб показати все разом трохи реалістичніше, додамо мінімальні правила доступу.

http.authorizeHttpRequests(auth -> auth
    // Публічні ендпоінти: доступні без автентифікації
    .requestMatchers("/api/public/**", "/api/auth/register").permitAll()
    // Усе інше: лише для автентифікованих користувачів
    .anyRequest().authenticated()
);

І разом з entry point:

http
  .authorizeHttpRequests(auth -> auth
      // Публічні ендпоінти: доступні без автентифікації
      .requestMatchers("/api/public/**", "/api/auth/register").permitAll()
      // Усе інше: лише для автентифікованих користувачів
      .anyRequest().authenticated()
  )
  // Якщо автентифікації немає, повертаємо 401 у JSON (через наш EntryPoint)
  .exceptionHandling(ex -> ex.authenticationEntryPoint(entryPoint));

Зверніть увагу, ми тут не обговорюємо, увімкнено у вас formLogin чи httpBasic. Це окрема тема, і у вас уже були лекції про form login та HTTP Basic. AuthenticationEntryPoint відповідає саме за реакцію, коли автентифікації немає, незалежно від того, чим ви її взагалі збиралися робити.

5. Перевірка в Secure Content Platform API

Будь-яка конфігурація security без перевірки — це як ремінь безпеки, який «десь у машині лежить». Технічно він існує, але допоможе лише у світі фантазій. Тож перевірмо симптоми: як поводиться /api/me, коли користувач не автентифікований.

Припустімо, у нас є контролер:

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

import java.util.Map;

@RestController
class MeController {

    @GetMapping("/api/me")
    public Map<String, String> me() {
        // Цей ендпоінт приватний: за відсутності автентифікації має спрацювати EntryPoint і повернутися 401 JSON
        return Map.of("message", "закрита зона");
    }
}

Тепер перевіряємо без автентифікації:

# Запит без токена або сесії: очікуємо 401 від рівня безпеки
curl -i http://localhost:8080/api/me

Очікуємо приблизно таке, спрощено:

HTTP/1.1 401
Content-Type: application/json
...

{"status":401,"error":"UNAUTHORIZED","message":"Потрібна автентифікація","path":"/api/me"}

І ось це вже зручно для REST-API: клієнт бачить 401, бачить JSON, може легко обробити відповідь і показати користувачеві «увійдіть», або ініціювати процес входу, або просто чесно сказати «доступу немає».

Маленька, але важлива дисципліна

Навіть якщо вам здається, що «ну й так зрозуміло, чому 401», поле path сильно полегшує життя під час налагодження, особливо коли у вас у Postman колекція з 40 запитів, частина з них параметризована, і ви випадково стукаєте не туди.

6. Межі відповідальності AuthenticationEntryPoint

Дуже легко почати очікувати від entry point усього: «нехай він і логін виправляє, і помилки авторизації, і validation, і нехай ще каву заварює». Але правильна mental model тут економить час і нерви.

AuthenticationEntryPoint відповідає за реакцію на проблему «немає автентифікації». Це місце, де ви зобов’язані зробити API-відповідь передбачуваною, але ви не повинні перетворювати його на бізнес-обробник помилок. Воно не має лазити в базу, збирати складні traceId, тягнути всі поля користувача й переказувати клієнту внутрішню політику безпеки.

Також entry point не вирішує проблему 403. Якщо користувач уже автентифікований і намагається зайти в /api/admin/**, entry point не повинен робити вигляд, що це 401. Це принципово різні сценарії. І якраз наступний крок — окремий обробник для forbidden-сценаріїв.

Ще одна межа — надто докладні помилки. Якщо ви в message почнете писати «User is disabled» або «Account is locked», ви можете ненароком перетворити ваш API на довідкову службу для зловмисника: він буде перебирати логіни й дізнаватися, які облікові записи існують і в якому вони стані. Навіть у навчальному проєкті корисно відразу звикати до нейтрального формулювання.

7. Типові помилки під час налаштування AuthenticationEntryPoint

Помилка №1: повернути 403 з entry point «бо доступ заборонений».
Логіка зрозуміла по-людськи, але неправильна за змістом протоколу: 403 означає, що користувач відомий (автентифікований), але йому не можна. Якщо ви віддаєте 403 анонімному користувачу, клієнт починає думати: «я увійшов, але не туди», хоча насправді він узагалі не входив.

Помилка №2: забути Content-Type і отримати «JSON, який не JSON».
Без Content-Type: application/json багато клієнтів поводитимуться дивно: хтось покаже тіло як plain text, хтось спробує парсити як HTML, а хтось, особливо в мобільному SDK, просто не знайде потрібний обробник. В API-контрактах дрібниці — це не дрібниці.

Помилка №3: писати JSON вручну рядком.
Сьогодні ви написали "{\"error\":\"UNAUTHORIZED\"}", завтра додали лапку в текст, післязавтра прилетіла локалізація зі символом перенесення рядка — і ви раптово віддаєте клієнту невалідний JSON. ObjectMapper існує не для краси.

Помилка №4: розкривати надто багато внутрішньої інформації.
AuthenticationException всередині може містити деталі, корисні вам у логах, але не корисні назовні. Не віддавайте ex.getMessage() напряму клієнту. Це часта «зручна» практика, яка згодом перетворюється на неприємну вразливість і на нестабільний контракт.

Помилка №5: намагатися вирішити проблему в контролері через try/catch.
Якщо запит не пройшов security-фільтри, ваш контролер його не побачить. Тому обробка «немає автентифікації» має жити рівно там, де вона виникає, — у security-рівні, через AuthenticationEntryPoint.

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