← Kursa Dön
📄 Text · 30 min

Veritabanı ile Authentication

Gerçek uygulamalarda kullanıcı bilgileri bellekte değil, veritabanında saklanır. Bu derste JPA entity'leri ile Spring Security'yi entegre ederek veritabanı tabanlı authentication kuracağız. Bu, üretim uygulamalarının standart yaklaşımıdır — InMemoryUserDetailsManager geliştirme ortamı içindir, production'da veritabanı kullanılır.

Bunu bir şirketin çalışan yönetimine benzetebilirsiniz. Küçük bir startup'ta herkesin adı bir kağıda yazılı (InMemory). Ama 1000 çalışanlı bir şirkette çalışan bilgileri HR veritabanında tutulur. Yeni çalışan ekleme, çıkarma, yetki güncelleme — hepsi veritabanı üzerinden yönetilir.

User Entity Tasarımı

İlk adım, Spring Security'nin ihtiyaç duyduğu bilgileri taşıyacak JPA entity'leri tasarlamaktır:

@Entity
@Table(name = "users")
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false, length = 100)
    private String username;

    @Column(unique = true, nullable = false, length = 150)
    private String email;

    @Column(nullable = false, length = 255)
    private String password; // BCrypt encoded — VARCHAR(255)

    @Column(nullable = false)
    private boolean enabled = true; // Email doğrulanmış mı?

    @Column(name = "account_locked")
    private boolean accountLocked = false; // Çok fazla yanlış deneme?

    @Column(name = "credentials_expired")
    private boolean credentialsExpired = false; // Şifre süresi doldu mu?

    @Column(name = "created_at")
    private LocalDateTime createdAt;

    @Column(name = "last_login_at")
    private LocalDateTime lastLoginAt;

    @ManyToMany(fetch = FetchType.EAGER)
    @JoinTable(
        name = "user_roles",
        joinColumns = @JoinColumn(name = "user_id"),
        inverseJoinColumns = @JoinColumn(name = "role_id")
    )
    private Set<Role> roles = new HashSet<>();

    @PrePersist
    protected void onCreate() {
        this.createdAt = LocalDateTime.now();
    }

    // Constructors
    public User() {}
    
    public User(String username, String email, String password) {
        this.username = username;
        this.email = email;
        this.password = password;
    }

    // Getter ve Setter'lar...
    public Long getId() { return id; }
    public void setId(Long id) { this.id = id; }
    public String getUsername() { return username; }
    public void setUsername(String username) { this.username = username; }
    public String getEmail() { return email; }
    public void setEmail(String email) { this.email = email; }
    public String getPassword() { return password; }
    public void setPassword(String password) { this.password = password; }
    public boolean isEnabled() { return enabled; }
    public void setEnabled(boolean enabled) { this.enabled = enabled; }
    public boolean isAccountLocked() { return accountLocked; }
    public void setAccountLocked(boolean accountLocked) { this.accountLocked = accountLocked; }
    public Set<Role> getRoles() { return roles; }
    public void setRoles(Set<Role> roles) { this.roles = roles; }
    public LocalDateTime getLastLoginAt() { return lastLoginAt; }
    public void setLastLoginAt(LocalDateTime lastLoginAt) { this.lastLoginAt = lastLoginAt; }
}

⚠️ Dikkat: FetchType.EAGER roller için kullanılıyor çünkü authentication sırasında roller her zaman gereklidir. Ancak çok fazla role/permission varsa performans sorununa yol açabilir — bu durumda FetchType.LAZY ile @Transactional kullanılır.

Role Entity

@Entity
@Table(name = "roles")
public class Role {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(unique = true, nullable = false, length = 50)
    private String name; // ROLE_USER, ROLE_ADMIN, ROLE_EDITOR

    @ManyToMany(mappedBy = "roles")
    @JsonIgnore // Sonsuz döngü önleme
    private Set<User> users = new HashSet<>();

    public Role() {}
    
    public Role(String name) {
        this.name = name;
    }

    // Getter/Setter...
    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
}

ROLE_ prefix'i Spring Security convention'ıdır. hasRole("ADMIN") kontrolü ROLE_ADMIN authority'sini arar. hasAuthority("ROLE_ADMIN") da aynı şeyi yapar ama prefix dahil yazılır.

UserRepository

public interface UserRepository extends JpaRepository<User, Long> {
    Optional<User> findByUsername(String username);
    Optional<User> findByEmail(String email);
    Optional<User> findByUsernameOrEmail(String username, String email);
    boolean existsByUsername(String username);
    boolean existsByEmail(String email);
    
    @Query("SELECT u FROM User u WHERE u.enabled = true AND u.accountLocked = false")
    List<User> findAllActiveUsers();
    
    @Modifying
    @Query("UPDATE User u SET u.lastLoginAt = :time WHERE u.id = :id")
    void updateLastLoginAt(@Param("id") Long id, @Param("time") LocalDateTime time);
}

public interface RoleRepository extends JpaRepository<Role, Long> {
    Optional<Role> findByName(String name);
}

Custom UserDetailsService İmplementasyonu

Şimdi kritik bileşen: Spring Security'ye veritabanından kullanıcı yüklemeyi öğreten servis:

@Service
@RequiredArgsConstructor
public class DatabaseUserDetailsService implements UserDetailsService {

    private final UserRepository userRepository;

    @Override
    @Transactional(readOnly = true)
    public UserDetails loadUserByUsername(String username) 
            throws UsernameNotFoundException {
        
        // Hem username hem email ile arama (login esnekliği)
        User user = userRepository.findByUsernameOrEmail(username, username)
            .orElseThrow(() -> new UsernameNotFoundException(
                "Kullanıcı bulunamadı: " + username));

        return new org.springframework.security.core.userdetails.User(
            user.getUsername(),
            user.getPassword(),
            user.isEnabled(),                    // enabled
            true,                                 // accountNonExpired
            !user.isCredentialsExpired(),         // credentialsNonExpired
            !user.isAccountLocked(),              // accountNonLocked
            getAuthorities(user.getRoles())
        );
    }

    private Collection<? extends GrantedAuthority> getAuthorities(Set<Role> roles) {
        return roles.stream()
            .map(role -> new SimpleGrantedAuthority(role.getName()))
            .collect(Collectors.toSet());
    }
}

Kritik noktalar:

  • @Transactional(readOnly = true) — Lazy-loading sorunlarını önler ve read-only transaction açar

  • UsernameNotFoundException fırlatmak zorunlu — Spring Security bu exception'ı yakalar

  • SimpleGrantedAuthorityGrantedAuthority arayüzünün basit implementasyonu

  • Hem username hem email ile arama — kullanıcı her ikisiyle de giriş yapabilir

Custom UserDetails İmplementasyonu

Spring Security'nin User sınıfı yerine, kendi UserDetails implementasyonunuzu yazabilirsiniz. Bu, kullanıcıya ek bilgiler (ID, e-posta, profil resmi) eklemek için gereklidir:

@RequiredArgsConstructor
public class CustomUserPrincipal implements UserDetails {

    private final User user; // JPA Entity

    @Override
    public Collection<? extends GrantedAuthority> getAuthorities() {
        return user.getRoles().stream()
            .map(role -> new SimpleGrantedAuthority(role.getName()))
            .collect(Collectors.toSet());
    }

    @Override
    public String getPassword() { return user.getPassword(); }

    @Override
    public String getUsername() { return user.getUsername(); }

    @Override
    public boolean isAccountNonExpired() { return true; }

    @Override
    public boolean isAccountNonLocked() { return !user.isAccountLocked(); }

    @Override
    public boolean isCredentialsNonExpired() { return !user.isCredentialsExpired(); }

    @Override
    public boolean isEnabled() { return user.isEnabled(); }

    // ─── Ek Metotlar — JPA entity'sine erişim ─────────────
    public Long getId() { return user.getId(); }
    public String getEmail() { return user.getEmail(); }
    public LocalDateTime getLastLoginAt() { return user.getLastLoginAt(); }
    
    public boolean hasRole(String roleName) {
        return user.getRoles().stream()
            .anyMatch(role -> role.getName().equals("ROLE_" + roleName));
    }
}

Custom principal kullanırken service'i güncelleyin:

@Override
public UserDetails loadUserByUsername(String username) 
        throws UsernameNotFoundException {
    User user = userRepository.findByUsernameOrEmail(username, username)
        .orElseThrow(() -> new UsernameNotFoundException("Bulunamadı: " + username));
    return new CustomUserPrincipal(user);
}

Controller'da custom bilgilere erişim:

@GetMapping("/profile")
public ResponseEntity<Map<String, Object>> profile(
        @AuthenticationPrincipal CustomUserPrincipal principal) {
    return ResponseEntity.ok(Map.of(
        "id", principal.getId(),
        "username", principal.getUsername(),
        "email", principal.getEmail(),
        "roles", principal.getAuthorities().stream()
            .map(GrantedAuthority::getAuthority).toList(),
        "lastLogin", principal.getLastLoginAt() != null 
            ? principal.getLastLoginAt().toString() : "İlk giriş"
    ));
}

Kayıt (Registration) Endpoint'i

Kullanıcı kaydı için tam bir service implementasyonu:

@Service
@RequiredArgsConstructor
public class UserService {

    private final UserRepository userRepository;
    private final RoleRepository roleRepository;
    private final PasswordEncoder passwordEncoder;

    @Transactional
    public UserDto registerUser(RegisterRequest request) {
        // Validasyonlar
        if (userRepository.existsByUsername(request.getUsername())) {
            throw new DuplicateFieldException(
                "Kullanıcı adı zaten kullanılıyor: " + request.getUsername());
        }
        if (userRepository.existsByEmail(request.getEmail())) {
            throw new DuplicateFieldException(
                "E-posta zaten kullanılıyor: " + request.getEmail());
        }

        // Entity oluştur
        User user = new User();
        user.setUsername(request.getUsername());
        user.setEmail(request.getEmail());
        user.setPassword(passwordEncoder.encode(request.getPassword()));
        user.setEnabled(true);

        // Varsayılan rol ata
        Role userRole = roleRepository.findByName("ROLE_USER")
            .orElseThrow(() -> new RuntimeException("ROLE_USER bulunamadı"));
        user.getRoles().add(userRole);

        User saved = userRepository.save(user);
        return UserDto.fromEntity(saved);
    }

    @Transactional(readOnly = true)
    public UserDto findById(Long id) {
        return userRepository.findById(id)
            .map(UserDto::fromEntity)
            .orElseThrow(() -> new UserNotFoundException("Kullanıcı bulunamadı: " + id));
    }
}

Registration controller:

@RestController
@RequestMapping("/api/auth")
public class AuthController {

    private final UserService userService;

    public AuthController(UserService userService) {
        this.userService = userService;
    }

    @PostMapping("/register")
    public ResponseEntity<UserDto> register(@RequestBody @Valid RegisterRequest request) {
        UserDto created = userService.registerUser(request);
        return ResponseEntity.status(HttpStatus.CREATED).body(created);
    }
}

SecurityConfig ile Entegrasyon

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        return http
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
                .requestMatchers("/login", "/register", "/css/**", "/js/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .requestMatchers("/editor/**").hasAnyRole("EDITOR", "ADMIN")
                .anyRequest().authenticated()
            )
            .formLogin(form -> form
                .loginPage("/login")
                .defaultSuccessUrl("/dashboard")
                .permitAll()
            )
            .logout(logout -> logout
                .logoutSuccessUrl("/login?logout")
                .permitAll()
            )
            .build();
    }

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

    // UserDetailsService bean'i @Service ile zaten tanımlı (DatabaseUserDetailsService)
    // Spring Security otomatik olarak bulur ve kullanır
}

DTO — Veri Transfer Nesneleri

Entity'leri doğrudan API yanıtı olarak döndürmeyin — DTO kullanın:

public record UserDto(Long id, String username, String email, 
                       List<String> roles, LocalDateTime createdAt) {
    
    public static UserDto fromEntity(User user) {
        return new UserDto(
            user.getId(),
            user.getUsername(),
            user.getEmail(),
            user.getRoles().stream().map(Role::getName).toList(),
            user.getCreatedAt()
        );
    }
}

public record RegisterRequest(
    @NotBlank @Size(min = 3, max = 50) String username,
    @NotBlank @Email String email,
    @NotBlank @Size(min = 8) String password
) {}

DTO kullanmak şu sorunları önler: password hash'inin API'de görünmesi, JPA lazy-loading exception'ları, entity değişikliklerinin API'yi kırması.

Veritabanı Şeması

CREATE TABLE users (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    username VARCHAR(100) NOT NULL UNIQUE,
    email VARCHAR(150) NOT NULL UNIQUE,
    password VARCHAR(255) NOT NULL,
    enabled BOOLEAN DEFAULT TRUE,
    account_locked BOOLEAN DEFAULT FALSE,
    credentials_expired BOOLEAN DEFAULT FALSE,
    created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
    last_login_at TIMESTAMP NULL
);

CREATE TABLE roles (
    id BIGINT AUTO_INCREMENT PRIMARY KEY,
    name VARCHAR(50) NOT NULL UNIQUE
);

CREATE TABLE user_roles (
    user_id BIGINT NOT NULL,
    role_id BIGINT NOT NULL,
    PRIMARY KEY (user_id, role_id),
    FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
    FOREIGN KEY (role_id) REFERENCES roles(id) ON DELETE CASCADE
);

-- Varsayılan roller
INSERT INTO roles (name) VALUES ('ROLE_USER'), ('ROLE_ADMIN'), ('ROLE_EDITOR');

-- Test kullanıcısı (şifre: admin123, BCrypt ile hash'lenmiş)
INSERT INTO users (username, email, password, enabled)
VALUES ('admin', 'admin@example.com', 
    '$2a$12$LJ3m4ys3NyIZS1GntKnmNOeOVoFIJkDjkXoTXQrCW8gVn.VdC3nHa', true);
    
INSERT INTO user_roles (user_id, role_id)
VALUES (1, 1), (1, 2); -- admin → ROLE_USER + ROLE_ADMIN

Login Sonrası Son Giriş Zamanı Güncelleme

@Component
@RequiredArgsConstructor
public class LoginSuccessListener {

    private final UserRepository userRepository;

    @EventListener
    @Transactional
    public void onLoginSuccess(AuthenticationSuccessEvent event) {
        String username = event.getAuthentication().getName();
        userRepository.findByUsername(username).ifPresent(user -> {
            user.setLastLoginAt(LocalDateTime.now());
            userRepository.save(user);
        });
    }
}

Hesap Kilitleme — Brute Force Koruması

@Service
@RequiredArgsConstructor
public class LoginAttemptService {

    private final Map<String, Integer> attemptsCache = new ConcurrentHashMap<>();
    private static final int MAX_ATTEMPTS = 5;

    public void loginFailed(String username) {
        int attempts = attemptsCache.getOrDefault(username, 0) + 1;
        attemptsCache.put(username, attempts);
    }

    public void loginSucceeded(String username) {
        attemptsCache.remove(username);
    }

    public boolean isBlocked(String username) {
        return attemptsCache.getOrDefault(username, 0) >= MAX_ATTEMPTS;
    }
}

@Component
@RequiredArgsConstructor
public class AuthEventListener {

    private final LoginAttemptService loginAttemptService;
    private final UserRepository userRepository;

    @EventListener
    public void onFailure(AuthenticationFailureBadCredentialsEvent event) {
        String username = (String) event.getAuthentication().getPrincipal();
        loginAttemptService.loginFailed(username);
        
        if (loginAttemptService.isBlocked(username)) {
            userRepository.findByUsername(username).ifPresent(user -> {
                user.setAccountLocked(true);
                userRepository.save(user);
            });
        }
    }

    @EventListener
    public void onSuccess(AuthenticationSuccessEvent event) {
        loginAttemptService.loginSucceeded(event.getAuthentication().getName());
    }
}

Özet

  • Veritabanı tabanlı authentication, üretim uygulamalarının standart yaklaşımıdır. InMemory sadece geliştirme içindir.

  • UserDetailsService implementasyonu, JPA Repository ile veritabanından kullanıcı yükler. @Transactional(readOnly = true) ile lazy-loading sorunları önlenir.

  • Custom UserDetails implementasyonu (CustomUserPrincipal), kullanıcıya ek bilgiler (id, email) eklemenizi ve @AuthenticationPrincipal ile erişmenizi sağlar.

  • GrantedAuthority, roller ve izinleri temsil eder. Convention: ROLE_ prefix'li olanlar rol, prefix'siz olanlar granüler izindir.

  • Registration flow: username/email benzersizlik kontrolü → şifre encoding → varsayılan rol atama → kaydetme.

  • Login event listener ile son giriş zamanı, brute force koruması ve audit logging yapılabilir.

  • ManyToMany ilişki (User ↔ Role) ile bir kullanıcının birden fazla rolü, bir rolün birden fazla kullanıcısı olabilir. FetchType.EAGER kullanarak authentication sırasında roller otomatik yüklenir.

  • Brute force koruması: Belirli sayıda başarısız deneme sonrası hesap otomatik kilitlenir. AuthenticationFailureBadCredentialsEvent dinlenerek implementasyon yapılır.