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

EntryPoint: 401 JSON

Spring Security
17 уровень , 2 лекция
Открыта

1. Введение

Проблема уже видна: REST endpoint без аутентификации не должен отправлять клиента на /login или кормить его HTML. И разницу между 401 и 403 мы уже развели: сейчас нас интересует только первая ветка — для текущего запроса нет успешной аутентификации.

Значит, нужен не if в контроллере, а компонент security‑слоя, который сработает ещё в filter chain и вернёт нормальный 401 в JSON. Сейчас соберём первую рабочую версию этой ветки: без вынесения общего кода, зато с максимально прозрачной механикой.

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

AuthenticationEntryPoint — это точка, в которую Spring Security приходит, когда запрос упёрся именно в отсутствие аутентификации. Он не проверяет роли и не решает 403; его задача проще: оформить ответ на вопрос “кто ты вообще?” так, чтобы клиент понял, что сначала нужно аутентифицироваться.

Если совсем коротко, связка здесь такая: security‑слой видит anonymous‑запрос к protected endpoint, ExceptionTranslationFilter перехватывает эту ситуацию и вызывает entry point. У 403 есть отдельная ветка через AccessDeniedHandler, а здесь держим фокус только на 401‑ветке.

Мини‑схема вызова AuthenticationEntryPoint

Чтобы не гадать “кто вообще его вызывает”, полезно иметь картинку. На прикладном уровне (без углубления в весь каталог фильтров) это выглядит примерно так:

flowchart TD
    A[HTTP request: GET /api/me] --> B[Security Filter Chain]
    B --> C["Проверка доступа: endpoint требует authenticated()"]
    C -->|пользователь anonymous| D[ExceptionTranslationFilter]
    D --> E["AuthenticationEntryPoint.commence(...)"]
    E --> F[HTTP 401 + JSON body]

Нам не нужно знать все внутренности ExceptionTranslationFilter на уровне исходников, но ключевая мысль такая: есть фильтр, который перехватывает security‑исключения и выбирает, как ответить. Если проблема — отсутствие аутентификации, он вызывает entry point.

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

Сейчас мы сделаем максимально прикладной шаг: напишем компонент, который формирует 401 и тело ответа в JSON. Наша цель — чтобы любой API‑клиент (Postman, curl, мобильное приложение, другой backend‑сервис) мог опираться на три вещи: корректный статус, 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",
        "Authentication is required",
        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 уже контролируется централизованно в security‑слое, без редиректов и без попыток чинить всё в контроллере.

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();
}

Этот кусок сам по себе ещё не говорит, какие endpoint’ы защищены. Но он фиксирует важное: когда Spring Security решит, что нужна аутентификация, он не будет редиректить на логин‑страницу (если вы не оставили дефолтный entry point), а вызовет наш entry point, и мы выдадим JSON.

Чтобы показать “всё вместе” чуть реалистичнее, добавим минимальные правила доступа.

http.authorizeHttpRequests(auth -> auth
    // Публичные endpoint'ы: доступны без аутентификации
    .requestMatchers("/api/public/**", "/api/auth/register").permitAll()
    // Всё остальное: только для аутентифицированных пользователей
    .anyRequest().authenticated()
);

И вместе с entry point:

http
  .authorizeHttpRequests(auth -> auth
      // Публичные endpoint'ы: доступны без аутентификации
      .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() {
        // Этот endpoint приватный: при отсутствии аутентификации должен сработать EntryPoint и вернуться 401 JSON
        return Map.of("message", "private zone");
    }
}

Теперь проверяем без аутентификации:

# Запрос без токена/сессии: ожидаем 401 от security-слоя
curl -i http://localhost:8080/api/me

Ожидаем примерно такое (упрощённо):

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

{"status":401,"error":"UNAUTHORIZED","message":"Authentication is required","path":"/api/me"}

И вот это уже “REST‑friendly”: клиент видит 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.

1
Задача
Spring Security, 17 уровень, 2 лекция
Недоступна
Собственный `AuthenticationEntryPoint` для `/api/profile`
Собственный `AuthenticationEntryPoint` для `/api/profile`
1
Задача
Spring Security, 17 уровень, 2 лекция
Недоступна
Один `AuthenticationEntryPoint` для нескольких защищённых endpoint’ов
Один `AuthenticationEntryPoint` для нескольких защищённых endpoint’ов
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ