Gün 3 / Production Basics / Modül 08
Modül 08 Gün 3 Production Basics ~90 dk

Spring Security Basics

Spring Security çoğu ekipte “korkutucu config sınıfı” diye bilinir; aslında HTTP isteği controller’a ulaşmadan önce çalışan kontrollü bir güvenlik katmanından ibaret. Önce authentication ile authorization farkını netleştiriyoruz, ardından filter chain, JWT, role-based access ve method-level security’yi MiniBank üzerinde birlikte kuruyoruz.

Anlatım
55 dk
MiniBank Case
50 dk
Kapanış
5 dk
Branch
lab-08

Spring Security’ye başlamadan önce ortak dil

Security konusu çoğu zaman kavramlar birbirine karıştığı için zor görünür. Bu sözlük, modül boyunca aynı kelimeleri aynı anlamda kullanmamızı sağlar.

Authentication

Kullanıcının kim olduğunu doğrulama işlemidir. Spring Security’de bu işlem başarılı olursa sistem artık request’i yapan kişinin kimliğini bilir.

Authorization

Doğrulanmış kullanıcının hangi işlemi yapabileceğine karar verme işlemidir. Örneğin USER rolü hesap görüntüleyebilirken ADMIN rolü kullanıcı yönetebilir.

Principal

Güvenlik bağlamında işlem yapan kullanıcıyı temsil eder. Genellikle kullanıcı adı, user id veya kullanıcı detayları principal üzerinden taşınır.

GrantedAuthority / Role

Kullanıcıya verilmiş yetki bilgisidir. Spring’de rol kontrolü çoğu zaman ROLE_USER ve ROLE_ADMIN gibi authority değerleriyle yapılır.

SecurityContext

O anki request için authentication bilgisinin tutulduğu yerdir. Controller’a gelindiğinde “bu kullanıcı kim?” sorusunun cevabı bu context üzerinden bulunur.

SecurityFilterChain

HTTP request controller’a gitmeden önce çalışan güvenlik filtreleri zinciridir. Login, token doğrulama, yetki kontrolü ve güvenlik hataları bu zincirde ele alınır.

JWT

Kimlik ve yetki bilgisini imzalı bir token olarak taşıyan yapıdır. API istemcisi bu token’ı genellikle Authorization: Bearer ... header’ı ile gönderir.

Method-Level Security

Yetki kontrolünü sadece URL seviyesinde değil, service method seviyesinde de yapabilmektir. Özellikle kritik business işlemlerinde ikinci güvenlik katmanı sağlar.

Refresh Token

Uzun ömürlü ve sadece yeni access token almak için kullanılan token’dır. Normal API isteklerinde taşınmaz; access token expire olduğunda kullanıcıyı tekrar login’e göndermeden yenileme yapmayı sağlar.

CORS

Cross-Origin Resource Sharing. Bir tarayıcı sayfasının kendi origin’inden farklı bir origin’deki API’yi çağırmasına sunucunun izin verme mekanizmasıdır. Same-origin policy’nin kontrollü şekilde gevşetilmesidir.

Preflight (OPTIONS)

Tarayıcının asıl cross-origin istekten önce gönderdiği OPTIONS isteğidir. “Bu origin, method ve header’larla istek atabilir miyim?” diye sorar; sunucu izin verirse asıl istek gönderilir.

Spring Security ne işe yarar?

Spring Security, uygulamana gelen request’lerin kimden geldiğini ve o kişinin ne yapmaya yetkili olduğunu kontrol eden güvenlik katmanıdır.

Normalde bir REST API yazdığında request doğrudan controller method’una gelir gibi düşünürsün. Ancak production uygulamasında bu yeterli değildir. Bazı endpoint’ler herkese açık olabilir, bazıları login gerektirebilir, bazıları ise sadece belirli role sahip kullanıcılara açık olmalıdır. Bu kontrolü her controller method’unun içine if yazarak yapmak hem tekrar üretir hem de güvenlik açıklarına davetiye çıkarır.

Spring Security burada devreye girer ve şunu söyler: “Request controller’a gelmeden önce ben araya gireyim. Önce bu kişinin kim olduğunu anlayayım, sonra bu endpoint’e erişmeye yetkisi var mı karar vereyim.” Bu yüzden Spring Security’yi sadece login ekranı veya JWT üretimi olarak düşünmemek gerekir. Asıl değer, güvenlik kararlarını merkezi, okunabilir ve test edilebilir bir katmanda toplamaktır.

Request gelir
Security filter çalışır
Kimlik doğrulanır
Yetki kontrol edilir
Controller’a geçilir
Response döner
MiniBank bağlantısı

MiniBank’ta bazı endpoint’ler herkese açık olacak: örneğin login. Bazıları login gerektirecek: hesap bilgisi görüntüleme. Bazıları ise sadece ADMIN rolüne açık olacak: kullanıcı veya sistem yönetimi. Bu ayrımı controller içine dağıtmak yerine Security configuration ve method-level security ile yöneteceğiz.

“Sen kimsin?” ve “Ne yapabilirsin?” ayrımı

Spring Security’de en temel ayrım authentication ve authorization ayrımıdır. Bu ayrım netleşmeden JWT, role, filter chain veya method security anlatımı havada kalır.

Authentication

Soru: Sen kimsin?

Kullanıcının kimliğini doğrular. Login sırasında username/password kontrolü, token doğrulama veya external identity provider’dan gelen bilgi bu kapsamdadır.

Basit örnek
Kullanıcı: gokhan
Şifre doğru mu?
Token geçerli mi?
Bu request gerçekten bu kullanıcıdan mı geliyor?

Authorization

Soru: Ne yapmaya yetkin var?

Kullanıcının erişmek istediği endpoint veya method için gerekli yetkiye sahip olup olmadığını kontrol eder.

Basit örnek
Bu kullanıcı /api/accounts/me endpoint'ine girebilir mi?
Bu kullanıcı ADMIN işlemi yapabilir mi?
Transfer onaylama yetkisi var mı?

Günlük kodda nasıl görünür?

Authentication başarılı olduğunda Spring Security bir Authentication objesi oluşturur ve bunu SecurityContext içine koyar. Authorization aşamasında ise bu Authentication içindeki authority/role bilgilerine bakılarak karar verilir.

Controller içinde kullanıcı bilgisine erişme
@GetMapping("/api/accounts/me")
public AccountResponse me(Authentication authentication) {
    String username = authentication.getName();
    return accountQueryService.findAccountOf(username);
}
Üretim notu

Authentication bilgisinin var olması, kullanıcının her şeyi yapabileceği anlamına gelmez. “Login olmuş kullanıcı” ile “bu işlemi yapmaya yetkili kullanıcı” ayrı kavramlardır. Production’da birçok yetki açığı, bu iki kavramın karıştırılmasından doğar.

Request controller’a gelmeden önce nereden geçer?

Spring Security’nin kalbi filter chain’dir. Controller, service veya repository’den önce HTTP request’in geçtiği güvenlik koridoru gibi düşünebilirsin.

Servlet tabanlı Spring Boot uygulamasında Spring Security, servlet filter mekanizmasını kullanır. Bu zincirde birden fazla filter bulunabilir: token okuyan filter, username/password authentication yapan filter, exception handling filter, authorization filter gibi. Biz çoğu zaman bütün filter’ları tek tek yazmayız; SecurityFilterChain bean’i ile hangi request’in hangi kurala tabi olduğunu tanımlarız.

HTTP Request
Servlet Filter
Spring Security Chain
Authentication
Authorization
Controller

Modern Spring Boot Security config nasıl görünür?

Spring Boot 3 / Spring Security 6+ tarafında eski WebSecurityConfigurerAdapter yaklaşımı yerine genellikle SecurityFilterChain bean’i tanımlanır. Bu yapı daha okunabilir ve daha composition-friendly bir model sunar.

SecurityConfig.java
package com.definex.minibank.security;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;

@Configuration
@EnableMethodSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(
            HttpSecurity http,
            JwtAuthenticationFilter jwtAuthenticationFilter
    ) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(session ->
                        session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/api/auth/**", "/actuator/health").permitAll()
                        .requestMatchers(HttpMethod.GET, "/api/accounts/**").hasAnyRole("USER", "ADMIN")
                        .requestMatchers("/api/admin/**").hasRole("ADMIN")
                        .anyRequest().authenticated()
                )
                .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
                .build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}

Bu config ne söylüyor?

permitAll()

Login ve health gibi endpoint’ler token gerektirmeden çağrılabilir. Herkese açık olması gereken endpoint’leri bilinçli şekilde burada listeleriz.

authenticated()

Diğer endpoint’ler için kimlik doğrulama gerekir. Kullanıcı bilinmiyorsa request controller’a ulaşmadan 401 döner.

hasRole("ADMIN")

Sadece ADMIN rolüne sahip kullanıcıların erişebileceği endpoint’leri tanımlar. Role kontrolü merkezi olarak yapılır.

STATELESS

JWT kullanan REST API’lerde sunucu tarafında session tutmak istemeyiz. Her request kendi token’ı ile kimliğini tekrar kanıtlar.

CSRF notu

Bu eğitimde stateless JWT tabanlı API akışı üzerinden ilerlediğimiz için CSRF’i kapatıyoruz. Browser session ve form-login tabanlı uygulamalarda CSRF konusu ayrıca ele alınmalıdır. Bu karar “her projede kapatılır” anlamına gelmez; mimariye göre değerlendirilir.

JWT ile stateless API güvenliği

JWT, login olmuş kullanıcının kimlik ve yetki bilgisini her request’te taşıyabilmesi için kullanılan imzalı token modelidir.

Session tabanlı uygulamalarda kullanıcı login olduktan sonra sunucu tarafında session tutulur. REST API ve mikroservis mimarisinde ise çoğu zaman stateless yaklaşım tercih edilir. Bu durumda sunucu her kullanıcı için session saklamaz; istemci her request’te token gönderir. Spring Security de bu token’ı okuyup doğrular, ardından SecurityContext’i oluşturur.

Login request
Şifre kontrolü
JWT üretilir
Bearer token gönderilir
Filter token’ı doğrular
SecurityContext oluşur

Bearer token request’te nasıl taşınır?

HTTP Header
GET /api/accounts/me HTTP/1.1
Host: localhost:8080
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

Training için sade JWT yaklaşımı

Bu eğitimde external identity provider veya authorization server kurmayacağız. MiniBank içinde sade bir JwtService ve JwtAuthenticationFilter yazacağız. Ama production’da kurumsal sistemler çoğu zaman OAuth2/OpenID Connect, merkezi identity provider ve resource server konfigürasyonu ile çalışır.

JwtAuthenticationFilter.java — eğitim skeleton
package com.definex.minibank.security;

import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtService jwtService;

    public JwtAuthenticationFilter(JwtService jwtService) {
        this.jwtService = jwtService;
    }

    @Override
    protected void doFilterInternal(
            HttpServletRequest request,
            HttpServletResponse response,
            FilterChain filterChain
    ) throws ServletException, IOException {

        String header = request.getHeader("Authorization");

        if (header == null || !header.startsWith("Bearer ")) {
            filterChain.doFilter(request, response);
            return;
        }

        String token = header.substring(7);
        AuthenticatedUser user = jwtService.parseAndValidate(token);

        UsernamePasswordAuthenticationToken authentication =
                new UsernamePasswordAuthenticationToken(
                        user.username(),
                        null,
                        user.authorities()
                );

        SecurityContextHolder.getContext().setAuthentication(authentication);
        filterChain.doFilter(request, response);
    }
}
Önemli ayrım

JWT üretmek authentication’ın sadece bir parçasıdır. Asıl güvenlik, token’ın güvenilir şekilde imzalanması, süresinin kontrol edilmesi, doğru secret/key yönetimi, yetkilerin doğru taşınması ve filter chain’de doğru noktada doğrulanmasıyla oluşur.

Token’ı imzalamak için kullandığımız secret’ın koda gömülmediğini, Modül 7’deki config ve profile yaklaşımıyla dışarıdan geldiğini hatırla; JwtService bu secret’ı bir SecurityProperties üzerinden okur. Bir adım ileride ise şu var: SecurityContext’e koyduğumuz kullanıcının kim olduğu sadece “erişebilir mi?” sorusunu çözmez. Modül 9’da göreceğin gibi, bu kullanıcı bilgisi veri katmanına da iner; “bu kullanıcı bu hesabın gerçekten sahibi mi?” kontrolü, JWT’den gelen kimliğin repository sorgusuna taşınmasıyla yapılır. Yani authentication’ın çıktısı sadece security katmanında kalmaz, transfer/hesap işlemlerinin doğruluğunu da belirler.

PasswordEncoder neden gerekli?

Şifreleri düz metin olarak saklamak production’da kabul edilemez. Spring Security’de şifre doğrulama için PasswordEncoder kullanılır. Eğitimde BCryptPasswordEncoder göstereceğiz. Böylece login sırasında gelen ham şifre, saklanan hash ile güvenli şekilde karşılaştırılır.

PasswordEncoder kullanımı
boolean matches = passwordEncoder.matches(rawPassword, storedPasswordHash);

if (!matches) {
    throw new InvalidCredentialsException();
}

Refresh token: kısa ömürlü access + uzun ömürlü yenileme

Şu ana kadar MiniBank’ın JwtService sınıfı tek tip token üretiyordu: createToken ile login sonrası bir access token veriliyor. Bu yeterli görünür ama tek bir soru bütün tasarımı değiştirir: bu token kaç dakika geçerli olmalı? Cevabı vermek için önce şunu netleştirmek gerekir. Access token, her request’te taşınan ve sunucu tarafında saklanmayan stateless bir kimlik kartıdır. Stateless olmasının bedeli şudur: token bir kez üretildikten sonra sunucu onu kolayca iptal edemez, çünkü iptal listesi tutmak stateless modelin kendisini bozar.

Bu yüzden access token’ı kısa ömürlü tutarız. Eğer access token sızarsa (loglara düşer, yanlış yere kopyalanır, araya giren biri yakalar), hasar penceresi token’ın geçerlilik süresi kadardır. 10 dakikalık bir access token sızdığında saldırganın elinde en fazla 10 dakika vardır. Ama kullanıcıyı 10 dakikada bir tekrar login ekranına göndermek de kabul edilemez bir deneyim olur. İşte refresh token tam bu gerilimi çözmek için vardır: access token kısa ömürlüdür ve sık expire olur; refresh token ise uzun ömürlüdür ve sadece yeni access token almak için kullanılır, normal API isteklerinde taşınmaz.

Login isteği
Access + refresh üretilir
Access ile API çağrılır
Access expire olur
/auth/refresh çağrılır
Yeni access döner

Akış şöyle ilerler: kullanıcı login olur, sunucu hem kısa ömürlü bir access token hem de uzun ömürlü bir refresh token döner. İstemci normal isteklerinde sadece access token gönderir. Access token expire olduğunda istemci 401 alır, elindeki refresh token ile /api/auth/refresh endpoint’ine gider ve yeni bir access token alır. Kullanıcı bu sırada tekrar login olmaz; deneyim kesintisiz akar.

İki token’ı ayırırken pratikte iki şey değişir: farklı bir geçerlilik süresi (expiration) ve token’ın tipini belirten bir type claim’i. Bu type claim’i kritiktir, çünkü bir refresh token’ın normal API isteğinde access token gibi kullanılmasını engellemenin en sade yoludur. MiniBank’ın mevcut JwtService.createToken yapısını bozmadan, aynı HMAC imzalama mantığını iki ayrı metoda genişletiyoruz.

JwtService.java — createAccessToken + createRefreshToken
// SecurityProperties artık iki ayrı ttl taşıyor:
// accessTtlMinutes (örn. 10), refreshTtlMinutes (örn. 10080 = 7 gün)

public String createAccessToken(AppUser user) {
    return createSignedToken(user, "access", securityProperties.accessTtlMinutes());
}

public String createRefreshToken(AppUser user) {
    return createSignedToken(user, "refresh", securityProperties.refreshTtlMinutes());
}

private String createSignedToken(AppUser user, String type, long ttlMinutes) {
    Instant now = Instant.now();
    Instant expiresAt = now.plusSeconds(ttlMinutes * 60);

    Map<String, Object> header = Map.of("alg", "HS256", "typ", "JWT");

    Map<String, Object> payload = Map.of(
            "iss", securityProperties.issuer(),
            "sub", user.username(),
            "customerNo", user.customerNo(),
            "role", user.role(),
            "type", type,
            "iat", now.getEpochSecond(),
            "exp", expiresAt.getEpochSecond());

    String unsignedToken = base64UrlJson(header) + "." + base64UrlJson(payload);
    return unsignedToken + "." + sign(unsignedToken);
}

/api/auth/refresh endpoint’i ise yalnızca refresh token kabul eder, onu doğrular, type claim’inin gerçekten refresh olduğunu kontrol eder ve yeni bir token çifti döner. Eski refresh token sunucu tarafında iptal edilmediği için bu davranış re-issuance'dır; gerçek rotation/reuse rejection değildir. Bu ileri çalışma sonraki lab için prerequisite değildir.

AuthController.java — /api/auth/refresh iskeleti
@PostMapping("/api/auth/refresh")
public TokenResponse refresh(@RequestBody RefreshRequest request) {
    AuthenticatedUser user = jwtService.parseToken(request.refreshToken());

    // Bir access token ile refresh endpoint'i çağrılmasını engelle
    if (!"refresh".equals(user.tokenType())) {
        throw new JwtTokenException("Refresh token bekleniyor.");
    }

    AppUser appUser = userService.loadByUsername(user.username());
    String newAccessToken = jwtService.createAccessToken(appUser);
    return new TokenResponse(newAccessToken);
}
Üretim notu

Refresh token uzun ömürlü olduğu için onu nerede sakladığın access token’dan daha kritiktir. Tarayıcıda localStorage yerine HttpOnly + Secure cookie tercih edilir; çünkü localStorage JavaScript’e açıktır ve XSS varsa token doğrudan çalınır. İkinci konu rotation’dır: her refresh kullanımında eski refresh token’ı geçersiz kılıp yenisini vermek, çalınmış bir refresh’in tespit edilmesini kolaylaştırır. Ama burada stateless modelin temel gerilimi ortaya çıkar: çalınan bir refresh token’ı gerçekten iptal etmek istiyorsan, bir yerlerde durum tutmak zorundasın. Bu da çoğunlukla token’a bir jti (token id) koyup sunucu tarafında bir blacklist/whitelist veya refresh token tablosu tutmak demektir. Yani “tam stateless” ile “çalınmış token’ı iptal edebilme” aynı anda elde edilemez; üretimde bu ikisi arasında bilinçli bir denge kurulur.

CORS: tarayıcıdan gelen cross-origin istekler

JWT akışını kurduktan sonra MiniBank API’sini bir tarayıcı tabanlı frontend’den (örneğin https://minibank-dashboard.pages.dev) çağırmaya çalıştığında, Postman’de çalışan istek tarayıcıda “CORS error” ile reddedilebilir. Token doğrudur, endpoint doğrudur, ama tarayıcı isteği engeller. Bunun sebebi same-origin policy’dir.

Same-origin policy, tarayıcının bir güvenlik kuralıdır: bir sayfanın JavaScript’i, kendi origin’inden (protokol + host + port üçlüsü) farklı bir origin’e istek attığında, sunucu açıkça izin vermedikçe tarayıcı yanıtı sayfaya teslim etmez. https://minibank-dashboard.pages.dev üzerindeki bir sayfanın https://api.minibank.local adresine attığı istek cross-origin’dir. CORS (Cross-Origin Resource Sharing), sunucunun “bu origin’e izin veriyorum” demesinin standart yoludur.

Bazı isteklerden önce tarayıcı bir preflight isteği gönderir. Bu, asıl istekten önce gönderilen bir OPTIONS isteğidir ve tarayıcı şunu sorar: “Şu origin’den, şu method ve şu header’larla istek atacağım, izin var mı?” Basit GET istekleri preflight gerektirmeyebilir; ama özel bir header taşıyan istekler (örneğin bizim Authorization: Bearer header’ımız) ya da PUT/DELETE gibi methodlar preflight tetikler. Sunucu bu OPTIONS isteğine doğru CORS header’larıyla cevap vermezse, asıl istek hiç gönderilmez.

Spring Security’de CORS’u açmak iki adımdır: bir CorsConfigurationSource bean’i tanımlamak ve SecurityFilterChain içinde .cors(...) ile devreye almak. Aşağıdaki kod, mevcut SecurityConfig yapımıza eklenir.

SecurityConfig.java — CORS bean + filter chain’e bağlama
@Bean
public CorsConfigurationSource corsConfigurationSource() {
    CorsConfiguration config = new CorsConfiguration();
    // Yıldız değil, açık whitelist:
    config.setAllowedOrigins(List.of("https://minibank-dashboard.pages.dev"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
    config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
    config.setAllowCredentials(true);

    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return source;
}

@Bean
public SecurityFilterChain securityFilterChain(
        HttpSecurity http,
        JwtAuthenticationFilter jwtAuthenticationFilter,
        CorsConfigurationSource corsConfigurationSource) throws Exception {

    return http
            .cors(cors -> cors.configurationSource(corsConfigurationSource))
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session ->
                    session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                    .requestMatchers("/api/auth/**").permitAll()
                    .anyRequest().authenticated())
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .build();
}

CORS ile CSRF sık karıştırılır ama farklı problemleri çözerler. CORS, tarayıcının bir origin’den başka bir origin’e okuma/istek yapmasına sunucunun izin verip vermediğiyle ilgilidir. CSRF ise, kullanıcının zaten login olduğu bir sitede, başka bir sitenin kullanıcının tarayıcısındaki kimliğini kötüye kullanarak istek attırmasıyla ilgilidir. MiniBank stateless JWT kullandığı ve token tarayıcı tarafından otomatik gönderilen bir cookie’de değil, JavaScript’in bilinçli koyduğu Authorization header’ında taşındığı için CSRF saldırı yüzeyi büyük ölçüde kapanır; bu yüzden SecurityConfig içinde CSRF’i kapatmıştık. CORS’u açmak ise ayrı ve gerekli bir adımdır.

Üretim notu

En sık yapılan hata allowedOrigins("*") ile allowCredentials(true) kombinasyonudur. Tarayıcı bu ikisini birlikte kabul etmez; ya yıldız ile credentials’sız çalışırsın ya da credentials gönderiyorsan origin’leri açıkça whitelist’lersin. “Geçici çözüm” diye yıldız açmak, çoğu zaman production’a sızan ve her origin’e kapı açan kalıcı bir güvenlik açığına dönüşür. Origin listesini config’ten (Modül 7’de gördüğümüz profile/property yaklaşımı) yönetmek, dev ve prod için farklı whitelist tutmanın temiz yoludur.

Yetki kontrolünü service method seviyesine taşımak

URL seviyesinde güvenlik önemlidir; ama bazı business işlemleri için service method seviyesinde de yetki kontrolü yapmak daha güvenlidir.

Örneğin /api/admin/reports endpoint’i zaten ADMIN gerektiriyor olabilir. Ama aynı rapor üretme method’u başka bir controller’dan, scheduled job’dan veya ileride eklenecek farklı bir akıştan çağrılabilir. Kritik business method’un kendi üzerinde de güvenlik kuralı olması, güvenlik niyetini koda daha yakın taşır.

Method security örneği
@Service
public class AdminReportService {

    @PreAuthorize("hasRole('ADMIN')")
    public AdminReport generateDailyRiskReport() {
        return new AdminReport();
    }
}

@EnableMethodSecurity aktif olduğunda @PreAuthorize gibi annotation’lar devreye girer. Bu da bize URL güvenliği ile method güvenliğini birlikte kullanma imkanı verir.

URL-level security

Request controller’a gelmeden önce çalışır. Endpoint bazlı erişim kuralları için uygundur.

Method-level security

Service method çağrılmadan önce çalışır. Kritik business operasyonlarını korumak için uygundur.

Üretim notu

Method-level security de çoğu zaman proxy mekanizması üzerinden çalışır. AOP modülünde gördüğümüz gibi, proxy bypass edilen çağrılarda beklenen davranışlar şaşırtıcı olabilir. Bu yüzden security, AOP ve transaction konuları birbirinden kopuk düşünülmemelidir.

MiniBank Case 08 — JWT Security & Role-Based Access

MiniBank API’sine stateless JWT güvenliği ekliyoruz: login endpoint’i herkese açık olacak, hesap endpoint’leri login gerektirecek, admin endpoint’leri ise sadece ADMIN rolüyle erişilebilir olacak. Görev listesi, doğrulama komutları, kod adımları ve kabul kriterleri Lab Dashboard'da.

Başlangıç
lab-08-start
Tamamlanmış
lab-08-complete
Checkpoint kuralı

Lab sırasında geride kalırsan lab-08-complete branch'ine geçip herkesle aynı checkpoint'ten devam edebilirsin.

Bu modülden akılda kalması gerekenler

1

Authentication ve authorization farklıdır.

Önce kullanıcının kim olduğu anlaşılır, sonra ne yapmaya yetkili olduğu kontrol edilir.

2

Spring Security controller’dan önce çalışır.

Request güvenlik filter chain’den geçmeden controller method’una ulaşmaz.

3

JWT stateless API güvenliği için sık kullanılan bir modeldir.

Client her request’te bearer token gönderir; server token’ı doğrulayarak SecurityContext oluşturur.

4

Role-based access merkezi tanımlanmalıdır.

Controller içine dağılmış if kontrolleri yerine SecurityFilterChain ve method-level security tercih edilmelidir.

5

Security tasarımı business tasarımıyla birlikte düşünülür.

Hangi endpoint public, hangisi authenticated, hangisi admin-only olacak kararı API contract’ın parçasıdır.

5 soru ile hızlı kontrol

1. Authentication ve authorization arasındaki fark nedir?

Authentication kullanıcının kim olduğunu doğrular. Authorization ise doğrulanmış kullanıcının belirli bir işlemi yapmaya yetkili olup olmadığını kontrol eder.

2. SecurityFilterChain ne işe yarar?

HTTP request controller’a ulaşmadan önce güvenlik filtrelerinden geçirir. Public endpoint, authenticated endpoint ve role-based access kuralları burada tanımlanır.

3. JWT request içinde nasıl gönderilir?

Genellikle Authorization: Bearer <token> header’ı ile gönderilir.

4. 401 ve 403 farkı nedir?

401 kimlik doğrulama yok veya geçersiz demektir. 403 kullanıcı doğrulanmıştır ama istenen işlem için yetkisi yoktur.

5. Method-level security neden kullanılır?

Kritik business method’larını sadece URL seviyesinde değil, service method seviyesinde de korumak için kullanılır.