1. Порядок правил — это поведение
Когда вы впервые смотрите на authorizeHttpRequests, очень хочется думать так: «Ну это же просто список правил… какая разница, в каком порядке они написаны? Они ведь все “одновременно”». Увы, Spring Security не телепат. Он не читает ваши намерения — он читает конфигурацию сверху вниз. И если вверху стоит правило, которое подходит почти ко всему, до нижних правил дело просто не дойдёт. Это не баг и не каприз фреймворка, а нормальная модель работы систем безопасности: сначала проверяются более ранние и более приоритетные правила.
К этому моменту у нас уже есть все кирпичики: мы умеем делить API на зоны через requestMatchers, отличать authenticated() от более строгих проверок и писать отдельные правила для ролей или полного запрета. Теперь важно собрать это в одну карту без случайных дыр.
Проблема становится очень практичной, как только у вас появляется публичная зона внутри общего пространства API. Например, /api/public/** — это открытая часть, а /api/** — в целом приватная. Если вы случайно поставите «общий барьер» раньше, вы закроете публичную часть, и никто не сможет читать статьи без логина. И наоборот: если поставите «общий допуск для вошедших» раньше, чем «только админ», то админка станет доступна любому аутентифицированному пользователю. Это как повесить табличку «служебный вход» на дверь супермаркета… а потом удивляться, почему у вас в подсобке толпа.
Чтобы было совсем честно: ошибки порядка — это тот тип ошибок, который особенно неприятен новичкам. Код выглядит «логично», компилируется, приложение запускается, а реальное поведение не совпадает с тем, что вы ожидали. Поэтому сегодня мы не просто «напишем матрицу», а научимся читать её глазами Spring Security.
2. «Первое совпадение решает»
Один из лучших способов перестать бояться Spring Security — представить, что authorizeHttpRequests создаёт список правил, и каждое правило — это пара: «matcher» + «решение». В рантайме приходит запрос, и Spring Security пробует правила по очереди. Первое правило, чей matcher «подошёл», и будет применено. Остальные правила ниже уже не рассматриваются, даже если они «более правильные» по вашему замыслу. Это очень похоже на то, как работают файрволы, роутеры и даже охранник в клубе, у которого есть один лист бумаги с условиями входа (и он не обязан дочитывать до конца, если уже нашёл подходящую строку).
Если эту идею выразить псевдокодом (без углубления во внутренние классы Spring Security), получится примерно так:
// Упрощённая модель: правила проверяются строго сверху вниз
for (Rule rule : rules) {
// Если matcher подошёл — дальше уже не смотрим, это и есть "первое совпадение"
if (rule.matches(request)) {
// Решение может быть permitAll / authenticated / hasRole / denyAll ...
return rule.decision();
}
}
// Если ничего не совпало — срабатывает дефолтная политика (зависит от конфигурации)
return defaultDecision;
Давайте визуализируем это ещё проще — как мини-блок-схему:
flowchart TD
A["HTTP request приходит в приложение"] --> B{"Rule #1 matcher подходит?"}
B -- да --> R1["Применяем решение #1 и останавливаемся"]
B -- нет --> C{"Rule #2 matcher подходит?"}
C -- да --> R2["Применяем решение #2 и останавливаемся"]
C -- нет --> D{"Rule #3 matcher подходит?"}
D -- да --> R3["Применяем решение #3 и останавливаемся"]
D -- нет --> E["Доходим до anyRequest / default"]
Теперь ключевой вывод звучит почти как мем, но он абсолютно инженерный: побеждает не «самое строгое» правило, а первое совпавшее. Поэтому порядок — это не косметика, а часть логики. И раз уж мы сами пишем SecurityFilterChain, то сами же отвечаем за то, чтобы этот порядок отражал нашу access matrix.
3. Ошибки matcher-ов и открытая админка
Самая частая ошибка порядка выглядит невинно: вы ставите «общий барьер» раньше «исключения». Например, вы хотите: «всё API закрыто, кроме public». И пишете так:
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
// Ошибка: слишком широкий matcher стоит выше исключения
// В результате /api/public/** тоже попадает под /api/** и требует логин
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.requestMatchers("/api/public/**").permitAll()
);
На человеческом языке вы будто сказали: «всё API требует входа, но public — исключение». А на языке порядка проверок вы сказали: «если URL начинается с /api/, требуй аутентификацию». И всё. Потому что /api/public/** тоже начинается с /api/, значит первое правило совпало, и до permitAll() мы даже не дошли.
Правильный порядок для «исключения» — сначала исключение, потом общий барьер:
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
// Правильно: сначала более узкое правило-исключение, потом широкое правило
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll() // public должен отработать первым
.requestMatchers("/api/**").authenticated() // всё остальное под /api/ — только для вошедших
);
Эта ошибка неприятна, но есть ещё более коварная. Представим, что вы уже сделали «всё API для вошедших», а потом решили: «а админку надо только админам». И добавили строку ниже:
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
// Ошибка: правило "admin только для ADMIN" не сработает,
// потому что /api/** перехватит /api/admin/** раньше
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**").authenticated()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
);
Выглядит вполне разумно… пока вы не вспомните, что /api/admin/** тоже подпадает под /api/**. Значит, первое правило сработало раньше. И реальный эффект такой: админка доступна любому аутентифицированному пользователю. То есть вы как будто поставили на вход в серверную табличку «вход только по пропуску», а на дверь с надписью «директор» добавили «вход только директору», но забыли, что охранник смотрит на таблички сверху вниз и после первой проверки уже не читает вторую.
Правильный порядок тут такой: сначала «особо защищённые зоны», потом общий барьер:
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
// Правильно: сначала "VIP-зона", потом общий барьер
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/admin/**").hasRole("ADMIN") // самое строгое и самое узкое — выше
.requestMatchers("/api/**").authenticated() // общий доступ для вошедших — ниже
);
Здесь вы явно говорите: «админка — это отдельный VIP-вход», и только потом — «в остальное API можно, если ты просто вошёл».
И ещё один нюанс, который в реальном проекте спасает от очень странных дыр: закрывать хвост. Если вы не написали, что делать с остальными запросами, легко получить поведение «по умолчанию», которое сегодня нам совсем не хочется оставлять неявным. Поэтому мы любим финальную строку anyRequest() — как последнюю сетку на воротах.
4. Первая access matrix в коде
Сейчас мы сделаем то, ради чего все и затевался: возьмём зоны доступа Secure Content Platform API и соберём их в один читаемый блок. Важно, что эта матрица пока не про «реальных пользователей из БД» и не про «тонкие права на уровне отдельных операций». Это именно первый практичный слой: развести базовые зоны по URL так, чтобы приложение стало предсказуемо защищённым.
Давайте сначала запишем матрицу в виде таблицы. Это полезно не только студентам — это вообще хорошая инженерная привычка: сначала проектировать доступ как договорённость, а потом переносить её в DSL.
| Зона | Примеры URL | Решение |
|---|---|---|
| Public | /api/public, /api/public/** | permitAll() |
| Me (личная зона) | /api/me, /api/me/** | authenticated() |
| Editor | /api/editor/** | hasRole("EDITOR") |
| Admin | /api/admin/** | hasRole("ADMIN") |
| Всё остальное | anyRequest() | denyAll() |
Для зон public и me здесь специально перечислим и корневой endpoint, и вложенные пути. Так политика читается без догадок: если у зоны есть отдельная «точка входа» и есть поддерево URL, это видно прямо в конфиге.
Сам каркас цепочки сохраняем прежним: базовые способы входа остаются удобной учебной опорой для ручной проверки, а меняется уже сама access policy.
Теперь переносим это в SecurityFilterChain. Я покажу финальную версию — ту самую «матрицу в коде», — а затем поясню, почему порядок именно такой и чем полезен denyAll() внизу.
package com.example.securecontent.security.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Configuration
public class SecurityConfig {
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
// Матрица читается сверху вниз: первое совпадение останавливает проверку
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public", "/api/public/**").permitAll() // 1) public-зона доступна всем
.requestMatchers("/api/me", "/api/me/**").authenticated() // 2) личная зона — только для вошедших
.requestMatchers("/api/editor/**").hasRole("EDITOR") // 3) editor-зона — нужна роль EDITOR
.requestMatchers("/api/admin/**").hasRole("ADMIN") // 4) admin-зона — нужна роль ADMIN
.anyRequest().denyAll() // 5) хвост закрываем явно, чтобы не было "как получится"
)
// Оставляем базовые способы входа для ручной проверки правил
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults())
.build();
}
}
Почему этот порядок безопасен и читаем?
Во‑первых, мы явно вынесли публичную зону самой первой. Это психологически удобно: вы сразу видите, «что открыто миру». И это технически удобно: более узкая зона стоит выше потенциально более широких правил (например, если позже вы добавите общий /api/**). Во‑вторых, мы последовательно «сужаем доступ» по мере движения вниз: от public к authenticated, затем к privileged-зонам. Здесь важно не путаться: привилегированные зоны (/api/editor/**, /api/admin/**) не обязаны стоять ниже /api/me/** с точки зрения matcher’ов, потому что это разные поддеревья. Но в таком порядке их удобнее читать: конфигурация становится похожей на карту приложения.
Третье, и очень важное: anyRequest().denyAll() делает поведение предсказуемым. Если вы добавите новый контроллер и забудете прописать правило, он не станет «случайно доступным». Он станет явно недоступным, пока вы не примете решение и не впишете его в матрицу. Это прямое продолжение идеи secure-by-default: лучше «закрыто, пока не открыл осознанно», чем «открыто, пока случайно не вспомнил закрыть».
Если хочется ещё больше дисциплины в читаемости, можно вынести шаблоны путей в константы. Это не обязательно, но многие команды так делают, чтобы не плодить «магические строки»:
package com.example.securecontent.security.config;
// Константы путей: меньше "магических строк" и меньше риска опечаток
final class ApiPaths {
static final String PUBLIC = "/api/public/**";
static final String ME = "/api/me/**";
static final String EDITOR = "/api/editor/**";
static final String ADMIN = "/api/admin/**";
// Запрещаем создание экземпляров: это утилитный класс
private ApiPaths() {
}
}
Обратите внимание: это не «про красоту ради красоты». Это про то, что через неделю вы будете искать, где именно задаётся доступ к /api/editor/**, и хотите видеть это как единый термин, а не как строку, написанную в четырёх местах с тремя разными опечатками.
5. Ручная проверка правил
Конфигурация безопасности — штука коварная: мозг любит думать «ну я же написал правильно, значит и работает правильно». Но полезная привычка — после каждого изменения цепочки фильтров быстро проверять 2–3 контрольных сценария. Это не заменяет тесты (до них мы ещё дойдём, но не сегодня), зато сильно экономит время на вопросе «почему у меня всё внезапно 403». Здесь особенно полезно смотреть два режима: анонимный запрос и запрос от вошедшего пользователя.
На этом этапе у нас уже собран слой правил доступа. Конфиг говорит, какие зоны публичны, какие требуют просто входа, а какие — роль. Но сами роли и учётные данные пока ещё существуют в очень учебном виде. То есть карта доступа уже стала понятной, а пользовательская сторона ещё только просит такого же порядка.
Если в проекте у вас есть хотя бы минимальные endpoints, например /api/public/ping и /api/me, то проверка выглядит так. Для публичного endpoint вы ожидаете, что он доступен без логина:
curl -i http://localhost:8080/api/public/ping
Для личной зоны вы ожидаете, что без аутентификации доступ не дадут:
curl -i http://localhost:8080/api/me
Дальше вы делаете тот же запрос, но уже аутентифицированным пользователем. В нашем контексте это может быть стартовый пользователь из Spring Boot, которого вы уже видели раньше. И вы ожидаете, что /api/me станет доступен, а вот /api/admin/** — нет, потому что роли ADMIN у стартового пользователя обычно нет. На этом этапе важно не «поймать идеальный статус-код», а увидеть главное: матрица действительно работает, и привилегированная зона не открывается случайно.
И особенно полезная проверка именно для этой лекции — намеренно сломать порядок, убедиться, что сломалось ровно то, что вы ожидали, и вернуть порядок обратно. Это как тренировка на симуляторе: лучше один раз осознанно «разбить игрушечную админку», чем случайно открыть её в реальном проекте и потом героически закрывать на проде в 3 ночи.
6. Типичные ошибки при порядке правил
Ошибка №1: общий matcher стоит выше частного, и «исключения» перестают работать.
Эта ошибка выглядит безобидно, потому что в конфигурации есть оба правила. Но Spring Security не «сводит их в одно» — он идёт сверху вниз. Поэтому строка вроде .requestMatchers("/api/**").authenticated() выше .requestMatchers("/api/public/**").permitAll() делает public-зону не public-зоной. Лечится просто: сначала узкие исключения, потом широкие правила.
Ошибка №2: «барьер» на /api/** случайно открывает привилегированные зоны.
Иногда разработчик честно пишет «всё API только для вошедших», а потом добавляет «admin только для ADMIN» ниже и думает, что всё хорошо. На деле /api/** перехватывает /api/admin/** раньше, и админка становится доступна любому вошедшему пользователю. Это особенно опасно, потому что в ручной проверке «под одним пользователем» может долго не проявляться. Правильный подход — ставить admin/editor-правила выше общего /api/** либо вообще явно прописывать зоны без общего барьера на раннем этапе.
Ошибка №3: забыли закрыть хвост и оставили поведение «как получится».
Если вы не завершаете конфигурацию чем-то вроде .anyRequest().denyAll() (или хотя бы .anyRequest().authenticated()), вы отдаёте часть поведения на волю дефолтов, auto-configuration и случайных соседних настроек. На учебном проекте это особенно вредно: вы хотите видеть карту доступа глазами, а не угадывать её по симптомам. Хорошая конфигурация заканчивается явным правилом для «всего остального».
Ошибка №4: конфигурация получилась «простынёй» без группировки, и её невозможно читать.
Технически можно накидать двадцать requestMatchers подряд в случайном порядке — Spring Security это проглотит. Но через две недели вы уже сами не вспомните, почему /api/me/profile оказался выше /api/me, а /api/admin/users почему-то в середине public-блока. Лечится дисциплиной: группируйте зоны, держите порядок, добавляйте пустые строки или выносите пути в константы. Security-конфигурация должна читаться как документ, иначе она превращается в минное поле.
Ошибка №5: попытка «проверить всё» без понимания, что текущий пользователь может не иметь нужных ролей.
На этом этапе курса у вас часто есть только базовый пользователь (например, дефолтный), и он может не иметь ролей EDITOR или ADMIN. Это нормально: вы всё равно можете проверить, что зоны действительно закрыты. Если видите, что editor/admin endpoints недоступны, — это не «всё сломалось», а часто ровно то, что и должно быть на первой матрице доступа.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ