SSE + Spring Security: три ловушки async-dispatch, которые ломают tool-streaming
Запустил SSE для tool-streaming LLM, упёрся в три отдельных бага Spring Security при async-обработке. Разбор каждого и какой минимум кода правильный.
В моём ассистенте поверх системы мониторинга OLT каждый диалог стримится в браузер через Server-Sent Events: tool-вызовы, аргументы, результаты, output модели. Без стрима пользователь сидит 6 секунд перед пустым экраном, со стримом — видит, как бот строит ответ.
Локально всё работало. На стейдже — AccessDenied 403 ровно в момент финализации SSE, после уже отданных событий. Лог обрывался на закрывающем complete(). Ушло пол-дня и три отдельных коммита, прежде чем я понял, что это три разных бага в трёх разных слоях, и закрывать их надо все три сразу.
Как устроен SSE в Spring MVC
Контроллер возвращает SseEmitter, и Spring обрабатывает запрос в два прохода:
- REQUEST dispatch — нормальный HTTP-цикл: фильтры, аутентификация, контроллер. Он завершается, как только метод вернул
SseEmitter— но соединение остаётся открытым. - ASYNC dispatch — отдельный worker thread пишет события в response через
emitter.send(...). Он живёт сколько угодно, потом вызываетemitter.complete(). - Финальный dispatch — Spring повторно прогоняет запрос через фильтры с
DispatcherType.ASYNCдля post-processing. Здесь мойSecurityFilterChainснова срабатывал и снова проверял auth — ноSecurityContextуже не было в worker'е.
Каждый из трёх шагов — кандидат на сломать.
Ловушка 1: @PreAuthorize на SSE endpoint ломает async-dispatch
@GetMapping(value = "/api/llm/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
@PreAuthorize("isAuthenticated()") // ← вот это ломает
public SseEmitter chat(@RequestParam String message) {
var emitter = new SseEmitter(0L);
chatService.runAsync(message, emitter);
return emitter;
}
@PreAuthorize — это AOP-аспект, проверяющий SecurityContext на каждом dispatch. На REQUEST всё хорошо: пользователь аутентифицирован. На ASYNC dispatch — context пустой, аспект бросает AccessDeniedException, Spring отвечает 403 поверх уже стрима.
Снял аннотацию, проверяю SecurityContextHolder.getContext().getAuthentication() руками в начале метода:
@GetMapping("/api/llm/chat", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public SseEmitter chat(@RequestParam String message) {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !auth.isAuthenticated()) {
throw new AccessDeniedException("auth required");
}
// ...
}
Auth проверяется один раз на REQUEST, async-dispatch не цепляет аспект. Стало лучше, но не вылечило.
Ловушка 2: SecurityContext теряется в worker thread
chatService.runAsync крутит tool-loop в отдельном потоке через @Async или Executor.submit(). Поток новый — ThreadLocal пустой. Если в loop'е вызывается tool, который сам ходит куда-то с проверкой прав (@PreAuthorize на сервисе), снова летит AccessDenied, но уже изнутри worker'а.
Решение в стандартной библиотеке Spring Security — DelegatingSecurityContextRunnable:
import org.springframework.security.concurrent.DelegatingSecurityContextRunnable;
public void runAsync(String message, SseEmitter emitter) {
var ctx = SecurityContextHolder.getContext();
executor.submit(new DelegatingSecurityContextRunnable(
() -> tryRun(message, emitter),
ctx
));
}
Обёртка копирует SecurityContext в worker thread перед запуском и убирает после. Тот же эффект даёт DelegatingSecurityContextExecutor — обернуть в него сам Executor, тогда руками каждый раз не писать.
Ловушка 3: SecurityFilterChain бьёт по DispatcherType.ASYNC
Это самая хитрая. После завершения worker'а Spring делает финальный async-dispatch для post-processing. Запрос снова проходит через SecurityFilterChain — но это тот же самый запрос, аутентификация уже была. Если фильтр снова попробует достать principal (например, через JwtAuthenticationFilter), он либо упадёт, либо попытается ре-аутентифицировать с пустым Authorization header (он же снят на REQUEST после login).
В моём случае JwtAuthenticationFilter не находил токена и отдавал AccessDenied. Решение — пропустить async/error dispatchтипы:
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.securityMatcher(req ->
req.getDispatcherType() == DispatcherType.REQUEST
)
// ... всё остальное как было
.build();
}
securityMatcher с проверкой DispatcherType.REQUEST означает: фильтр-чейн срабатывает только на первый dispatch. Async и error пропускаются — они и так уже на authorized-запросе, повторная проверка лишняя и вредная.
Что я понял
- SSE — это три dispatch'а, не один. REQUEST, ASYNC, и финальный async-после-complete. Любая Security-логика должна понимать, на каком из трёх она исполняется.
- Не вешай
@PreAuthorizeна SSE-endpoint. Аннотация-аспект бьёт на каждый dispatch. Проверяй auth руками один раз и пробрасывай SecurityContext в worker. DelegatingSecurityContextRunnable— стандарт, не хак. Spring Security даёт его именно для async-сценариев. Не изобретай собственный пробрасыватель ThreadLocal'ов.securityMatcher(req → DispatcherType.REQUEST)— must-have для проектов с SSE/WebFlux/async. Без него любой post-processing dispatch может уронить уже отвеченный запрос.
Если SSE падает с AccessDenied через секунду после того, как уже отдал данные — ищи async-dispatch, не endpoint.
Комментарии работают через Giscus + GitHub. По клику ваши данные передаются в GitHub Inc. (США). Не будете кликать — ничего не отправляется.