1. Два разных мира: браузерный пользователь и API-клиент
Если упростить (но не слишком обидно для реальности), у нас есть два типа “потребителей” backend’а. Первый — живой человек в браузере, который кликает, логинится, видит страницы и радуется редиректам. Второй — API-клиент: Postman, curl, мобильное приложение или другой backend-сервис, которому HTML-страницы не просто не нужны — они мешают работать.
Представьте, что вы сделали endpoint /api/me, который должен вернуть JSON с информацией о текущем пользователе. Для браузера «не залогинен → редирект на страницу логина» звучит логично: человека вежливо отвели туда, где он может ввести логин/пароль.
Но для API-клиента редирект — это почти издевательство. Клиент ожидал: “если нет доступа — верни код ошибки и понятное тело ответа”. А вместо этого получил “вот тебе ссылка на HTML-страницу, сходи туда, посмотри форму логина и как-нибудь… сам разберись”.
Тут полезно зафиксировать одну мысль: security-ошибка — это не «внутреннее дело сервера». Для клиента это такая же часть контракта, как и успешный ответ.
Чтобы увидеть проблему «глазами разных клиентов», удобно держать в голове простую таблицу:
| Клиент | Что он “ожидает” при проблеме доступа | Что он часто получает по умолчанию при formLogin |
|---|---|---|
| Браузер | Redirect на страницу логина (человеку понятно) | Redirect на /login + HTML login page |
| Postman | HTTP status + JSON с ошибкой | 302 redirect, а при follow redirect — 200 HTML |
| curl | Явный статус + тело (желательно JSON) | 302 + Location: /login, или HTML при -L |
2. Что теперь должен увидеть REST-клиент
После registration flow, password encoding и account states возникает следующий естественный вопрос: как все эти security-сценарии выглядят снаружи для REST-клиента. Anonymous-запрос, disabled account и запрет по роли — это разные причины отказа, и клиент должен отличать их по нормальному HTTP-ответу, а не по HTML-странице после редиректа.
Поэтому дальше нам важно пройти цепочку целиком: сначала увидеть, почему browser-style реакция ломает API, потом развести 401 и 403, а уже после этого настроить две security-точки ответа и собрать единый JSON-контракт.
3. Redirect 302 и потерянный JSON
Редирект в HTTP — это не “ошибка в теле ответа”, это другая ветка поведения протокола. Сервер говорит клиенту: “я не дам тебе то, что ты запросил, но дам адрес, куда сходить дальше”. Для браузера это как стрелка “Вход → сюда”. Для API-клиента это как получить вместо сдачи бумажку: “сходите в соседний магазин, там вам объяснят”.
Давайте посмотрим на очень типичную конфигурацию, которую мы легко могли иметь в stateful-ветке (и часто имеем на ранних этапах проекта).
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
.requestMatchers("/api/public/**").permitAll()
.anyRequest().authenticated()
)
// Включаем браузерный flow: при отсутствии аутентификации будет редирект на /login
.formLogin(form -> form.permitAll())
.build();
}
С точки зрения браузера это даже мило: не залогинен — улетел на /login, залогинился — пошёл дальше. Но для JSON API начинается странное.
Допустим, вы делаете запрос без аутентификации:
# Запрос без аутентификации: смотрим реальный HTTP-статус и заголовки
curl -i http://localhost:8080/api/me
Вы часто увидите примерно такую картину (укорочено):
HTTP/1.1 302 Found
Location: http://localhost:8080/login
То есть сервер не говорит “вот тебе JSON-ошибка”, он говорит “иди на /login”.
А теперь внимание: многие клиенты (и инструменты разработчика) умеют автоматически ходить по редиректам. Если включён follow redirects, запрос превращается в цепочку: /api/me → /login → и в итоге вы получаете… HTML страницу логина, часто со статусом 200.
Пример:
# -L: следуем редиректам (получим HTML login page вместо JSON)
curl -i -L http://localhost:8080/api/me
И где-то в ответе вы увидите:
HTTP/1.1 200 OK
Content-Type: text/html;charset=UTF-8
С точки зрения API-клиента это катастрофа мелкого масштаба: формально 200 OK, а по факту пришёл HTML. Это как заказать пиццу, а курьер привёз чек. Чек тоже бумага, но цель была другая.
4. Security раньше контроллера
Очень частая иллюзия выглядит так: “Если контроллер RESTовый, значит все ответы будут JSON”. Это правда только для тех запросов, которые дошли до контроллера. А Spring Security — штука вежливая, но строгая: если запрос не должен пройти, он остановит его раньше.
Вот минимальный контроллер из нашего проекта:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
class MeController {
// Приватный endpoint: сюда должны доходить только аутентифицированные запросы
@GetMapping("/api/me")
Map<String, String> me() {
// На happy path вернём JSON, потому что это @RestController
return Map.of("message", "private zone");
}
}
На happy path всё ок: вы залогинены — получите JSON. Но если вы не аутентифицированы, то до метода me() запрос не доберётся. И это не баг, это и есть смысл security-слоя: не допустить выполнение бизнес-кода, когда доступ запрещён.
Чтобы уложить это в голове, полезно ещё раз визуализировать путь запроса:
flowchart TD
A["Client: /api/me"] --> B["Security Filter Chain"]
B -->|ok| C["DispatcherServlet"]
C --> D["@RestController"]
B -->|blocked| E["Security error response"]
Ключевой вывод: формат ответа при security-ошибке определяется не контроллером, а механизмами Spring Security. Поэтому попытка «пофиксить всё в контроллере» обычно выглядит как попытка поставить дверь в квартире уже после того, как воры ушли. Дверь хорошая, но поздно.
5. HTML вместо JSON и ошибки парсинга
На уровне “мне неудобно в Postman” проблема кажется косметической. Но в реальных интеграциях она становится вполне технической и болезненной. API-клиент обычно делает примерно так: “получи ответ → распарси JSON → обработай поля”. Когда вместо JSON прилетает HTML, клиент падает не красиво и не объяснимо для пользователя.
Особенно узнаваемый симптом — ошибка парсинга JSON, потому что HTML начинается с символа <. Примерно как в жизни: вы ожидали цифры, а вам принесли “<html>”.
Если бы это был Java-клиент, то внутри вы могли бы увидеть что-то вроде (идея, не привязка к конкретному клиенту):
import com.fasterxml.jackson.databind.ObjectMapper;
public class JsonExpectingClient {
public static void main(String[] args) throws Exception {
// Имитация типичного ответа: вместо JSON прилетела HTML-страница логина
String body = """
<html>
<body>login page</body>
</html>
""";
// Пытаемся распарсить как JSON — и закономерно падаем на символе '<'
new ObjectMapper().readTree(body); // Unexpected character ('<' ...)
}
}
Даже если вы не пишете Java-клиент, логика та же: мобильное приложение (Android/iOS), фронтенд (React/Vue), другой backend-сервис — все они хотят предсказуемое поведение.
И вот что особенно неприятно: HTML-ответ часто приходит со статусом 200 (после редиректа), поэтому клиент может даже не понять, что это “ошибка доступа”. Он видит “успех”, пытается распарсить, падает, а разработчик потом полдня ищет баг в JSON-моделях. Хотя баг был в том, что сервер вообще решил поговорить на языке браузера, а не API.
6. Контракт security-ошибок для API
REST API — это договор. И как любой договор, он должен описывать не только “как всё хорошо”, но и “как всё плохо”. Если клиент не аутентифицирован или не имеет права, сервер обязан ответить так, чтобы клиент мог это обработать программно, без гадания по HTML и редиректам.
Минимальный набор ожиданий от нормального security-ответа для API почти всегда такой: во-первых, корректный HTTP status code (обычно это какая-то из веток 4xx), во-вторых, Content-Type: application/json, и в-третьих, стабильная структура тела (пусть даже маленькая).
Например, в идеальном мире security-ошибка могла бы выглядеть вот так:
{
"status": 401,
"error": "UNAUTHORIZED",
"message": "Authentication is required",
"path": "/api/me"
}
Важно: это не “финальная спецификация” (мы её соберём позже сегодня), а просто демонстрация принципа: клиенту не нужно угадывать, что случилось. Он видит код и поля, и может сделать ровно то, что должен: например, показать экран логина или сообщить “недостаточно прав”.
И ещё один принципиальный момент: это должно настраиваться на security-уровне, а не через if в контроллере. Контроллер — это бизнес-логика. Security-ошибка — это инфраструктурное решение, которое должно быть единым и одинаковым для всех защищённых endpoint’ов.
Пока что мы не пишем конкретные обработчики (это следующие лекции дня), но уже сейчас можно зафиксировать: Spring Security даёт две ключевые точки для такого поведения, и они находятся именно там, где им и положено быть — в security-слое, а не в MVC.
7. Secure Content Platform API: где болит
В нашем проекте “Secure Content Platform API” проблема особенно заметна, потому что домен очень естественно делится на зоны. Публичные статьи (/api/public/**) доступны всем — там редиректы вообще не возникают. А вот личная зона и привилегированные зоны — это прямой генератор “неприятных” ошибок, если оставить browser-семантику по умолчанию.
Возьмём несколько живых примеров из карты endpoint’ов проекта. Запрос GET /api/me для anonymous-клиента — это нормальный сценарий: клиент ещё не залогинен, он проверяет, есть ли сессия/аутентификация. В REST-мире он должен получить понятный ответ, по которому можно решить: “окей, покажу экран логина” или “нужно обновить токен/куки”.
Дальше есть более «острые» зоны: GET /api/editor/review-queue и GET /api/admin/users. Тут уже типичный сценарий — пользователь залогинен, но у него не та роль/authority. Если в ответ внезапно прилетает HTML или редирект, клиент не сможет корректно отличить “ты не залогинен” от “ты залогинен, но тебе нельзя”. А это ломает UX и логику приложений: например, мобильное приложение может начать бесконечно предлагать “войти заново”, хотя вход уже был успешным.
Именно поэтому сегодня мы и начинаем приводить security-ошибки к виду, который подходит для API. Мы не “отключаем безопасность”, не “ломаем form login”, и не делаем костыли в контроллерах. Мы делаем скучную, взрослую вещь: формируем предсказуемый контракт ошибок.
Мини-диагностика с curl и Postman
Прежде чем что-то чинить, полезно научиться это “видеть” без мистики. Иначе получится классическая история: “я что-то поменял в конфиге — оно стало по-другому, но я не понял, стало ли лучше”. Для security это особенно опасно: можно случайно улучшить одно и открыть другое.
Самый честный способ — curl -i, потому что он показывает заголовки. Начните с этого: запросите любой защищённый endpoint без аутентификации и посмотрите, какой статус пришёл и есть ли Location.
curl -i http://localhost:8080/api/me
Если вы видите 302 и Location: .../login, значит сейчас сервер разговаривает с вами как с браузером. Дальше попробуйте намеренно “включить тупость” клиента и пойти по редиректу (это делают многие инструменты автоматически).
curl -i -L http://localhost:8080/api/me
Если в конце цепочки вы видите 200 OK и Content-Type: text/html, значит вы получили login page или другую HTML-страницу. И это как раз то, что будет ломать JSON-клиентов.
В Postman есть похожий трюк: отключить автоматический follow redirects, чтобы увидеть первичный ответ сервера. Многие разработчики годами живут без этого знания, а потом удивляются, почему “у меня в Postman 200, но приложение не работает”. Postman просто дошёл до логина и показал страницу, а вы думали, что это успех.
И последняя маленькая проверка, которая часто ломает мозг новичкам: даже если вы добавите заголовок Accept: application/json, это не гарантирует, что security-ошибка станет JSON. Контент-неготиэйшн важен, но если конфигурация security ориентирована на browser flow, она вполне может продолжить выдавать HTML/redirect. Это именно то, что мы будем исправлять дальше по дню.
8. Типичные ошибки
Ошибка №1: “У меня же @RestController, значит всё будет JSON”.
Эта мысль звучит логично, пока вы не вспомнили, что Spring Security стоит до контроллеров. Если запрос не прошёл security-проверку, контроллер даже не выполняется, а значит — не может “выдать правильный JSON”. Правильная реакция на security-ошибку настраивается в SecurityFilterChain, а не в @ExceptionHandler контроллера.
Ошибка №2: не заметить редирект из-за авто-follow в инструменте.
Postman, браузер и некоторые HTTP-клиенты умеют автоматически ходить по 302/301. В итоге вы видите 200 и HTML-страницу, хотя исходная проблема была “нет доступа к /api/me”. Если вы отлаживаете security-поведение, всегда полезно временно отключать follow redirects или проверять ответ через curl -i.
Ошибка №3: пытаться “вернуть 401 вручную” из контроллера через ResponseEntity.
Наивный подход выглядит так: “ну я же могу в методе проверить, залогинен ли пользователь, и вернуть 401”. Это работает плохо по двум причинам: во-первых, вы дублируете security-логику в бизнес-коде, во-вторых, вы всё равно не перехватите случаи, когда Spring Security не пустил запрос до контроллера. В результате половина ошибок будет обработана одним способом, половина — другим, и клиент получит хаос вместо контракта.
Ошибка №4: смешать browser-семантику и API-семантику и надеяться, что «как-то само разрулится».
Если у вас formLogin включён и вы обслуживаете браузер — редирект может быть уместен. Но для /api/** обычно нужен другой стиль реакции: код + JSON. Если не разделить эти ожидания явно, вы получите систему, где один и тот же endpoint “иногда возвращает JSON, иногда HTML”, и это почти гарантированный источник багов.
Ошибка №5: считать HTML-ошибку “косметикой”.
HTML вместо JSON — это не просто некрасиво. Это ломает автоматическую обработку ошибок, приводит к странным падениям на клиенте (“Unexpected character '<'”), усложняет отладку и мешает корректно отличать разные типы security-сбоев. В REST API формат ошибок — такая же часть качества, как DTO и validation.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ