1. Понятие security audit
Слово “аудит” у многих вызывает образ человека с папкой, который смотрит на вас так, будто вы украли у него молодость. Но в нашем курсе security audit — это, по сути, нормальная инженерная привычка: пройтись по проекту и убедиться, что безопасность не стала “сборником случайных решений, которые когда-то помогли пройти Postman”.
В практическом смысле audit — это проверка согласованности. Мы сравниваем ожидаемую модель доступа (кто куда может) с тем, что реально делает система: на уровне URL-правил, на уровне сервисов, на уровне ownership, на уровне ошибок, на уровне runtime-конфигурации, transport/perimeter (headers, HTTPS, reverse proxy), exposed surfaces вроде docs/upload/ops и, что важно, на уровне тестов. Если хотя бы один слой “выпадает”, то система начинает вести себя странно: где-то 401, где-то 403, где-то HTML вместо JSON, а где-то вообще внезапно открытый Swagger “потому что так удобнее”.
Ещё один важный момент: аудит — это не попытка сделать идеальную security-платформу. Мы остаёмся в рамках fundamentals. Наша цель — чтобы проект был осмысленно защищён, предсказуем и поддавался сопровождению. Для учебного проекта это уже победа, потому что в реальной жизни половина проблем безопасности происходит не из-за “не того алгоритма подписи”, а из-за несостыкованной конфигурации и хаотичных правил.
К этому моменту у нас уже есть несколько разных плоскостей проверки: откуда приезжают секреты и runtime-параметры, как приложение живёт во внешнем HTTP-контуре, какие поверхности вообще торчат наружу, и как потом читать 401/403, если что-то пошло не так. Теперь всё это нужно собрать в один audit-маршрут, иначе проект так и останется набором локально правильных решений.
2. Access matrix перед аудитом
Если начать аудит с кода, вы почти гарантированно утонете в деталях. Глаза зацепятся за requestMatchers, потом за @PreAuthorize, потом за обработчики ошибок, и вы забудете главный вопрос: а какую систему доступа мы вообще хотели?
Поэтому мы начинаем с access matrix проекта. Это как план помещения перед установкой сигнализации: сначала вы решаете, какие двери должны быть закрыты и у кого какой ключ, и только потом обсуждаете модель замков. Для Secure Content Platform API матрица довольно понятная: публичные статьи доступны анонимно, /api/me и черновики — только аутентифицированному пользователю, редакторская зона — только EDITOR/ADMIN, админская — только ADMIN, а аватар — owner-only плюс ограничения по типу/размеру.
Удобно иметь матрицу не “в голове”, а в виде компактной таблицы. Это не документ ради документа, а якорь для аудита: любой спор “а должен ли это быть 401 или 403?” решается очень быстро, если видно, какой сценарий предполагается.
Вот пример минимальной таблицы, которая уже подходит для финальной проверки:
| Зона | Примеры endpoint’ов | Ожидаемый доступ | Типичный отказ |
|---|---|---|---|
| Public | GET /api/public/articles | permitAll | 200 |
| Me / Profile | GET /api/me, |
authenticated + иногда authority | 401 если anonymous |
| Drafts (own) | GET /api/drafts/{id} | authenticated + owner-only | 401/ |
| Editor | POST /api/editor/drafts/{id}/publish | EDITOR/ |
403 для |
| Admin | PATCH /api/admin/users/{id}/roles | ADMIN | 403 для |
| Upload | POST /api/me/avatar | owner-only + constraints + CSRF (stateful ветка) | 401// |
| Docs | /swagger-ui/**, |
обычно или вообще не exposed наружу |
403/ |
| Ops / Actuator | /actuator/health, , остальные |
минимальная экспозиция + отдельные rules | 403/ |
Заметьте, что “типичный отказ” уже намекает на важную часть аудита: мы проверяем не только доступ, но и семантику отказа. Это особенно критично для REST API, где клиент живёт на статусах и контракте ошибок.
Только не пытайтесь запихнуть в эту таблицу вообще всё. Access matrix отвечает на вопрос “кто может ходить в какой endpoint”, но не отвечает за HSTS, X-Forwarded-Proto или источник JWT secret. Поэтому после matrix мы отдельно проверяем transport/perimeter и runtime-config: endpoint может быть закрыт правильно, а приложение всё равно жить небезопасно снаружи.
3. Слой 1: правила SecurityFilterChain
Теперь, когда мы знаем “как должно быть”, мы можем честно проверить первый слой — правила на входе HTTP-запроса. В Spring Security это в первую очередь ваш SecurityFilterChain: matchers, порядок, и то, что вы делаете по умолчанию для “всего остального”. Именно здесь чаще всего и всплывают случайно открытые docs, actuator или слишком широкие правила для admin-зоны: формально это тот же вход в приложение, просто цена ошибки выше.
Самая частая инженерная проблема на этом слое — слишком широкие правила. Например, "/api/**".permitAll() где-то высоко, а ниже вы уже пытаетесь ограничить "/api/admin/**". Это не “ошибка синтаксиса”, это логическая ошибка порядка. И аудит — идеальный момент, чтобы такие вещи выловить, потому что они часто появляются по пути “хочу быстро проверить один endpoint” и забываются.
В финальном проекте полезно, чтобы конфигурация читалась как короткий рассказ. Сначала идут самые конкретные и чувствительные зоны (admin/editor/docs/actuator), затем public, затем “всё остальное по умолчанию”. Если у вас наоборот (сначала широкое правило, потом попытки уточнить), это повод остановиться и переписать для читаемости.
Небольшой пример “аудитно читаемого” фрагмента (не обязательно 1-в-1 как у вас, но стиль важен):
import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
// Важно: порядок правил имеет значение — Spring Security применяет первое подошедшее правило.
.authorizeHttpRequests(auth -> auth
// Самые чувствительные зоны — как можно выше и как можно конкретнее.
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.requestMatchers("/api/editor/**").hasAnyRole("EDITOR", "ADMIN")
// Public — явно разрешаем.
.requestMatchers("/api/public/**").permitAll()
// Всё остальное под /api — только для аутентифицированных.
.requestMatchers("/api/**").authenticated()
// Страховка: всё, что не попало под правила выше, закрываем.
.anyRequest().denyAll()
)
.build();
}
Обратите внимание на последнюю строку: anyRequest().denyAll(). Для учебного проекта это очень хороший “страховочный пояс”, потому что случайно открытые endpoints часто появляются не из злого умысла, а из “я добавил контроллер, забыл правила, оно стало доступно…”.
Здесь hasRole("ADMIN") — это именно внешняя граница admin-зоны. Для конкретных user-management операций audit не должен останавливаться на этом уровне: дальше в сервисе имеет смысл ждать более узкое permission вроде user:manage, чтобы правило читалось как действие, а не просто как название роли. По той же причине docs и Actuator лучше видеть в конфиге отдельными правилами или отдельной цепочкой, а не надеяться, что их случайно прикроет широкий matcher.
В аудит также входит проверка того, что docs и operational endpoints не остались “по инерции” публичными. Если у вас есть Swagger (/swagger-ui/**, /v3/api-docs/**) или Actuator (/actuator/**), то аудит обязан задать неприятный вопрос: “кто это видит?” Даже если в вашем проекте сейчас этого нет, как только оно появится — это станет одной из самых вкусных поверхностей для злоумышленника, потому что docs буквально показывают карту вашего API.
4. Слой 2: method security в сервисах
Когда URL-правила уже выглядят прилично, начинается любимое место реальности: сервисный слой. Здесь аудит отвечает на вопрос: “если кто-то попадёт в сервис не через ваш контроллер, или вы переиспользуете метод в другом endpoint’е — останется ли защита?”
Method security — это не “ещё одна настройка ради красоты”, а защита бизнес-действия рядом с этим действием. И в аудите мы проверяем не только наличие @EnableMethodSecurity, но и то, что критичные операции реально защищены на сервисах, а не только на URL.
В проекте контентной платформы есть минимум две зоны, где метод-level защита особенно важна: редакторские действия (publish/reject) и админские действия (управление ролями, блокировка/включение аккаунта). Если эти методы не защищены в сервисе, то URL-правила становятся единственной стеной, а мы как раз весь курс боролись с идеей “одна стена — достаточно”.
Для user-management сценариев полезно видеть здесь ту же business-permission, которую вы потом проверяете в тестах и audit. Outer gate может быть ADMIN, но сам сервисный action читабельнее выражать через user:manage.
Пример, который удобно видеть при аудите: короткая аннотация на сервисном методе, понятная без SpEL-романа на 200 символов.
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class AdminUserService {
@PreAuthorize("hasAuthority('user:manage')") // Конкретное business-действие проверяем через явное permission.
public void lockUser(long userId) {
// business logic
// Важно: внутри метода не полагайтесь на данные, пришедшие с клиента, для принятия security-решений.
}
}
Если вы видите, что сервисы “голые”, а вся логика доступа размазана по контроллерам, это тревожный сигнал. Контроллер — плохое место для бизнес-авторизации, потому что контроллеры часто меняются, а сервисы — это то, что хочется считать “ядром”.
При этом аудит должен ловить и противоположную крайность: когда на методах стоят суперсложные @PreAuthorize выражения, которые никто не может прочитать через неделю. В fundamentals-курсе мы стараемся держать этот слой простым: роли и authorities, плюс отдельная аккуратная проверка ownership (следующий слой).
5. Слой 3: owner-based access
Owner-based доступ — это место, где многие “честные” проекты внезапно превращаются в игрушку: роли есть, токены есть, а вот проверка “это мой черновик или чужой?” сделана как-нибудь потом. В аудит это входит обязательно, потому что это не косметика, а базовая безопасность пользовательских данных.
Смысл owner-check прост: у двух пользователей может быть одна и та же роль USER, но права на конкретный объект разные. Именно поэтому owner-check нельзя заменить ролями. Если вы вдруг обнаружили в SecurityFilterChain правило вида “/api/drafts/** доступно USER”, это ещё не означает, что пользователь не увидит чужой черновик — это означает только, что он попал в контроллер. Дальше всё решает бизнес-уровень.
Хороший audit-вопрос: “Где в коде реально происходит сравнение authorId у черновика и текущего пользователя?” И второй: “Можно ли это место обойти?”
Один из читаемых паттернов — вынести owner-check в отдельный компонент и использовать его в @PreAuthorize. В учебном проекте это нормально, если выражение остаётся коротким и не превращается в сериал.
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;
@Service
public class DraftService {
// Важно: проверка ownership должна идти к данным на сервере (БД/модель),
// а не доверять тому, что прислал клиент.
@PreAuthorize("@draftAccess.isOwner(#draftId, authentication)")
public void deleteDraft(long draftId) {
// delete logic
// Здесь должна быть только логика удаления, а не ручная проверка ролей/ownership "на всякий случай".
}
}
Аудит здесь проверяет три вещи. Во‑первых, что draftAccess.isOwner(...) действительно проверяет ownership по данным из БД (или из модели), а не доверяет “чему-то, что пришло с клиента”. Во‑вторых, что вы не раскрываете информацию “черновик существует/не существует” слишком болтливо (это уже нюанс, но полезно помнить). В‑третьих, что для admin/editor сценариев ownership не ломает привилегированный доступ там, где он должен быть (например, редактор может просматривать submitted drafts для ревью).
6. Граница stateful и stateless
В нашем курсе специально есть две модели: stateful (session/cookies/CSRF) и stateless (JWT). Это очень полезно методически, но в финальной версии проекта это легко превратить в “франкенштейна”, где половина endpoints ожидает cookie, половина — bearer token, а клиент должен быть телепатом.
Поэтому аудит включает проверку границы: где у вас session-модель, где JWT-модель, и как это выражено в коде/ветках/checkpoints. В идеале это разделено либо ветками, либо чёткими профилями, либо хотя бы очень явным условием “в этой конфигурации мы stateless”. Смешивание без границы почти всегда приводит к тому, что вы сами перестаёте понимать, почему запрос сегодня даёт 403, а завтра 401.
Хороший “аудитный” маркер stateless API — это явная политика сессий:
import org.springframework.security.config.http.SessionCreationPolicy;
// Для stateless-модели важно явно отключить создание сессии:
// иначе легко получить "случайно stateful" поведение.
http.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
Если вы видите STATELESS, но при этом у вас где-то “для удобства” остался formLogin(), и ещё сверху вы неявно рассчитываете на CSRF, это повод остановиться. Stateless модель обычно пересобирает эти решения: сессия не хранится, current user восстанавливается из токена на каждый запрос, и логика ошибок должна быть REST-friendly.
В аудите также стоит проверить, что ваши клиенты и тесты не мешают модели. Например, MockMvc-тесты для stateful-ветки должны использовать csrf(), а для stateless — jwt() или установку Authorization заголовка (в зависимости от выбранной ветки). Если тесты “смешаны”, это почти всегда означает, что и конфигурация смешана.
Небольшая схема, которая помогает объяснить себе (и коллегам) границу во время аудита:
flowchart TD
A[Запрос клиента] --> B{"Какая модель?"}
B -->|Session branch| C["Cookie JSESSIONID + CSRF для state-changing"]
C --> D[SecurityContext восстанавливается из session]
B -->|JWT branch| E["Authorization: Bearer ..."]
E --> F[SecurityContext восстанавливается из токена]
D --> G[Request-level + Method security + Owner rules]
F --> G
Заметьте, что внизу обе ветки сходятся: request-level правила, method security и owner rules должны работать в обоих случаях. Разница только в том, откуда взялся current user.
7. Ошибки API: 401/403 и JSON
Многие недооценивают “красоту” ошибок, но для API это часть безопасности. Клиент (Postman, мобильное приложение, фронтенд, интеграция) живёт на статусах и контракте. Если вы отдаёте то HTML-страницу логина, то JSON, то redirect — это не просто неудобно, это ломает способность клиента корректно реагировать на security события.
В аудите мы задаём скучный, но важный вопрос: “В каких случаях мы отдаём 401, а в каких 403, и можно ли это предсказать?” Если ответ “ну… зависит” — значит, где-то смешались сценарии unauthenticated и forbidden.
Здоровая модель такая: если пользователь не аутентифицирован и пришёл в защищённую зону, это 401 (unauthenticated). Если пользователь аутентифицирован, но ему нельзя, это 403 (forbidden). Owner-based запрет — тоже 403, просто причина не в роли, а в том, что объект чужой.
В коде аудит обычно ищет две точки: AuthenticationEntryPoint и AccessDeniedHandler. Даже если вы уже их писали, финальный аудит проверяет, что они подключены именно там, где нужно, и что их ответы действительно одинаковы по формату.
Если хочется, чтобы audit был “самодокументируемым”, полезно иметь маленький DTO/record для ошибок, который используется и в entry point, и в denied handler. Тогда у проекта есть единый стиль, а не две похожие, но разные JSON-структуры.
8. Аудит тестов безопасности
Ручная проверка Postman’ом хороша, но это проверка “здесь и сейчас”. Аудит же — это вопрос: “Можем ли мы сломать security случайным изменением кода и не заметить?” Если ответ “да, легко” — значит, тесты не выполняют роль страховки.
Поэтому в финальном аудите мы смотрим на тесты как на доказательство того, что модель доступа не расползлась. Очень полезно, когда у вас явно присутствуют пары тестов “401 vs 403” для одной и той же зоны: одна проверка для anonymous, другая — для аутентифицированного, но без прав.
Пример на 401 (anonymous):
import org.junit.jupiter.api.Test;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class AdminSecurityTest {
@Test
void anonymous_cannot_access_admin() throws Exception {
// anonymous: без .with(user(...)) и без токена/сессии
mockMvc.perform(get("/api/admin/users"))
// Ожидаем 401: аутентификации нет, значит "не представился"
.andExpect(status().isUnauthorized());
}
}
И симметричный пример на 403 (аутентифицированный пользователь без права):
import org.junit.jupiter.api.Test;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
class AdminSecurityTest {
@Test
void user_role_cannot_access_admin() throws Exception {
mockMvc.perform(get("/api/admin/users")
// Пользователь аутентифицирован, но роль не подходит под admin-зону
.with(user("alice").roles("USER")))
// Ожидаем 403: "мы тебя узнали, но тебе нельзя"
.andExpect(status().isForbidden());
}
}
Аудит здесь смотрит не только на наличие тестов, но и на их смысл. Если вы видите тест “admin получает 200”, но нет теста “user получает 403”, значит граница может сломаться, а вы это узнаете только когда кто-то постучится в /api/admin в проде (что обычно происходит в самый неподходящий момент, например, в пятницу вечером).
Для stateful-ветки аудит обязательно проверяет наличие негативных CSRF сценариев. Типичный “ловец регрессий” — test на state-changing endpoint без csrf(). Он должен падать (то есть возвращать forbidden), иначе вы незаметно отключили CSRF или сломали его проверку.
9. Конфигурация и секреты
К этому моменту часто возникает соблазн сказать: “Ну конфиги — это не код”. А потом кто-то коммитит JWT secret в репозиторий, и проект становится учебником по тому, как делать не надо.
Здесь уже не нужен второй tutorial про externalized config. Для аудита достаточно трёх вопросов: чувствительные значения вынесены из кода, для секрета нет default, и приложение действительно не стартует молча с пустым или подставным значением. Плюс runtime реально доставляет эти значения в Environment, а не просто хранит их “где-то рядом”.
Простейший маркер здоровья — placeholders и env vars в application.yml, а не строковые литералы в TokenService. Для JWT это особенно критично: secret — не то, что должно жить рядом с бизнес-логикой.
Пример, который выглядит правильно для учебного проекта:
app:
security:
jwt:
secret: ${APP_SECURITY_JWT_SECRET}
access-token-ttl: 15m
Аудит задаёт неприятный, но честный вопрос: “Если я открою репозиторий на GitHub, найду ли я секрет?” Если да — это не “мелочь”, это системная ошибка. Даже для учебного проекта. Потому что учебные проекты очень любят копировать “как есть” в следующий pet project, а дальше — в реальный.
Также аудит проверяет стабильность имён ключей. Когда ключи называются хаотично (jwtSecret, secretJwt, token.secret), сопровождение превращается в археологию. А безопасность — это как раз дисциплина: если вы не можете быстро найти, где настраивается TTL токена или allowed origins, вы не сможете быстро провести диагностику инцидента.
10. Аудит логов без утечек
Логи — это одновременно лучший друг и потенциальный предатель. Они нужны, чтобы разбирать “почему 403?”, “почему контекст пустой?”, “почему токен не распарсился?”. Но они же могут случайно утечь в централизованную систему логов, в чат поддержки, в Jira и куда угодно ещё.
В финальном аудите мы проверяем два класса вещей: что полезные события вообще логируются (например, admin-изменение ролей, блокировка/разблокировка, подозрительные попытки доступа), и что мы случайно не пишем туда то, что писать нельзя.
Запреты здесь простые и жёсткие: не логируем raw password, не логируем полный Authorization header, не логируем bearer token целиком, не печатаем значения секретов из конфигурации. Если очень хочется “для отладки”, то лучше логировать факт, что заголовок был/не был, или что токен не прошёл валидацию по причине expired, но не само значение.
Да, это тот самый момент, когда разработчик говорит: “Но мне так удобно”. И это тот самый момент, когда безопасник (или вы через полгода) отвечает: “А потом это улетело в логи продакшена, и стало ещё удобнее — но уже злоумышленнику”.
11. Быстрый маршрут проверки
Хочется иметь ощущение завершённости, но без отдельного раздела “Итоги”. Поэтому я оставлю здесь один простой маршрут, который можно держать в голове как порядок мыслей при аудите. Он не требует чек-листа на 200 пунктов и не превращает вас в человека с папкой; он просто помогает не забыть слой.
Сначала вы проверяете, откуда приезжают секреты и runtime-параметры: secret не захардкожен, для него нет default, профиль не живёт своей отдельной жизнью. Потом смотрите transport/perimeter: security headers на месте, приложение корректно понимает HTTPS за reverse proxy, а forwarded headers не превращают схему запроса в лотерею. Затем сужаете exposed surface: docs, upload, actuator и admin-зона имеют явные правила, лимиты и не торчат наружу случайно. После этого сверяете access matrix и SecurityFilterChain, потом service layer, ownership, семантику 401/403, тесты и только в конце — логи как инструмент диагностики, но без утечек.
Если в процессе вы поймали себя на мысли “я не знаю, почему тут 401”, это не повод переписывать конфиг “наугад”. Это повод вернуться к mental model: откуда должен был взяться SecurityContext в этом сценарии (session/basic/jwt/test postprocessor) и где по пути он потерялся.
12. Типичные ошибки при финальном security audit
Ошибка №1: начинать аудит с кода, а не с модели доступа.
Когда вы сразу открываете SecurityConfig и “ищете проблемы глазами”, вы фактически делаете угадайку. Правильный аудит начинается с access matrix: кто, куда, каким способом аутентифицируется, какие зоны public, какие privileged, где owner-only. Тогда любые несостыковки в коде становятся очевидными, а не “кажется, тут что-то странное”.
Ошибка №2: считать, что один слой защиты “достаточен”.
Очень частая ловушка: либо вера только в SecurityFilterChain, либо вера только в @PreAuthorize. На практике эти слои дополняют друг друга. URL-правила — первая линия обороны, а method security защищает бизнес-действие. Если вы проверили только одно, вы не провели аудит, вы просто посмотрели на один кусок системы.
Ошибка №3: путать 401 и 403 и делать из этого “философию”.
Это не философия, это диагностика. 401 означает, что аутентификации нет (или она не дошла до decision point). 403 означает, что пользователь распознан, но права не хватает (в том числе из‑за ownership). Если вы называете любой отказ “403”, вы ломаете себе алгоритм поиска причин и превращаете расследование в перебор настроек.
Ошибка №4: смешать stateful и stateless в одном runtime без ясной границы.
Смешивание session/CSRF и JWT в одном и том же наборе endpoint’ов без явного разделения почти всегда приводит к сюрпризам: то контекст не восстановился, то CSRF “вдруг” требуется там, где вы ожидали stateless, то login ведёт себя “как-то странно”. В финальном проекте граница должна быть видимой: в конфигурации, в тестах и в понимании “какой клиент как ходит”.
Ошибка №5: считать security tests “приятным бонусом”, а не частью безопасности.
Если тесты не проверяют негативные сценарии, то безопасность проекта держится на вашей памяти. А память у разработчика — ресурс, который заканчивается примерно после третьего созвона за день. Аудит должен опираться на тесты: anonymous → 401, authenticated без прав → 403, CSRF missing → отказ, JWT missing/invalid → отказ, owner violation → 403.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ