← Kursa Dön
📄 Text · 25 min

UserDetails ve InMemory Authentication

Spring Security'nin kullanıcı bilgilerini nasıl yüklediğini anlamak, tüm authentication mekanizmasının temelidir. UserDetailsService arayüzü bu sürecin kalbidir — Spring Security'ye "kullanıcı bilgilerini nereden al" demenin yoludur. İster bellekten, ister veritabanından, ister LDAP'den — her durumda bu arayüz kullanılır.

Bunu bir otelin resepsiyon görevlisine benzetebilirsiniz. Misafir adını söyler, resepsiyonist rezervasyon defterinde (UserDetailsService) o ismi arar. Bulursa oda bilgilerini (UserDetails) çıkarır — oda numarası, yetki kartı (rol), hesap durumu (aktif mi?). Misafir bulunamazsa "kaydınız yok" (UsernameNotFoundException) der.

UserDetails Interface — Kullanıcının Kimlik Kartı

UserDetails, Spring Security'nin bir kullanıcıyı temsil etmek için kullandığı arayüzdür. Kullanıcı adı, şifre, roller ve hesap durumu gibi bilgileri taşır:

public interface UserDetails extends Serializable {
    // Kullanıcının sahip olduğu yetkiler (roller + izinler)
    Collection<? extends GrantedAuthority> getAuthorities();
    
    // Hash'lenmiş şifre
    String getPassword();
    
    // Kullanıcı adı (benzersiz tanımlayıcı)
    String getUsername();

    // Hesap durumu kontrolleri — hepsi true dönmelidir ki giriş yapabilsin
    boolean isAccountNonExpired();     // Hesap süresi dolmadı mı?
    boolean isAccountNonLocked();      // Hesap kilitli değil mi?
    boolean isCredentialsNonExpired(); // Şifre süresi dolmadı mı?
    boolean isEnabled();               // Hesap aktif mi?
}

Bu dört boolean metot, farklı hesap durumlarını kontrol eder. Her biri farklı bir iş senaryosuna karşılık gelir:

Metotfalse DönerseKullanım Senaryosu
isAccountNonExpired()Hesap süresi dolmuşAbonelik bazlı sistemler, trial süre bitimi
isAccountNonLocked()Hesap kilitli5 yanlış şifre → hesap kilitleme
isCredentialsNonExpired()Şifre süresi dolmuş90 günde bir şifre yenileme zorunluluğu
isEnabled()Hesap devre dışıEmail doğrulanmamış, admin tarafından devre dışı

Herhangi biri false dönerse giriş başarısız olur ve Spring Security uygun hata mesajını üretir: DisabledException, LockedException, AccountExpiredException, CredentialsExpiredException.

Spring Security'nin User Builder'ı

Spring Security, User adında hazır bir UserDetails implementasyonu sunar:

// Builder pattern ile kullanıcı oluşturma
UserDetails user = User.builder()
    .username("ali")
    .password("{bcrypt}$2a$10$...") // Encoded şifre
    .roles("USER")                  // ROLE_USER authority'sine dönüşür
    .build();

// Authority ile — daha granüler yetkiler
UserDetails admin = User.builder()
    .username("admin")
    .password("{bcrypt}$2a$10$...")
    .authorities("ROLE_ADMIN", "REPORT_VIEW", "USER_MANAGE", "SYSTEM_CONFIG")
    .build();

// Hesap durumu kontrolleri
UserDetails lockedUser = User.builder()
    .username("locked")
    .password("{bcrypt}$2a$10$...")
    .roles("USER")
    .accountLocked(true)     // Hesap kilitli
    .build();

UserDetails disabledUser = User.builder()
    .username("unverified")
    .password("{bcrypt}$2a$10$...")
    .roles("USER")
    .disabled(true)          // Hesap devre dışı (email doğrulanmamış)
    .build();

⚠️ Dikkat: roles("USER") çağrısı otomatik olarak ROLE_ prefix'i ekler ve ROLE_USER authority'si oluşturur. authorities() ise doğrudan verilen string'leri kullanır — prefix eklemez. hasRole("ADMIN") kontrolü ROLE_ADMIN authority'sini arar; hasAuthority("REPORT_VIEW") ise doğrudan REPORT_VIEW'i arar.

UserDetailsService Interface — Kullanıcı Kaynağı

UserDetailsService, tek bir metot tanımlayan basit bir arayüzdür:

public interface UserDetailsService {
    UserDetails loadUserByUsername(String username) throws UsernameNotFoundException;
}

Spring Security authentication sırasında bu metodu çağırır:

  1. Kullanıcı adını alır

  2. UserDetails nesnesini döndürür

  3. Bulunamazsa UsernameNotFoundException fırlatır

Bu arayüzü implemente ederek kullanıcı bilgilerinin herhangi bir kaynaktan yüklenmesini sağlayabilirsiniz:

  • Bellek (InMemoryUserDetailsManager) — geliştirme/test

  • Veritabanı (JPA Repository) — production

  • LDAP (LdapUserDetailsService) — enterprise kurumsal

  • Harici API (REST çağrısı) — microservice mimarisi

InMemoryUserDetailsManager — Geliştirme Ortamı İçin

Geliştirme ve test ortamları için en basit UserDetailsService implementasyonu InMemoryUserDetailsManager'dır. Kullanıcıları bellekte saklar — uygulama kapandığında kaybolur:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public UserDetailsService userDetailsService() {
        UserDetails user = User.builder()
            .username("user")
            .password(passwordEncoder().encode("user123"))
            .roles("USER")
            .build();

        UserDetails editor = User.builder()
            .username("editor")
            .password(passwordEncoder().encode("editor123"))
            .roles("USER", "EDITOR")
            .build();

        UserDetails admin = User.builder()
            .username("admin")
            .password(passwordEncoder().encode("admin123"))
            .roles("USER", "ADMIN")
            .authorities("ROLE_USER", "ROLE_ADMIN", 
                         "USER_MANAGE", "REPORT_VIEW", "SYSTEM_CONFIG")
            .build();

        return new InMemoryUserDetailsManager(user, editor, admin);
    }

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

InMemoryUserDetailsManager, UserDetailsManager arayüzünü de implemente eder — yani createUser(), updateUser(), deleteUser(), changePassword() gibi CRUD metotları da sunar. Ancak uygulama yeniden başladığında tüm değişiklikler kaybolur.

// InMemoryUserDetailsManager CRUD operasyonları
InMemoryUserDetailsManager manager = new InMemoryUserDetailsManager();

// Kullanıcı oluştur
manager.createUser(User.builder()
    .username("newuser")
    .password(passwordEncoder.encode("pass"))
    .roles("USER")
    .build());

// Kullanıcı var mı kontrol et
boolean exists = manager.userExists("newuser"); // true

// Kullanıcı güncelle
manager.updateUser(User.builder()
    .username("newuser")
    .password(passwordEncoder.encode("newpass"))
    .roles("USER", "EDITOR")
    .build());

// Kullanıcı sil
manager.deleteUser("newuser");

Authentication Provider Akışı

UserDetailsService'in authentication akışındaki yerini net görelim:

1. Kullanıcı: username=ali, password=secret123 gönderir
       ↓
2. UsernamePasswordAuthenticationFilter → Token oluşturur
   [principal="ali", credentials="secret123", authenticated=false]
       ↓
3. AuthenticationManager → ProviderManager
       ↓
4. ProviderManager → DaoAuthenticationProvider (varsayılan)
       ↓
5. DaoAuthenticationProvider:
   a. userDetailsService.loadUserByUsername("ali") → UserDetails
   b. Kullanıcı bulunamazsa → UsernameNotFoundException
   c. Hesap durumu kontrolü (locked, disabled, expired)
   d. passwordEncoder.matches("secret123", userDetails.getPassword())
   e. Şifre eşleşmezse → BadCredentialsException
   f. Eşleşme varsa → Authenticated token döner
      [principal=UserDetails, credentials=null, authenticated=true]
       ↓
6. SecurityContextHolder'a authenticated token set edilir
       ↓
7. Session'a kaydedilir → Sonraki isteklerde tekrar login gerekmez

DaoAuthenticationProvider, Spring Security'nin varsayılan authentication provider'ıdır. UserDetailsService ve PasswordEncoder bean'lerini otomatik bulur ve kullanır — ek yapılandırma gerekmez.

💡 İpucu: DaoAuthenticationProvider kullanıcı bulunamadığında ve şifre yanlış olduğunda aynı hata mesajını verir: "Bad credentials". Bu, güvenlik açısından bilinçli bir tasarım kararıdır — saldırgana "kullanıcı adı doğru ama şifre yanlış" gibi bilgi sızdırmaz.

Custom UserDetailsService İmplementasyonu

İleriki derslerde veritabanı ile çalışacağız, ama şimdi basit bir custom implementasyonu görelim:

@Service
public class CustomUserDetailsService implements UserDetailsService {

    // Basit in-memory Map (ileride JPA Repository olacak)
    private final Map<String, UserDetails> users = new ConcurrentHashMap<>();

    public CustomUserDetailsService(PasswordEncoder encoder) {
        users.put("ali", User.builder()
            .username("ali")
            .password(encoder.encode("ali123"))
            .roles("USER")
            .build());

        users.put("admin", User.builder()
            .username("admin")
            .password(encoder.encode("admin123"))
            .roles("ADMIN", "USER")
            .build());

        users.put("editor", User.builder()
            .username("editor")
            .password(encoder.encode("editor123"))
            .authorities("ROLE_USER", "ROLE_EDITOR", "CONTENT_CREATE", "CONTENT_EDIT")
            .build());
    }

    @Override
    public UserDetails loadUserByUsername(String username) 
            throws UsernameNotFoundException {
        
        UserDetails user = users.get(username.toLowerCase().trim());
        
        if (user == null) {
            // UsernameNotFoundException fırlatmak ZORUNLU
            throw new UsernameNotFoundException(
                "Kullanıcı bulunamadı: " + username);
        }
        
        // Yeni bir User nesnesi döndürmek iyi pratiktir
        // Orijinal nesnenin değiştirilmesini önler
        return new org.springframework.security.core.userdetails.User(
            user.getUsername(),
            user.getPassword(),
            user.isEnabled(),
            user.isAccountNonExpired(),
            user.isCredentialsNonExpired(),
            user.isAccountNonLocked(),
            user.getAuthorities()
        );
    }
}

⚠️ Dikkat: UsernameNotFoundException fırlatmak kritiktir — Spring Security bu exception'ı yakalayıp uygun HTTP yanıtını oluşturur. null döndürmek veya boş Optional döndürmek yanlıştırNullPointerException'a yol açar.

GrantedAuthority — Roller ve İzinler

public interface GrantedAuthority extends Serializable {
    String getAuthority(); // "ROLE_ADMIN", "REPORT_VIEW", "USER_DELETE"
}

Spring Security'de roller ve izinler aynı mekanizma (GrantedAuthority) ile temsil edilir. Fark sadece convention'dadır:

// Rol: Geniş yetki grubu (ROLE_ prefix'i ile)
SimpleGrantedAuthority role = new SimpleGrantedAuthority("ROLE_ADMIN");

// İzin: Granüler yetki (prefix'siz)
SimpleGrantedAuthority permission = new SimpleGrantedAuthority("USER_DELETE");

// Kontrol:
hasRole("ADMIN")              // ROLE_ADMIN arar (prefix otomatik)
hasAuthority("ROLE_ADMIN")    // ROLE_ADMIN arar (prefix manuel)
hasAuthority("USER_DELETE")   // USER_DELETE arar

Gerçek dünyada bir kullanıcının hem rolleri hem de granüler izinleri olabilir:

UserDetails user = User.builder()
    .username("editor")
    .password(encodedPassword)
    .authorities(
        "ROLE_USER",           // Rol
        "ROLE_EDITOR",         // Rol
        "CONTENT_CREATE",      // İzin
        "CONTENT_EDIT",        // İzin
        "CONTENT_PUBLISH",     // İzin
        "COMMENT_MODERATE"     // İzin
    )
    .build();

Controller'da Kullanıcı Bilgisine Erişim

Giriş yapmış kullanıcının bilgilerine Controller'dan erişmenin birkaç yolu vardır:

@RestController
@RequestMapping("/api")
public class UserController {

    // 1. SecurityContextHolder ile (her yerden erişilebilir — service, util)
    @GetMapping("/me-v1")
    public String currentUser1() {
        Authentication auth = SecurityContextHolder.getContext().getAuthentication();
        return "Hoş geldin, " + auth.getName();
    }

    // 2. Principal parametresi ile (daha temiz)
    @GetMapping("/me-v2")
    public String currentUser2(Principal principal) {
        return "Hoş geldin, " + principal.getName();
    }

    // 3. @AuthenticationPrincipal ile (en temiz — tip güvenli)
    @GetMapping("/me-v3")
    public Map<String, Object> currentUser3(
            @AuthenticationPrincipal UserDetails userDetails) {
        return Map.of(
            "username", userDetails.getUsername(),
            "roles", userDetails.getAuthorities().stream()
                .map(GrantedAuthority::getAuthority)
                .toList(),
            "enabled", userDetails.isEnabled()
        );
    }

    // 4. Authentication parametresi ile (en fazla bilgi)
    @GetMapping("/me-v4")
    public Map<String, Object> currentUser4(Authentication authentication) {
        return Map.of(
            "name", authentication.getName(),
            "authenticated", authentication.isAuthenticated(),
            "authorities", authentication.getAuthorities().toString(),
            "details", authentication.getDetails() != null 
                ? authentication.getDetails().toString() : "N/A"
        );
    }

    // 5. Custom UserDetails ile (kendi tipleriniz)
    @GetMapping("/me-v5")
    public Map<String, Object> currentUser5(
            @AuthenticationPrincipal CustomUserPrincipal user) {
        return Map.of(
            "id", user.getId(),
            "username", user.getUsername(),
            "email", user.getEmail()
        );
    }
}

@AuthenticationPrincipal, Principal nesnesini doğrudan belirtilen tipe cast eder. Custom UserDetails implementasyonunuz varsa, kendi tipinizi de kullanabilirsiniz.

Yaygın Hatalar ve Çözümleri

Hata 1: PasswordEncoder Bean Tanımlanmamış

// ❌ HATA: There is no PasswordEncoder mapped for the id "null"
@Bean
public UserDetailsService userDetailsService() {
    return new InMemoryUserDetailsManager(
        User.builder()
            .username("user")
            .password("password123") // Encode edilmemiş şifre!
            .roles("USER")
            .build()
    );
}

// ✅ ÇÖZÜM 1: PasswordEncoder bean tanımlayın
@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

@Bean
public UserDetailsService userDetailsService() {
    return new InMemoryUserDetailsManager(
        User.builder()
            .username("user")
            .password(passwordEncoder().encode("password123"))
            .roles("USER")
            .build()
    );
}

// ✅ ÇÖZÜM 2: {noop} prefix'i (SADECE TEST İÇİN!)
@Bean
public UserDetailsService userDetailsService() {
    return new InMemoryUserDetailsManager(
        User.builder()
            .username("user")
            .password("{noop}password123") // Düz metin — asla production'da kullanmayın!
            .roles("USER")
            .build()
    );
}

Hata 2: roles() ve authorities() Karıştırılması

// ❌ YANLIŞ — roles() zaten ROLE_ prefix ekler, tekrarlama
User.builder()
    .username("admin")
    .password(encoder.encode("pass"))
    .roles("ROLE_ADMIN")  // → ROLE_ROLE_ADMIN olur!
    .build();

// ✅ DOĞRU
User.builder()
    .username("admin")
    .password(encoder.encode("pass"))
    .roles("ADMIN")  // → ROLE_ADMIN olur
    .build();

// Veya authorities ile doğrudan:
User.builder()
    .username("admin")
    .password(encoder.encode("pass"))
    .authorities("ROLE_ADMIN", "USER_MANAGE")  // Prefix elle yazılır
    .build();

Hata 3: Birden Fazla UserDetailsService Bean

// ❌ SORUN — İki bean çakışır
@Bean
public UserDetailsService inMemoryService() { /* ... */ }

@Bean
public UserDetailsService databaseService() { /* ... */ }
// Spring hangisini kullanacağını bilemez!

// ✅ ÇÖZÜM — @Primary ile belirtin
@Bean
@Primary  // Bu bean varsayılan olarak kullanılır
public UserDetailsService databaseService() { /* ... */ }

@Bean
public UserDetailsService inMemoryService() { /* ... */ }

Test'te UserDetails Kullanımı

@WebMvcTest(UserController.class)
class UserControllerTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    @WithMockUser(username = "ali", roles = {"USER"})
    void shouldAccessUserEndpoint() throws Exception {
        mockMvc.perform(get("/api/me"))
            .andExpect(status().isOk());
    }

    @Test
    @WithMockUser(username = "admin", roles = {"ADMIN"})
    void shouldAccessAdminEndpoint() throws Exception {
        mockMvc.perform(get("/admin/dashboard"))
            .andExpect(status().isOk());
    }

    @Test
    void shouldReturn401WithoutAuth() throws Exception {
        mockMvc.perform(get("/api/me"))
            .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(username = "user", roles = {"USER"})
    void shouldReturn403ForUnauthorizedAccess() throws Exception {
        mockMvc.perform(get("/admin/dashboard"))
            .andExpect(status().isForbidden());
    }
}

@WithMockUser annotation'ı, test sırasında belirtilen kullanıcı ile authentication yapılmış gibi davranır. Gerçek UserDetailsService çağrılmaz — sadece mock kullanıcı oluşturulur.

Profil Bazlı Yapılandırma

Geliştirme ve production ortamlarında farklı UserDetailsService kullanabilirsiniz:

// Geliştirme ortamı — bellek tabanlı
@Configuration
@Profile("dev")
public class DevSecurityConfig {
    @Bean
    public UserDetailsService userDetailsService(PasswordEncoder encoder) {
        return new InMemoryUserDetailsManager(
            User.builder().username("dev").password(encoder.encode("dev"))
                .roles("ADMIN", "USER").build()
        );
    }
}

// Production ortamı — veritabanı tabanlı
@Configuration
@Profile("prod")
public class ProdSecurityConfig {
    @Bean
    public UserDetailsService userDetailsService(UserRepository userRepository) {
        return new DatabaseUserDetailsService(userRepository);
    }
}

Özet

  • UserDetailsService, Spring Security'ye kullanıcı bilgilerinin kaynağını söyler. Tek bir metodu vardır: loadUserByUsername().

  • UserDetails, kullanıcının kimliğini, şifresini, rollerini ve hesap durumunu taşır. Dört boolean metot (enabled, locked, expired) farklı hesap durumlarını kontrol eder.

  • InMemoryUserDetailsManager, geliştirme ortamı için hızlı bir çözümdür. Production'da veritabanı tabanlı implementasyona geçilir.

  • `roles("ADMIN")` otomatik ROLE_ prefix'i ekler; `authorities()` doğrudan string kullanır. hasRole() ve hasAuthority() arasındaki fark bu prefix'tedir.

  • UsernameNotFoundException fırlatmak zorunludur — null veya Optional.empty() döndürmeyin.

  • Controller'da kullanıcı bilgisine en temiz erişim `@AuthenticationPrincipal` ile sağlanır.

  • Profil bazlı yapılandırma ile dev'de InMemory, prod'da Database kullanabilirsiniz.