1. Когда нужен AccessDeniedHandler
401-ветку мы уже собрали: если запрос приходит без аутентификации, security-слой сам возвращает JSON через AuthenticationEntryPoint. Но это только половина картины. В Secure Content Platform API пользователь вполне может успешно войти и всё равно не иметь права на /api/admin/** или /api/editor/**.
Здесь уже не нужен новый login flow. Нужен честный 403, который скажет клиенту: пользователя мы знаем, но правило доступа не прошло. Этой веткой и занимается AccessDeniedHandler.
Чтобы не смешивать две ветки, удобно держать в голове такую таблицу:
| Сценарий запроса | Пользователь известен системе? | Правило доступа выполнено? | HTTP статус | Кто формирует ответ |
|---|---|---|---|---|
| GET /api/admin/users без аутентификации | нет | нет | 401 | AuthenticationEntryPoint |
| GET /api/admin/users как USER | да | нет | 403 | AccessDeniedHandler |
| GET /api/admin/users как ADMIN | да | да | 200 | контроллер |
Ключевая мысль здесь такая: 403 — это не “сервер вредничает”, это честный сигнал клиенту: аутентификация прошла, но авторизация — нет. Поэтому для 403 нам нужен отдельный, специально предназначенный компонент — AccessDeniedHandler.
2. Как выбирается 401 или 403
Как и в случае с 401, решение принимается до того, как запрос попадёт в ваш контроллер. Security-слой уже проверил правило доступа, получил отказ и поднял AccessDeniedException. Дальше ExceptionTranslationFilter смотрит, есть ли у запроса аутентифицированный пользователь: если нет, это всё ещё 401 и работает AuthenticationEntryPoint; если да, включается AccessDeniedHandler и оформляет 403.
AccessDeniedHandler не проверяет роли заново и не решает, кто прав. Он получает уже готовый факт отказа и превращает его в нормальный REST-ответ, чтобы клиент увидел JSON, а не случайную HTML-страницу.
Это можно изобразить так:
flowchart TD
A["Запрос приходит в Security filter chain"] --> B["Проверка правил доступа"]
B -->|правило ОК| C["Запрос доходит до контроллера"]
B -->|правило НЕ ОК| D["Возникает AccessDeniedException"]
D --> E["ExceptionTranslationFilter переводит исключение в HTTP-ответ"]
E -->|пользователь anonymous / нет Authentication| F["AuthenticationEntryPoint → 401 JSON"]
E -->|пользователь аутентифицирован| G["AccessDeniedHandler → 403 JSON"]
Если вы держите это в голове, становится очевидно, почему попытки “вернуть 403 из контроллера” — это костыль. Контроллер просто не обязан выполняться в ситуации, где доступ запрещён.
3. Пишем RestAccessDeniedHandler: минимальная реализация
Сейчас мы сделаем очень «обычную» вещь: напишем класс, который реализует интерфейс AccessDeniedHandler и возвращает JSON. Это не самая сложная часть Spring Security, но она важна методически: именно тут у студентов обычно ломается ожидание “у меня же REST, почему мне пришёл HTML?”. И да, мы делаем это один раз централизованно, а не копируем try/catch по контроллерам.
Берём такую же прямую рабочую форму, как и для 401: тело ответа пока собираем прямо внутри handler’а. Так лучше видно механику handle(...), прежде чем выносить общий код отдельно.
Начнём с того, что AccessDeniedHandler — интерфейс из web-слоя Spring Security. Он получает HttpServletRequest, HttpServletResponse и AccessDeniedException. Наша задача — выставить статус 403, указать JSON-контент и записать понятное тело ответа.
Мини-скелет класса выглядит так:
import org.springframework.security.web.access.AccessDeniedHandler;
public class RestAccessDeniedHandler implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException ex) throws IOException {
// Здесь мы "оформляем отказ": выставляем 403 и возвращаем JSON вместо HTML
// Важно: до контроллера запрос в этот момент уже НЕ дойдёт
}
}
Теперь добавим нормальную реализацию. Мы будем использовать ObjectMapper, потому что в Spring Boot он уже есть как bean (спасибо Jackson), и это правильнее, чем вручную собирать JSON строкой (строки в Java и так страдают — не будем добавлять им лишней боли).
Вот компактная версия класса для проекта. Обратите внимание на пакет: по нашему ТЗ это логично положить в com.example.securecontent.security.handler.
package com.example.securecontent.security.handler;
import com.fasterxml.jackson.databind.ObjectMapper;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.MediaType;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Map;
@Component
public class RestAccessDeniedHandler implements AccessDeniedHandler {
// ObjectMapper инжектится из Spring Boot (Jackson)
// Это удобнее и безопаснее, чем "склеивать" JSON руками строкой
private final ObjectMapper objectMapper;
public RestAccessDeniedHandler(ObjectMapper objectMapper) {
this.objectMapper = objectMapper;
}
@Override
public void handle(HttpServletRequest request,
HttpServletResponse response,
AccessDeniedException ex) throws IOException {
// 403: пользователь аутентифицирован, но доступ запрещён (роль/правило/CSRF и т.п.)
response.setStatus(HttpServletResponse.SC_FORBIDDEN);
// Явно задаём кодировку, чтобы сообщение корректно читалось в клиентах и логах
response.setCharacterEncoding(StandardCharsets.UTF_8.name());
// Явно задаём тип контента: это REST API, поэтому возвращаем JSON
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
// На этом шаге собираем JSON прямо здесь; общий кусок потом вынесем отдельно
objectMapper.writeValue(response.getOutputStream(), Map.of(
"status", 403,
"error", "FORBIDDEN",
"message", "Access is denied",
"path", request.getRequestURI()
));
}
}
Здесь есть несколько важных решений, которые стоит осознать, а не просто скопировать.
Мы явно ставим SC_FORBIDDEN (403), потому что это и есть смысл отказа: пользователь прошёл аутентификацию, но право доступа не подтверждено. Мы задаём Content-Type как application/json, потому что API-клиенту важно понимать, что внутри именно JSON, а не “угадай-ка” (некоторые клиенты реально ориентируются на content type). Мы используем request.getRequestURI(), чтобы в ответе было видно, какой путь был отклонён. Это не раскрывает секретов, но сильно помогает и клиенту, и разработчику.
Про сообщение стоит отдельно сказать пару слов. Очень хочется написать что-то типа "message": ex.getMessage(), но это как раз та самая ловушка, в которую новичок падает с красивым разбегом. Сообщения внутренних исключений часто слишком подробные, иногда непредсказуемые, иногда зависят от версии Spring Security. Для публичного API лучше держать сообщение нейтральным и стабильным.
Если хочется чуть больше информации для разработчика, правильнее делать это через логирование, а не через ответ наружу. Например, можно залогировать имя пользователя и путь, но не тащить это в JSON. Внутри handle(...) это делается так:
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
private static final Logger log = LoggerFactory.getLogger(RestAccessDeniedHandler.class);
// Берём пользователя из SecurityContext: это для логов, а не для ответа клиенту
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String username = (auth != null) ? auth.getName() : "<unknown>";
// Важно: в логи можно писать больше деталей, чем отдавать наружу в JSON
log.info("Access denied. user={}, path={}", username, request.getRequestURI());
Пользователь не увидит эту информацию (и это хорошо), а вы в логах получите нормальную “ниточку”, когда начнёте разбирать “почему у меня 403”.
4. Подключаем AccessDeniedHandler в SecurityFilterChain
Самый частый «студенческий» баг в этот момент звучит так: “Я написал класс, а ничего не поменялось”. И это нормально: Spring Security не читает ваши мысли, даже если вы очень выразительно на него смотрите. Компонент нужно либо зарегистрировать как bean (мы сделали @Component), либо создать @Bean вручную, и затем подключить его в конфигурацию через exceptionHandling.
Ниже пример конфигурации, где мы подключаем и RestAuthenticationEntryPoint (из прошлой лекции), и наш новый RestAccessDeniedHandler. Для примера оставим понятную зону /api/admin/**, доступную только роли ADMIN. В реальном проекте правила могут быть богаче, но нам сейчас важна именно механика 403.
import com.example.securecontent.security.handler.RestAccessDeniedHandler;
import com.example.securecontent.security.handler.RestAuthenticationEntryPoint;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
RestAuthenticationEntryPoint entryPoint,
RestAccessDeniedHandler deniedHandler) throws Exception {
http.authorizeHttpRequests(auth -> auth
// Публичные эндпоинты: без аутентификации
.requestMatchers("/api/public/**", "/api/auth/register").permitAll()
// Админ-зона: только роль ADMIN
.requestMatchers("/api/admin/**").hasRole("ADMIN")
// Всё остальное: пользователь должен быть аутентифицирован
.anyRequest().authenticated()
);
http.exceptionHandling(ex -> ex
// 401: нет аутентификации (anonymous)
.authenticationEntryPoint(entryPoint)
// 403: аутентификация есть, но доступ запрещён
.accessDeniedHandler(deniedHandler)
);
return http.build();
}
}
Обратите внимание: exceptionHandling — это то место, где мы «прикручиваем» человеко- и машинно-ориентированное поведение ошибок на уровне security. authenticationEntryPoint(...) отвечает за 401, а accessDeniedHandler(...) — за 403. Они не конфликтуют: они отвечают за разные смысловые ветки.
Это ещё не финальная форма слоя ошибок: сейчас важно запустить 403-ветку рядом с уже работающим 401, а общий формат ответа перестать дублировать уже следующим движением.
5. Проверка: USER в админке
Сейчас хочется сделать то, что мы всегда делаем в разработке: убедиться, что оно правда работает, а не «кажется». Самый быстрый способ — взять endpoint, который точно закрыт ролью, и сходить туда сначала без аутентификации, а потом с аутентификацией, но без права доступа. В идеале вы должны увидеть два разных статуса и два разных обработчика.
Для демонстрации можно использовать, например, такой контроллер:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
class AdminController {
// Простейший эндпоинт "админской зоны" для проверки работы 401/403
@GetMapping("/api/admin/users")
Map<String, String> users() {
// Здесь специально простое тело ответа — нам важен сам факт доступа/запрета
return Map.of("message", "admin zone");
}
}
Дальше сценарий проверки очень прямолинейный.
Если вы зовёте GET /api/admin/users без аутентификации, вы должны получить 401 и JSON, сформированный AuthenticationEntryPoint. В прошлой лекции мы его настраивали, так что повторяться сейчас не будем.
Если вы зовёте тот же endpoint как обычный пользователь (аутентификация есть, но роль не подходит), вы должны получить 403 и JSON, сформированный RestAccessDeniedHandler. Ответ будет примерно такой:
HTTP/1.1 403 Forbidden
Content-Type: application/json
{"status":403,"error":"FORBIDDEN","message":"Access is denied","path":"/api/admin/users"}
И вот это уже тот самый результат, ради которого всё затевалось. Клиенту не нужно парсить HTML, не нужно понимать, что такое “Whitelabel Error Page”, не нужно гадать, это редирект или нет. Он видит 403 и машиночитаемое тело. А вы, как разработчик, видите в ответе path и можете быстро сопоставить с конфигурацией.
Важно: сам AccessDeniedHandler не создаёт forbidden-сценарий из воздуха. Чтобы увидеть этот 403, в access matrix уже должен существовать реальный запрет — например, .requestMatchers("/api/admin/**").hasRole("ADMIN").
Нюанс: 403 не только про роли
На этом месте обычно появляется закономерный вопрос: “А всегда ли 403 означает ‘не та роль’?” И ответ — нет. 403 Forbidden в Spring Security — это общий язык для ситуации “доступ запрещён”, но запрет может быть вызван разными причинами. Иногда это действительно роль/authority, иногда — другие механизмы защиты, которые тоже выражают отказ как AccessDeniedException.
Например, в stateful-ветке с CSRF вы легко могли видеть 403 при POST/PATCH/DELETE, если CSRF-токен отсутствует или неправильный. С точки зрения security-слоя это тоже “нельзя выполнять операцию”, и оно вполне может уйти в тот же AccessDeniedHandler. Это ещё один аргумент, почему мы держим message нейтральным и не пишем туда “у вас нет роли ADMIN”: завтра вы поймаете 403 из-за CSRF, и клиент будет удивлён.
Правильная инженерная позиция тут такая: наружу мы отдаём стабильный контракт и аккуратную семантику (403 + JSON), а внутри (логи) — можем хранить больше деталей для отладки. Если вам нужно более детально различать причины 403 для клиента, это уже вопрос дизайна error contract и общей стратегии ошибок API. Но базовая дисциплина начинается с того, что 403 всегда выглядит как нормальный JSON-ответ, а не как “рандомный сюрприз от фреймворка”.
6. Типичные ошибки при настройке AccessDeniedHandler
Ошибка №1: путаница между 401 и 403.
Самая частая ошибка — перепутать смыслы и начать возвращать 401 там, где нужен 403. Если пользователь уже успешно аутентифицирован, но не проходит правило (роль, authority или другой запрет), 401 будет врать клиенту, заставляя его думать, что проблема в логине. Это ломает UX и усложняет отладку, потому что клиент начнёт “перелогиниваться”, хотя ему просто не дадут доступ никогда.
Ошибка №2: забыли выставить Content-Type: application/json.
Кажется мелочью, но некоторые клиенты и прокси ведут себя по-разному в зависимости от типа контента, а фронтенд-код может попытаться парсить ответ как JSON и упасть, если content type не соответствует ожиданиям. Когда вы делаете REST-friendly контракт, такие “мелочи” и составляют разницу между “работает у меня” и “работает у всех”.
Ошибка №3: писать в ответ ex.getMessage() (или сериализовать exception целиком).
Внутренние сообщения исключений могут содержать технические детали, могут меняться между версиями и иногда раскрывают то, что наружу лучше не отдавать: названия внутренних прав, куски конфигурации, иногда — детали механизма защиты. Для публичного API лучше иметь короткий и предсказуемый текст, а подробности оставлять в логах.
Ошибка №4: handler написали, но не подключили в HttpSecurity.exceptionHandling(...).
Аннотация @Component сама по себе не гарантирует, что Spring Security начнёт использовать ваш компонент. Он станет bean’ом, да, но пока вы не указали его в конфигурации, Spring Security продолжит использовать дефолтный handler. Это очень “по-спринговски”: наличие bean’а не означает его автоматическое применение, если вы не попали в нужную точку wiring.
Ошибка №5: смешивать response.getOutputStream() и response.getWriter() в одном ответе.
В servlet API нельзя смешивать эти способы вывода для одного ответа. Если вы начали писать через OutputStream, продолжайте через него. Иначе получите исключения или обрезанный ответ, а потом будете полдня искать “почему JSON иногда пустой”. Здесь лучше выбрать один способ (в нашем примере objectMapper.writeValue(response.getOutputStream(), ...)) и держаться его стабильно.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ