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, передаём токен, держим правила явными. Отключение без понимания ломает саму цель модуля.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ