JavaRush /Курсы /Spring Security /Cross-origin конфиг SCP API

Cross-origin конфиг SCP API

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

1. Проблема: «в браузере не работает» — разные причины

Когда вы впервые подключаете к своему API какой-нибудь “временный фронтенд” (обычно это http://localhost:5173, http://localhost:3000 или что-то похожее), мозг разработчика очень быстро проходит стадии: отрицание, злость, торг, .csrf().disable() и принятие. Стадия с отключением всего подряд — самая опасная: она делает зелёной вкладку Network, но превращает безопасность в декоративную наклейку “secured”.

К этому месту мы уже по отдельности увидели три разные причины одного и того же симптома “в браузере не работает”: preflight может упасть на CORS, JSESSIONID может не уехать из-за cookie-политики, а state-changing запрос — из-за отсутствующего CSRF token. Сейчас не появляется ещё одна security-модель. Мы просто собираем один рабочий baseline: UI на http://localhost:5173, API на http://localhost:8080, session-based auth и локальный профиль, где session cookie не помечена Secure.

Главная сложность тут в том, что слово “не работает” прячет несколько разных уровней отказа. Иногда запрос вообще не уходит из браузера (preflight не прошёл). Иногда запрос уходит и даже получает ответ, но браузер не отдаёт ответ вашему JS из-за CORS. Иногда cookie не отправилась, и вы не аутентифицированы. Иногда вы аутентифицированы, но CSRF token не передан, и сервер честно отвечает 403. Если не научиться различать эти случаи, хочется “лечить всё одной кнопкой”, а это обычно кнопка Disable.

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

Ментальная модель: CORS, cookies, auth и CSRF

Важно поймать ощущение, что у нас не один “рубильник безопасности”, а несколько механизмов, которые стоят на разных этажах. CORS живёт в браузере. Cookies живут в браузере и частично управляются сервером через атрибуты. Authentication/authorization живёт в Spring Security (filter chain). CSRF тоже живёт в Spring Security, но он завязан на cookie/session модель и state-changing запросы.

Удобно представлять это как “коридор проверки”, где запрос может отвалиться на любом шаге. И если вы не знаете, где именно он отвалился, вы начинаете ругаться на всё сразу — на CORS, на контроллер, на Spring, на браузер, на фазу Луны и на коллегу, который “вчера работало”.

Ниже — простая схема того, что мы сегодня реально собираем (без лишней философии и без будущих тем):

flowchart TD
    %% Слои: браузер (CORS/cookies) -> Spring Security (auth/CSRF) -> приложение
    A["JS-код в браузере<br/>origin = http://localhost:5173"] --> B["Preflight OPTIONS (иногда)"]
    B -->|CORS headers OK| C["Основной запрос GET/PATCH/..."]
    C --> D["Cookie-policy браузера<br/>(SameSite/Secure)"]
    D -->|cookie ушла| E["Spring Security: Authentication"]
    E -->|есть session| F["Spring Security: CSRF (для PATCH/POST/DELETE)"]
    F -->|token ok| G["Controller / Service"]

Если где-то выше всё сломалось — до контроллера ваш запрос может даже не добраться. А если он добрался, но браузер заблокировал доступ к ответу — в логах сервера может быть “всё нормально”, а в браузере “CORS error”. И это не противоречие.

3. Цель: минимальная матрица запросов из браузера

Чтобы не сделать конфигурацию “на все случаи жизни” (а потом не забыть, что мы её сделали), полезно зафиксировать: какие запросы из браузера мы хотим, чтобы реально работали в нашем Secure Content Platform API на текущем checkpoint’е session-based безопасности.

Ниже — практическая мини-таблица, которая дисциплинирует мозг лучше, чем любые лозунги. Мы не проектируем enterprise security platform, мы делаем учебный, но честный baseline.

Сценарий Запрос Нужен ли credentials (cookies) Нужен ли CSRF token Что важно в CORS
Публичное чтение GET /api/public/articles нет (обычно) нет разрешить origin, методы GET
Bootstrap CSRF GET /csrf да (обычно, чтобы создать/привязать сессию) нет разрешить origin + credentials
Личное чтение GET /api/me/profile да нет origin + credentials
Изменение профиля PATCH /api/me/profile да да (X-CSRF-TOKEN) origin + credentials + разрешить заголовок X-CSRF-TOKEN

Во всех строках ниже держим именно этот local baseline. Ветка с SameSite=None + Secure относится к отдельному HTTPS/cross-site deployment-сценарию и здесь не подменяет основной пример.

Фишка тут в том, что CORS-конфигурация и security rules должны быть согласованы с этой таблицей. Если вы разрешили PATCH в security-правилах, но забыли разрешить X-CSRF-TOKEN в CORS — браузер не даст вам нормально сделать запрос. А если вы разрешили CORS “на всё”, но забыли allowCredentials(true) — cookie не поедет, и вы будете “вечно anonymous”, даже если логинились.

4. CORS-политика: CorsConfigurationSource

Когда CORS настраивают “на глаз” прямо в SecurityFilterChain, очень быстро получается конфиг в стиле “тут строка, там строка, где-то ещё аннотация @CrossOrigin, а потом мы забыли, что было где”. Поэтому на практике удобно сделать один отдельный компонент, который отвечает за CORS-политику как за нормальную часть конфигурации приложения.

В Spring/Spring Security для этого отлично подходит пара CorsConfiguration и CorsConfigurationSource. Мы описываем allowed origins, methods, headers и включаем allowCredentials(true), если хотим, чтобы браузер мог отправлять cookies в cross-origin запросах. И тут же важная ловушка: если allowCredentials(true), то allowedOrigins = "*", то есть “всем всё”, — нельзя. Нужны конкретные origin’ы.

В нашем local baseline allowed origin один — http://localhost:5173. Поэтому не плодим второй вариант конфигурации, а берём тот же CorsConfigurationSource, который уже описывает эти правила для /api/** и /csrf.

import org.springframework.context.annotation.Bean;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;

import java.util.List;

@Bean
UrlBasedCorsConfigurationSource corsConfigurationSource(CorsProps props) {
    CorsConfiguration config = new CorsConfiguration();

    // CorsProps берёт allowed origin'ы из application.yml, поэтому не хардкодим их ещё раз
    config.setAllowedOrigins(props.allowedOrigins());
    config.setAllowedMethods(List.of("GET", "POST", "PATCH", "DELETE"));
    config.setAllowedHeaders(List.of("Content-Type", "X-CSRF-TOKEN"));
    config.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    source.registerCorsConfiguration("/csrf", config);

    return source;
}

Здесь ничего принципиально нового не происходит: тот же источник CORS потом просто подключается к SecurityFilterChain.

5. CORS в SecurityFilterChain и preflight

Когда вы уже сделали CorsConfigurationSource, остаётся важнейший шаг: подключить CORS к Spring Security, иначе у вас может быть ощущение “я настроил CORS, но браузер всё равно ругается”. Причина обычно в том, что в одном месте вы сделали MVC-уровень, а в другом — Security-уровень, и они живут как два соседа, которые здороваются, но ключи друг другу не дают.

В нашем проекте явный SecurityFilterChain просто берёт уже существующий corsSource, а не изобретает вторую CORS-конфигурацию внутри цепочки. А ещё мы явно разрешаем preflight-запросы. Самый читаемый вариант — пропускать всё, что CorsUtils.isPreFlightRequest(...).

import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.CorsUtils;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
                                        CorsConfigurationSource corsSource) throws Exception {
    // 1) Подключаем CORS-политику к Spring Security (без этого заголовки могут не появляться)
    http.cors(cors -> cors.configurationSource(corsSource));

    http.authorizeHttpRequests(auth -> auth
            // 2) Preflight OPTIONS — это не "бизнес-запрос", его пропускаем всегда
            .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()

            // 3) Публичные эндпоинты — без логина
            .requestMatchers("/api/public/**").permitAll()

            // 4) Всё остальное — только для аутентифицированных
            .anyRequest().authenticated()
    );

    return http.build();
}

Здесь мы пока показали только “сердцевину”: включение CORS и разрешение preflight, плюс public зона. На реальном проекте у вас уже есть formLogin, logout, и возможно другие настройки. Мы их соберём в следующем разделе.

Почему мы вообще так заботимся об OPTIONS? Потому что preflight — это не ваш бизнес-запрос. Браузер задаёт вопрос: “а можно я потом сделаю PATCH с такими-то заголовками?”. Если вы начнёте требовать на этот вопрос session-аутентификацию или пытаться применять ваши обычные access rules, вы получите очень странный UX: “я даже залогинился, но браузер говорит CORS error”. И да, это будет ощущаться как мистика, пока вы не вспомните, что OPTIONS — отдельный запрос.

6. CSRF bootstrap и финальная SecurityFilterChain

CSRF bootstrap через /csrf

Когда у вас появляется отдельный браузерный клиент, который делает state-changing запросы, он должен где-то взять CSRF token. Для нашего API контракт /csrf уже простой: {headerName, token}. Этого достаточно, чтобы клиент понял, какой заголовок отправить обратно и какое значение туда положить.

Здесь важны две вещи. Во-первых, GET /csrf должен быть доступен без логина, иначе клиент не сможет нормально начать диалог. Во-вторых, на /csrf должен распространяться тот же CORS-источник, что и на основной API, иначе browser bootstrap сломается ещё до первого PATCH.

Итоговая SecurityFilterChain

Сейчас мы соберём всё в один рабочий, читаемый фрагмент. Важно удержать конфигурацию короткой: ваша цель — чтобы через месяц вы открыли SecurityConfig и за 30 секунд поняли, что тут происходит. Если конфигурация выглядит как “всё и сразу”, то это не фундаментальность — это просто будущая боль поддержки.

Ниже — пример “итоговой” цепочки на конец дня 12, где мы явно включили CORS, оставили CSRF включённым (то есть не капитулировали), разрешили preflight, сделали public зону, открыли /csrf и оставили обычную session-based модель с formLogin и logout.

import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.CorsUtils;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http,
                                        CorsConfigurationSource corsSource) throws Exception {
    // CORS: включаем поддержку заголовков Access-Control-Allow-*
    http.cors(cors -> cors.configurationSource(corsSource));

    http.authorizeHttpRequests(auth -> auth
            // Preflight OPTIONS всегда должен проходить, иначе будет "CORS error" ещё до основного запроса
            .requestMatchers(CorsUtils::isPreFlightRequest).permitAll()

            // Публичная зона
            .requestMatchers("/api/public/**").permitAll()

            // Bootstrap CSRF токена: клиент должен иметь доступ к /csrf, чтобы получить {headerName, token}
            .requestMatchers(HttpMethod.GET, "/csrf").permitAll()

            // Остальное — только после логина
            .anyRequest().authenticated()
    );

    // Session-based модель: логин и логаут доступны без аутентификации
    http.formLogin(form -> form.permitAll());
    http.logout(logout -> logout.permitAll());

    return http.build();
}

Обратите внимание на важную “психологическую” деталь: мы не открывали всё подряд, не писали "/**".permitAll(), не отключали CSRF, и при этом создали реальную основу для браузерной интеграции. Публичная зона открыта всем, технический /csrf открыт для bootstrap, всё остальное требует аутентификации, как и должно быть в приложении, у которого есть личная зона.

7. Диагностика сбоев из браузера по слоям

Когда вы собрали конфигурацию, следующий навык — научиться диагностировать сбои послойно, а не “в целом”. Иначе вы неизбежно придёте к подходу: “раз не работает, выключим CORS/CSRF/всю security” — это, конечно, тоже способ, но он примерно как чинить замок, сняв дверь.

Хороший debugging-алгоритм начинается с вопроса: “Где именно мы потеряли запрос?”. Сначала смотрим на preflight и CORS заголовки. Потом проверяем, ушла ли cookie. Потом понимаем, что вернул сервер: 401/302 (не аутентифицирован) или 403 (CSRF/доступ). На этом этапе очень помогают и DevTools в браузере, и “честный” curl, потому что curl не делает CORS-проверок — и этим он полезен как “контрольный эксперимент”.

Вот простой ориентир, который можно держать как шпаргалку:

flowchart TD
    A["Браузер ругается"] --> B{"Есть preflight OPTIONS?"}
    B -->|Да| C["Проверь ответ OPTIONS:<br/>Access-Control-Allow-*"]
    B -->|Нет| D["Проверь основной запрос"]
    C --> E{"Preflight 2xx?"}
    E -->|Нет| F["CORS конфиг / security rules не пропускают OPTIONS"]
    E -->|Да| D
    D --> G{"Cookie ушла?"}
    G -->|Нет| H["allowCredentials / SameSite / Secure / origin mismatch"]
    G -->|Да| I{"Ответ сервера 401/302 или 403?"}
    I -->|401/302| J["Не аутентифицирован: нет session или не прошёл login"]
    I -->|403| K["CSRF token отсутствует/неверен или запрет доступа"]

И чтобы “ощупать” CORS без браузера, можно сделать preflight руками через curl. Это выглядит так (обратите внимание на заголовки Origin, Access-Control-Request-Method, Access-Control-Request-Headers):

# Preflight-запрос: браузер именно так "спрашивает разрешение" перед PATCH/POST с нестандартными заголовками
curl -i -X OPTIONS "http://localhost:8080/api/me/profile" \
  -H "Origin: http://localhost:5173" \
  -H "Access-Control-Request-Method: PATCH" \
  -H "Access-Control-Request-Headers: content-type,x-csrf-token"

Если в ответе нет Access-Control-Allow-Origin и Access-Control-Allow-Headers, браузер потом не даст сделать основной запрос. Если ответ — 401/302, значит preflight попал под обычную security-логику и вы его не пропустили.

Дальше, когда preflight нормальный, проверяете /csrf. Если вы хотите, чтобы cookie создавалась и сохранялась, то запрос должен выполняться “с credentials” на стороне клиента (в JS это credentials: "include"). Но даже без фронтенда вы можете увидеть Set-Cookie: JSESSIONID=... в ответе сервера и понять, что сессия создаётся.

И наконец, для PATCH вы ждёте 403, если CSRF токен не передали, и 2xx, если передали. На этом этапе очень важно не перепутать “403 из-за CSRF” и “403 из-за роли” (роль-ограничения мы на этом дне не усложняем, но привычка различать причины 403 вам очень пригодится позже).

8. Типичные ошибки при сборке cross-origin конфигурации

Ошибка №1: разрешён * в origins при включённых credentials.
Как только вы включаете allowCredentials(true), вы больше не можете отвечать Access-Control-Allow-Origin: *. Браузер просто не примет такой ответ. В итоге разработчик думает, что “Spring Security что-то блокирует”, хотя это базовое правило CORS.

Ошибка №2: preflight (OPTIONS) попадает под обычные security правила и получает 401/302.
Снаружи это выглядит особенно обидно: вы уже сделали allowed origins, allowed methods, но браузер всё равно ругается. Причина в том, что preflight — это отдельный запрос, и его нужно пропустить. Самый читаемый вариант — requestMatchers(CorsUtils::isPreFlightRequest).permitAll().

Ошибка №3: не разрешён X-CSRF-TOKEN в allowedHeaders.
Сервер может быть готов принять CSRF token, но браузер не позволит вашему JS отправить заголовок, если preflight не получил Access-Control-Allow-Headers с этим именем. Итог — опять “CORS error”, хотя первопричина в том, что вы забыли один заголовок.

Ошибка №4: попытка «отключить CORS в Spring Security», чтобы CORS исчез.
CORS не живёт в Spring Security. Он живёт в браузере. Если вы “выключили CORS на сервере”, вы всего лишь перестали отправлять CORS-заголовки, и браузер теперь строже блокирует доступ к ответу. То есть вы не отключили проблему, вы отключили поддержку решения.

Ошибка №5: отключение CSRF ради того, чтобы заработал PATCH.
Да, так действительно “заработает”. Это как вынуть батарейку из пожарной сигнализации, потому что она орёт. На текущем checkpoint’е курса CSRF — часть модели session/cookie безопасности, и мы учимся с ним жить: делаем /csrf, передаём токен, держим правила явными. Отключение без понимания ломает саму цель модуля.

1
Задача
Spring Security, 12 уровень, 4 лекция
Недоступна
Публичная зона, `/csrf` и state-changing запрос с CORS
Публичная зона, `/csrf` и state-changing запрос с CORS
1
Задача
Spring Security, 12 уровень, 4 лекция
Недоступна
Public и private зоны с form login и preflight
Public и private зоны с form login и preflight
1
Опрос
CORS политика, 12 уровень, 4 лекция
Недоступен
CORS политика
Браузерные правила и заголовки
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ