İçeriğe geç

Spring Security Filter Chain

İleri 10 dk Çok sık karşılaşılır

Önce şunu oku: IoC ve Dependency Injection

30 saniyede özet

Spring Security, istek controller'a varmadan önce sırayla geçtiği bir turnike dizisidir. Önce 'sen kimsin?', en sonda 'buna iznin var mı?' sorulur. 401 ile 403'ün farkı da buradan gelir.

Bir binaya girerken önce turnikelerden geçersin. Turnikede takılan birini kattaki resepsiyon hiç görmez.

Üç istek, iki turnike: kim nerede duruyor, kim haberdar?
Adım adım oku
  1. Her istek controller'a varmadan önce sırayla güvenlik filtrelerinden, yani turnikelerden geçer.
  2. Token'ı olmayan istek ilk turnikede, sen kimsin sorusunda durur ve 401 alır.
  3. Kimliği belli ama rolü yetmeyen istek son turnikede, iznin var mı sorusunda durur ve 403 alır.
  4. Controller ve onun @ControllerAdvice'ı bu iki isteği hiç görmez; hatalar filtrelerde cevaba çevrilir.
  1. Bayt: Bütün hataları @ControllerAdvice'ta yakalıyorum. Güvenlik hataları da oraya düşer, değil mi?

  2. Sen: Düşmesi gerekmez mi?

  3. Bayt: Düşmüyor! 401 başka bir yerden, başka bir biçimde dönüyor.

  4. Bayt: Turnikede takılan misafiri kattaki resepsiyon hiç görmez.

Spring Security bir “katman” değil, bir servlet filter chainSpring Security'nin isteği sırayla geçirdiği servlet filtreleri. `DispatcherServlet`'ten önce çalışır — bu yüzden güvenlik hataları `@ControllerAdvice` ile yakalanamaz.Sözlükte gör →dir. Tek bir FilterChainProxy servlet konteynerine kaydolur ve içinde sırayla çalışan onlarca filtre barındırır.

Bu tek gerçek, kafa karıştıran üç durumu birden açıklar.

Kendin izle

Spring Security filter chain — bir isteğin yolculuğu

Tohum 1
GET /api/ordersBearer (geçerli)kural: hasRole(ADMIN)

SecurityContext

null — henüz doldurulmadı

  1. SecurityContextHolderFilterbekliyor
  2. HeaderWriterFilterbekliyor
  3. CorsFilterbekliyor
  4. CsrfFilterbekliyor
  5. LogoutFilterbekliyor
  6. UsernamePasswordAuthenticationFilterbekliyor
  7. BearerTokenAuthenticationFilterbekliyor
  8. AnonymousAuthenticationFilterbekliyor
  9. ExceptionTranslationFilterbekliyor
  10. AuthorizationFilterbekliyor
  11. DispatcherServlet → Controller
Hız
Adım 0

Şu an ne oldu?

SecurityContextHolderFilter

Var olan oturumdan SecurityContext yükler

Aklında kalsın: SecurityContext hâlâ boş — kimlik doğrulama henüz yapılmadı.

Olay günlüğü (0)

Henüz olay yok. Oynat veya adımla.

Varsayılan: geçerli JWT, ROLE_USER, endpoint hasRole("ADMIN") istiyor. Sekiz filtreyi geçip son filtrede 403 alıyor.

Sırayla dene:

  • Kimlik bilgisini Yok yap. AnonymousAuthenticationFilter devreye giriyor ve sonuç 401 oluyor — 403 değil.
  • Rolü ADMIN yap. Aynı zincir, bu kez 200.
  • Süresi dolmuş JWT seç. İstek BearerTokenAuthenticationFilter’da kesiliyor; AuthorizationFilter’a hiç sıra gelmiyor.
  • CSRFTarayıcının isteğe otomatik eklediği kimlik bilgilerini başka bir sitenin sömürmesi. Token elle eklenen bir başlıktaysa bu saldırı yüzeyi yoktur; cookie'deyse vardır.Sözlükte gör → token’ı olmayan POST seç. Zincirin çok başında kesiliyor — kimlik filtrelerine bile ulaşmıyor.
Kafam karıştı, daha basit anlat

İstek, sırayla dizilmiş güvenlik görevlilerinin önünden geçer. Biri kimliğine bakar, sonuncusu da “buraya girme yetkin var mı” diye sorar.

Hızlı kontrolOrta

Authentication ile authorization arasındaki fark nedir?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Güvenlik, Spring MVC’den önce çalışır

Token'sız bir istek /api/accounts adresine geldi. Controller'daki metodun ilk satırı çalışır mı? Cevabı göster

Hayır. İstek, Spring MVC’ye ulaşmadan güvenlik filtre zincirinde reddedilir ve 401 döner.

Servlet Container
└─ FilterChainProxy ← Spring Security burada
├─ SecurityContextHolderFilter
├─ ... (8 filtre daha)
└─ AuthorizationFilter
└─ DispatcherServlet ← Spring MVC ancak burada başlıyor
└─ Controller

Sonuç: @ControllerAdvice güvenlik istisnalarını yakalayamaz. 401 veya 403 döndüğünde istek Spring MVC’ye hiç ulaşmamıştır.

Hata gövdesini özelleştirmek istiyorsan @ControllerAdvice değil, şunlar gerekir:

SecurityConfig.java
http.exceptionHandling(ex -> ex
// 401 — kimlik doğrulanmamış
.authenticationEntryPoint(customEntryPoint)
// 403 — kimlik var, yetki yok
.accessDeniedHandler(customAccessDeniedHandler)
);

Kimlik doğrulama başta, yetkilendirme sonda. AuthorizationFilter zincirin son filtresidir. Yani geçerli bir token’ın seni içeri aldığı anlamına gelmez — sadece son filtreye kadar getirir.

Bu ayrım hata ayıklarken kritiktir:

  • İstek BearerTokenAuthenticationFilter’da kesildi → kimlik sorunu
  • İstek AuthorizationFilter’da kesildi → yetki sorunu

401 mi, 403 mü? Kararı ExceptionTranslationFilter verir ve kural basittir: kim olduğunu söylemediysen 401, kim olduğun belli ama iznin yoksa 403.

ÇağıranSonuçAnlamı
Anonim401 Unauthorized”Kim olduğunu söyle” — AuthenticationEntryPoint
Kimliği var, yetkisi yok403 Forbidden”Kim olduğunu biliyorum, izin yok” — AccessDeniedHandler
Karar nasıl veriliyor?· istersen atla

ExceptionTranslationFilter, kendisinden sonraki filtrenin fırlattığı AccessDeniedException’ı yakalar. Sonra çağıranın anonim olup olmadığına bakıp isteği bu iki yoldan birine gönderir.

Aşağıdaki örnek bir bankadan: aynı uygulamada mobil uygulamanın kullandığı JWT’li stateless API ve şube personelinin oturumlu paneli. Üstüne ‘bu hesap senin mi?’ kontrolü, kendi filtren ve bunları doğrulayan testler.

Derinleş · Mobil bankacılık API'si ve şube paneli: iki filter chain, bir uygulama 6 dosya · ~149 satır · ilk okumada atlayabilirsin
Proje dosyaları

src/main/java/com/bank/security/ SecurityConfig.java İki zincir: mobil uygulamanın kullandığı /api/** stateless JWT, şube personelinin paneli form login ve oturum. @Order hangisinin önce eşleşeceğini söyler.

src/main/java/com/bank/security/SecurityConfig.java
@Configuration
@EnableMethodSecurity
class SecurityConfig {
@Bean
@Order(1)
SecurityFilterChain mobileApi(HttpSecurity http, JwtAuthenticationConverter jwtConverter,
RequestIdFilter requestId) throws Exception {
return http
.securityMatcher("/api/**") // this chain only sees /api/**
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/fx-rates/**").permitAll() // public rate board
.requestMatchers("/api/ops/**").hasRole("OPERATIONS")
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter)))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
// No cookies are used on this chain, so there is nothing for CSRF to protect.
.csrf(csrf -> csrf.disable())
.addFilterBefore(requestId, BearerTokenAuthenticationFilter.class)
.build();
}
@Bean
@Order(2)
SecurityFilterChain branchPanel(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/login", "/assets/**").permitAll()
.requestMatchers("/panel/limits/**").hasRole("BRANCH_MANAGER")
.anyRequest().hasRole("BRANCH_OFFICER"))
.formLogin(Customizer.withDefaults()) // session cookie: CSRF stays ON here
.sessionManagement(session -> session.maximumSessions(1)) // one desk, one session
.build();
}
}

src/main/java/com/bank/security/ JwtRolesConfig.java JWT'deki roles claim'ini Spring'in ROLE_ yetkilerine çevirir.

src/main/java/com/bank/security/JwtRolesConfig.java
@Configuration
class JwtRolesConfig {
// Token: { "sub": "C-100042", "roles": ["CUSTOMER"] } -> ROLE_CUSTOMER
@Bean
JwtAuthenticationConverter jwtAuthenticationConverter() {
var authorities = new JwtGrantedAuthoritiesConverter();
authorities.setAuthoritiesClaimName("roles");
authorities.setAuthorityPrefix("ROLE_");
var converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(authorities);
return converter;
}
}

src/main/java/com/bank/account/ AccountQueries.java URL kuralı kaba taneli; 'bu hesap senin mi?' gibi ince kurallar metot seviyesinde.

src/main/java/com/bank/account/AccountQueries.java
@Service
class AccountQueries {
private final AccountRepository accounts;
private final TransactionHistory history;
AccountQueries(AccountRepository accounts, TransactionHistory history) {
this.accounts = accounts;
this.history = history;
}
// The URL rule only says "authenticated". Whether this IBAN belongs to the caller
// is decided here: guessing another customer's IBAN must not show their movements.
@PreAuthorize("hasRole('OPERATIONS') or @accountOwnership.owns(authentication.name, #iban)")
public List<MovementView> movements(String iban, YearMonth month) {
return history.of(iban, month);
}
}
@Component("accountOwnership")
class AccountOwnership {
private final AccountRepository accounts;
AccountOwnership(AccountRepository accounts) {
this.accounts = accounts;
}
public boolean owns(String customerNo, String iban) {
return accounts.existsByIbanAndCustomerNo(iban, customerNo);
}
}

src/main/java/com/bank/security/ RequestIdFilter.java Kendi filtren: her isteğe bir id verir; bir müşteri şikayetinde log'daki izi bulmak bununla kolaylaşır.

src/main/java/com/bank/security/RequestIdFilter.java
@Component
class RequestIdFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
String id = Optional.ofNullable(request.getHeader("X-Request-Id"))
.orElseGet(() -> UUID.randomUUID().toString());
MDC.put("requestId", id);
response.setHeader("X-Request-Id", id);
try {
chain.doFilter(request, response);
} finally {
MDC.remove("requestId");
}
}
}
// Registered as a @Component, Boot would also add it to the servlet filter chain
// and it would run twice. Keep it only inside the security chain:
@Configuration
class RequestIdFilterRegistration {
@Bean
FilterRegistrationBean<RequestIdFilter> requestIdFilterRegistration(RequestIdFilter filter) {
var registration = new FilterRegistrationBean<>(filter);
registration.setEnabled(false);
return registration;
}
}

src/main/resources/ application.yml Token doğrulama ayarı ve zinciri adım adım görmek için TRACE log.

src/main/resources/application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.bank.example/realms/retail # keys fetched from its JWKS
logging:
level:
# Every filter, in order, and the decision each one made. Development only:
# never in production, where it would log customer requests in detail.
org.springframework.security: TRACE

src/test/java/com/bank/security/ MobileApiSecurityTest.java Testler: token yok 401, yanlış rol 403, doğru rol 200.

src/test/java/com/bank/security/MobileApiSecurityTest.java
@WebMvcTest(controllers = OpsLimitsController.class)
@Import({SecurityConfig.class, JwtRolesConfig.class, RequestIdFilter.class})
class MobileApiSecurityTest {
@Autowired MockMvc mvc;
@MockitoBean LimitService limits;
@MockitoBean JwtDecoder jwtDecoder; // no real issuer in a slice test
@Test
void noTokenIsUnauthorized() throws Exception {
mvc.perform(get("/api/ops/limits")).andExpect(status().isUnauthorized());
}
@Test
void aCustomerCannotReachOperations() throws Exception {
mvc.perform(get("/api/ops/limits").with(jwt().authorities(new SimpleGrantedAuthority("ROLE_CUSTOMER"))))
.andExpect(status().isForbidden());
}
@Test
void operationsIsAllowed() throws Exception {
mvc.perform(get("/api/ops/limits").with(jwt().authorities(new SimpleGrantedAuthority("ROLE_OPERATIONS"))))
.andExpect(status().isOk());
}
}
Kafam karıştı, daha basit anlat

Güvenlik kapısı binanın dışındadır. Kapıda geri çevrilen istek içeri hiç girmediği için, içerideki hata masası onu hiç görmez.

Hızlı kontrolOrta

Spring Security filtre zinciri `DispatcherServlet`'e göre nerede durur?

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Her bileşeni, filtre zincirinde mi yoksa MVC katmanında mı çalıştığına göre ayır.

Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

Sınıflandırılmamış

Filtre zinciri

Controller'a ulaşmadan çalışır

    MVC katmanı

    DispatcherServlet'ten sonra

      Tuzaklar

      Yaygın hata
      var auth = SecurityContextHolder.getContext().getAuthentication();
      if (auth == null) { /* asla çalışmaz */ }
      // Kimliksiz isteklerde bile AnonymousAuthenticationToken vardır
      if (auth == null || !auth.isAuthenticated() || auth instanceof AnonymousAuthenticationToken) { }
      Anonim kullanıcı null değildir· istersen atla

      AnonymousAuthenticationFilter, o ana kadar kimlik belirlenmemişse bir AnonymousAuthenticationToken koyar. Bu yüzden “giriş yapılmamış” durumunu null kontrolüyle yakalayamazsın.

      CSRF: ne zaman gerekli, ne zaman değil. CSRF saldırısı, tarayıcının çerezleri otomatik göndermesine dayanır. Buradan doğrudan bir kural çıkar:

      Kimlik taşıma yöntemiCSRF koruması
      Çerez tabanlı oturum (JSESSIONID)Gerekli
      Authorization: Bearer başlığıGereksiz — tarayıcı başlığı otomatik göndermez
      Çereze konmuş JWTGerekli — çerez olduğu an risk geri gelir
      Stateless JWT API
      http.csrf(AbstractHttpConfigurer::disable)
      .sessionManagement(s -> s.sessionCreationPolicy(STATELESS));

      csrf().disable() yazmadan önce son satıra bak: token’ı çerezde taşıyorsan CSRF hâlâ gereklidir.

      Hızlı kontrolİleri

      Her durumu doğru HTTP yanıtına yerleştir.

      Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

      Sınıflandırılmamış

      401 Unauthorized

      Aslında: kimlik doğrulanmadı

        403 Forbidden

        Kimlik var, izin yok

          Session vs JWT

          Oturum (session)JWT
          Durum neredeSunucudaToken’ın içinde
          ÖlçeklemeSticky session veya RedisDoğal olarak stateless
          İptal etmekAnında — oturumu silZor — token süresi dolana kadar geçerli
          BoyutKüçük çerezHer istekte taşınan yük
          JSON
          Veri biçimi: içindeki bilgiler JSON olarak yazılır.
          Web
          Web: HTTP isteğiyle taşınır.
          Token
          Jeton: imzalı bir giriş kartı. Sunucu hatırlamaz, kartın imzasına bakar.
          Kafam karıştı, daha basit anlat

          Session, sunucunun seni hatırlamasıdır. JWT ise cebinde taşıdığın imzalı bir kart. Kartı geri almak zordur, bu yüzden kısa süreli verilir.

          JWT’nin en çok atlanan maliyeti iptal edilememesidir. Bir kullanıcıyı anında engellemen gerekiyorsa ya kısa ömürlü token + refresh token kullanırsın ya da bir iptal listesi tutarsın — ki bu da seni yeniden durumlu (stateful) hâle getirir.

          Kendini sına

          Şimşek turu1/5

          Spring Security filtreleri, istek controller'a varmadan önce çalışır.

          Soru 1/3İleri

          Kimlik bilgisi taşımayan bir istek `hasRole("ADMIN")` korumalı bir endpoint'e geldi. Hangi HTTP durumu döner?

          Cevabı biliyor musun?Önce birini seç. Tekrar zamanlaması buna göre ayarlanıyor.

          Aklında kalacak üç şey

          1. 1 Güvenlik, Spring MVC'den önce çalışan bir filtre zinciridir. Bu yüzden @ControllerAdvice güvenlik hatalarını yakalayamaz.
          2. 2 Sıra şöyle: kimlik doğrulama zincirin başında, yetki kontrolü en sonunda. Geçerli bir token seni yalnızca son filtreye kadar getirir.
          3. 3 Kim olduğunu söylemeyen istek 401 alır, kim olduğu belli ama izni olmayan istek 403.
          Sonraki kapı Aynı sınıfın içinden çağırdığın @Transactional metot neden transaction açmıyor? AOP, Proxy ve Self-Invocation Tuzağı · 10 dk

          5 kart sonraki derste seni bekliyor

          0/5 kart bu dersten toplandı

          Bu dersin üstüne kurulanlar

          Bunlar bu dersi temel alıyor; hazır olduğunda devam edebilirsin.