JavaRush /Курсы /Spring Security /Spring Security и CSRF tok...

Spring Security и CSRF token

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

1. От «залогинен» к «запрос мой»

Когда впервые сталкиваешься с CSRF, очень хочется сказать: «Но я же аутентифицирован! Я же вошёл! Зачем ещё какие-то токены?». Это нормальная реакция: мозг видит одну проверку (логин/пароль) и ожидает, что дальше всё автоматически безопасно. Но CSRF — про другое: сервер видит вашу сессию, но не понимает, кто инициировал действие — вы в «нормальном» интерфейсе или кто-то, кто заставил браузер отправить запрос.

В session-based мире браузер ведёт себя как очень услужливый официант: вы один раз показали ему «бейджик клиента» (cookie с идентификатором сессии), и дальше он этот бейджик прикладывает к каждому заказу, даже если заказ сделали не вы, а кто-то крикнул из-за угла «Эй, официант, отнеси вот это на столик №7». Сервер на столике №7 видит бейджик и думает: «О, мой постоянный клиент!» — и вот тут начинается веселье.

CSRF-защита добавляет вторую проверку: помимо cookie, нужно ещё дополнительное непредсказуемое значение, которое нормальный интерфейс умеет отправлять, а злоумышленник — нет.

2. Synchronizer Token Pattern: ожидаемое vs фактическое

Сразу хорошие новости: вам не нужно помнить весь каталог фильтров Spring Security или читать RFC на ночь вместо сказок. Достаточно понять базовый паттерн, который почти всегда стоит за CSRF в классических веб-приложениях: Synchronizer Token Pattern.

В этом паттерне есть три главных идеи.

Первая идея: сервер хранит «ожидаемый токен» у себя, обычно рядом с сессией пользователя.

Вторая идея: клиент должен прислать этот токен обратно в запросе, но не в cookie, а в таком месте запроса, которое браузер «сам по себе» не прикладывает автоматически, если запрос пришёл с чужого сайта.

Третья идея: сервер сравнивает «ожидаемый» и «фактический» токен. Совпало — запрос считается частью корректного пользовательского сценария. Не совпало — запрос блокируется ещё до контроллера.

Удобно представить это как мини-диалог:

sequenceDiagram
    participant B as Browser
    participant S as "Server (Spring Security)"

    B->>S: "GET /login (cookie может быть или нет)"
    S->>S: генерирует CSRF token и сохраняет в session
    S-->>B: "HTML страницы с hidden input _csrf=... (или иной способ передачи)"

    B->>S: POST /api/me/profile (cookie + CSRF token)
    S->>S: сравнивает ожидаемый token из session с token из запроса
    alt token совпал
        S-->>B: "200 OK (контроллер выполняется)"
    else token отсутствует/не совпал
        S-->>B: "403 Forbidden (контроллер не запускается)"
    end

Ключевой момент: CSRF — это не дополнительная “роль” и не “ещё один пароль”. Это проверка «в сценарии ли я сейчас?», «пришёл ли запрос так, как должен приходить запрос из нашего интерфейса».

3. CSRF в Spring Security: CsrfFilter

Теперь спускаемся на один уровень ниже: где эта проверка технически находится. Мы уже обсуждали, что Spring Security живёт в filter chain, то есть до DispatcherServlet и до ваших контроллеров. CSRF — не исключение. В Spring Security есть фильтр CsrfFilter, который отвечает за CSRF-проверку.

Это важно не для «знания названий», а для бытовой диагностики. Если CSRF не прошёл, вы можете сидеть и смотреть на свой контроллер, на DTO, на валидацию, на сервис… и не понимать, почему «метод даже не вызывается». А он реально может не вызываться: запрос отрезали раньше.

Если упростить путь запроса, получается такая картинка:

flowchart TD
    A[HTTP request] --> B[Security filter chain]
    B --> C[CsrfFilter]
    C -->|OK| D[DispatcherServlet]
    D --> E["@RestController"]
    C -->|Fail| F[Access Denied / 403]

По умолчанию Spring Security не требует CSRF-токен для GET, HEAD, TRACE и OPTIONS, а проверяет остальные методы. Поэтому нормальная HTTP-семантика здесь не “для красоты”: CsrfFilter реально опирается на неё.

И вот тут появляется важный практический вывод: CSRF — это инфраструктурная проверка, а не бизнес-правило. Её место — в security-слое, а не в контроллере.

Если вы поймали CSRF-отказ, вы не должны «чинить» это добавлением if (token.equals(...)) в каждом эндпоинте. Во-первых, это больно (и быстро станет багом). Во-вторых, вы просто повторяете то, что framework уже делает правильно и централизованно.

4. CsrfToken: контракт передачи

Теперь познакомимся с сущностью, которая в Spring Security представляет CSRF token: это интерфейс CsrfToken. И он важен тем, что токен в Spring — это не «просто случайная строка», а объект, у которого есть метаданные: как именно его надо передавать.

У CsrfToken обычно есть как минимум три вещи:

— само значение токена (строка);

— имя HTTP-заголовка, в котором можно передать токен;

— имя параметра, в котором можно передать токен (например, как hidden field в HTML-форме).

То есть Spring заранее говорит: «Вот token. И вот два легальных канала, по которым я готов его принимать».

В учебном проекте полезно сделать маленький debug endpoint, который покажет эти имена. Обратите внимание: мы покажем имена, а не значение токена, чтобы случайно не превратить debug endpoint в “раздатчик” секретов. Для локальной диагностики это обычно достаточно.

import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.Map;

@RestController
class CsrfDebugController {

    @GetMapping("/debug/csrf")
    Map<String, String> csrf(CsrfToken token) {
        // Важно: не возвращаем само значение токена, чтобы случайно не раскрыть секрет.
        // Показываем только "как передавать": имя заголовка и имя параметра.
        return Map.of(
                // Имя HTTP-заголовка, в который Spring Security разрешает класть CSRF token
                "headerName", token.getHeaderName(),
                // Имя параметра формы/query, в который Spring Security разрешает класть CSRF token
                "parameterName", token.getParameterName()
        );
    }
}

Для обычного browser-flow этого достаточно: увидеть, по какому header или parameter Spring вообще ждёт token. Если нужна ручная диагностика через curl или Postman, этот endpoint можно временно расширить в local-only варианте и добавить само значение токена. Это уже не часть продуктового API, а просто инструмент, чтобы руками увидеть связь token + session.

Этот пример заодно показывает удобную фишку: Spring MVC умеет инжектить CsrfToken прямо параметром метода. Вы его не создаёте и не парсите — это инфраструктурный объект, который становится доступным в рамках запроса.

5. Хранилище токена: CsrfTokenRepository

Пришло время ответить на главный вопрос из реальной жизни: «Окей, токен есть. А где он живёт?». В Spring Security за это отвечает CsrfTokenRepository. Название очень прямолинейное: это “репозиторий” токена (не путать с JPA-репозиториями; тут слово в более общем смысле — хранилище).

Репозиторий отвечает за три вещи, и они идеально укладываются в наш mental model из Synchronizer Token Pattern. Он должен уметь загрузить ожидаемый токен из хранилища, сгенерировать новый токен, если нужно, и сохранить токен обратно.

Чтобы это “не висело в воздухе”, можно представить контракт репозитория в виде мини-таблицы:

Что нужно сделать Зачем Где это в Spring Security
Загрузить ожидаемый токен Понять, какой токен должен быть у этого пользователя loadToken(...)
Сгенерировать новый токен Если токена ещё нет (новая сессия) generateToken(...)
Сохранить токен Привязать токен к сессии/контексту saveToken(...)

В рамках сегодняшнего дня (и всей stateful-ветки курса) нас интересует стандартный сценарий: хранить ожидаемый токен в HttpSession. Для этого есть готовая реализация: HttpSessionCsrfTokenRepository.

Она делает ровно то, что вы интуитивно ожидаете от session-based мира: ожидаемый CSRF token хранится вместе с другими session-данными. Браузер присылает cookie с session id, сервер по нему находит сессию, и внутри неё лежит «ожидаемое значение».

Сделаем это решение явным в проекте:

import org.springframework.context.annotation.Bean;
import org.springframework.security.web.csrf.CsrfTokenRepository;
import org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository;

@Bean
CsrfTokenRepository csrfTokenRepository() {
    // Храним "ожидаемый" CSRF token в HttpSession:
    // это и есть классический synchronizer token pattern для session-based приложений.
    return new HttpSessionCsrfTokenRepository();
}

Да, в большинстве приложений это и так будет работать «само», но в учебном проекте нам важно, чтобы решение было на виду. Вы должны уметь открыть конфигурацию и увидеть: «ага, в этом чекпоинте CSRF token живёт в session».

6. Настройка CSRF в SecurityFilterChain

Новички часто воспринимают CSRF как “ещё одна галочка в конфиге”. На самом деле это важная часть вашей модели безопасности: она отвечает за то, что state-changing запросы действительно принадлежат корректному browser-flow. Поэтому мы не прячем CSRF где-то «в магии по умолчанию», а явно указываем поведение в SecurityFilterChain.

Даже если вы не меняете дефолтное поведение Spring Security, явная настройка помогает вам читать конфигурацию как документ: «вот правила доступа, вот login/logout, вот CSRF защита, вот хранилище токена».

Пример минимальной “подсветки” CSRF в SecurityFilterChain:

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.csrf.CsrfTokenRepository;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http, CsrfTokenRepository csrfRepo) throws Exception {
    // Явно указываем, где Spring Security должен хранить/искать CSRF token.
    // Это помогает читать конфиг как документ и проще диагностировать проблемы.
    http.csrf(csrf -> csrf.csrfTokenRepository(csrfRepo));

    // Собираем filter chain; CsrfFilter будет работать ДО контроллеров.
    return http.build();
}

Обратите внимание на одну важную вещь: мы не пишем здесь .csrf(csrf -> csrf.disable()). Для сегодняшнего дня это как “лечить” сигнализацию в машине тем, что вы выкинули батарейку из брелка. Да, пищать перестанет. Но радость будет недолгой.

7. Browser-flow: GET выдаёт, POST отправляет

Теперь самое “жизненное”: как этот токен вообще попадает в запрос, если мы не хотим вручную копировать его из воздуха. В классическом browser-flow это происходит естественно: на GET-запрос сервер отдаёт HTML-страницу с формой, и в этой форме есть hidden field с CSRF token. Пользователь нажимает кнопку, браузер отправляет POST — и вместе с данными формы уезжает CSRF token.

Это важный момент: браузер не “придумывает” CSRF token сам. Он просто отправляет то, что ваша страница (ваш сервер) ему дала, потому что это часть формы. Злоумышленник не может прочитать эту страницу и украсть токен из неё (в нормальной модели), потому что мешает политика same-origin и то, что токен находится “внутри” вашего приложения.

Даже если вы не пишете HTML сами, Spring Security умеет демонстрировать этот flow через дефолтную login page. Именно поэтому в учебных целях formLogin так полезен: он заставляет CSRF стать видимым, а не оставаться абстрактной “защитой где-то там”.

Если представить это в двух шагах, получится простая схема:

flowchart TD
    A[GET страница/форма] --> B[Server генерирует token и сохраняет в session]
    B --> C["HTML содержит token (hidden input)"]
    C --> D[POST/PATCH/DELETE с token]
    D --> E[CsrfFilter сравнивает и пропускает дальше]

Здесь нам важен базовый browser-flow. Этого достаточно, чтобы увидеть механику: токен существует, связан с сессией, и в обычном form-based сценарии попадает в запрос потому, что ваше приложение само отдало его клиенту.

8. Session timeout и CSRF token

Один из самых раздражающих (и поэтому запоминающихся) сценариев выглядит так: вы открыли приложение, залогинились, посидели минут 20, отвлеклись… потом вернулись и жмёте “сохранить профиль” или “удалить черновик”, и вдруг получаете CSRF-ошибку. В голове сразу возникает мысль: «Да Spring Security сломался!».

Но чаще всего Spring Security не сломался. Скорее всего, истекла HttpSession. А раз токен у нас хранится в session-based хранилище (HttpSessionCsrfTokenRepository), то вместе с сессией исчез и “ожидаемый токен”. Браузер при этом может всё ещё отправлять cookie JSESSIONID, но сервер уже не знает такую сессию и создаёт новую. В новой сессии — новый CSRF token.

Получается типичная картина: клиент отправил токен, который он «держит» со старого UI-экрана, а сервер ожидает другой токен, потому что у него уже другая сессия. Это не «случайность» и не “каприз”. Это логичное следствие того, что токен живёт рядом с session state.

Если вы любите таймлайны (а их любят даже те, кто говорит, что ненавидит диаграммы), то можно представить это так:

sequenceDiagram
    participant B as Browser
    participant S as Server

    B->>S: GET /login
    S-->>B: HTML + token T1 (session S1)
    Note over B,S: пользователь ушёл пить чай, session S1 истекла

    B->>S: PATCH /api/me/profile + cookie JSESSIONID + token T1
    S->>S: session S1 не найдена → создаём S2, ожидаем token T2
    S-->>B: 403 (T1 != T2)

И это ещё одна причина, почему CSRF не стоит «лечить отключением». Лучше понимать, что именно произошло: сессия — это состояние, оно может истекать, и CSRF token закономерно следует за ним.

9. Типичные ошибки при работе с CSRF

Ошибка №1: глобально отключить CSRF при первой же проблеме.
Это самый частый рефлекс: “оно мешает — значит отключим”. На учебном проекте это почти всегда означает, что вы просто потеряли понимание механизма. В реальном продукте — что вы выкинули защиту, которая закрывала именно browser/session-угрозу. Корректнее остановиться и спросить себя: запрос state-changing? клиент — браузер? аутентификация идёт через cookie? если да, CSRF здесь не случайный барьер, а часть модели.

Ошибка №2: пытаться проверять CSRF token руками в каждом контроллере.
Такой код быстро превращается в “security spaghetti”: в одном контроллере проверили так, в другом забыли, в третьем сравнили не то поле. Spring Security уже делает сравнение централизованно в CsrfFilter, и ценность framework как раз в том, что вы не повторяете инфраструктурную логику сотней копипаст.

Ошибка №3: забыть, что CSRF token «работает» только вместе с той же session.
Очень часто студенты пытаются отправить state-changing запрос из Postman или curl, но прикладывают только token, забывая, что token ожидается внутри конкретной session. Если сессионная cookie не та, или вы случайно создали новую session — token будет “неверным” при любом раскладе, даже если он выглядит «правильно».

Ошибка №4: считать CSRF частью ролей/authorities.
CSRF не отвечает на вопрос “можно ли пользователю?”. Он отвечает на вопрос “из правильного ли сценария пришёл запрос?”. Эти проверки не конкурируют, они дополняют друг друга. Вы можете быть ADMIN, но если запрос на изменение состояния прилетел без токена — он всё равно подозрителен в browser/cookie модели.

Ошибка №5: делать state-changing операции через GET.
Это не просто «плохой стиль». Это ломает фундамент: безопасные методы (GET, HEAD, OPTIONS) считаются read-only и часто не подпадают под CSRF-проверку. Если вы спрятали “delete” в GET /api/drafts/123/delete, вы одновременно усложнили систему и создали угрозы, которые потом очень трудно объяснять и чинить.

1
Задача
Spring Security, 11 уровень, 2 лекция
Недоступна
Явная session-based CSRF-конфигурация
Явная session-based CSRF-конфигурация
1
Задача
Spring Security, 11 уровень, 2 лекция
Недоступна
Один token на одну session
Один token на одну session
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ