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.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ