Adem TONAY @ ademtonay.com

JWT ve Spring Boot ile Kimlik Doğrulama

Dec 3 · 6dk

Web uygulamalarında güvenlik, özellikle kimlik doğrulama ve yetkilendirme süreçleri son derece kritik bir rol oynamaktadır. Bu yazıda, Spring Boot uygulamanızda JWT (JSON Web Token) kullanarak kullanıcı kimlik doğrulaması nasıl yapılır, detaylı bir şekilde anlatacağım.

1. Gerekli Bağımlılıkların Eklenmesi

Spring Boot ile JWT tabanlı kimlik doğrulama ve yetkilendirme işlemleri gerçekleştirebilmek için bazı temel bağımlılıkları projenize dahil etmeniz gerekmektedir. Bu bağımlılıklar, güvenlik ve JWT işlemleri için gereklidir. Aşağıda, Spring Boot projenize bu bağımlılıkların nasıl ekleneceği açıklanmıştır.

Maven Kullanıyorsanız:

pom.xml dosyanıza aşağıdaki bağımlılıkları ekleyerek gerekli kütüphaneleri projenize dahil edebilirsiniz.

<dependencies>
    <!-- Spring Security -->
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-security</artifactId>
    </dependency>
    <!-- JJWT (Java JWT) -->
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-api</artifactId>
        <version>0.11.5</version>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-impl</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
    <dependency>
        <groupId>io.jsonwebtoken</groupId>
        <artifactId>jjwt-jackson</artifactId>
        <version>0.11.5</version>
        <scope>runtime</scope>
    </dependency>
</dependencies>

Gradle Kullanıyorsanız:

Eğer Gradle kullanıyorsanız, aşağıdaki bağımlılıkları build.gradle dosyanıza ekleyebilirsiniz.

dependencies {
  // Spring Security
  implementation 'org.springframework.boot:spring-boot-starter-security'

  // JJWT (Java JWT) API, Implementation ve Jackson desteği
  implementation 'io.jsonwebtoken:jjwt-api:0.11.5'
  runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5'
  runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5'
}

2. Kullanıcı Bilgilerinin Güvenli Bir Şekilde Saklanması: AuthDto

İlk adım olarak, kullanıcı bilgilerini saklayacağımız bir DTO (Data Transfer Object) sınıfı oluşturmalıyız. Bu sınıf, JWT token içinde yer alacak bilgileri temsil edecektir. Örneğin, kullanıcı id’si, email adresi ve rollerini içerecek bir sınıf:

@Builder
@Getter
@Setter
public class AuthDto {
    private UUID id;
    private String email;
    private List<GrantedAuthority> roles;
}

Bu DTO, kullanıcı bilgilerini taşıyacak ve güvenli bir şekilde SecurityContext içinde kullanılacaktır.

3. JWT Oluşturma ve Doğrulama: TokenService

JWT’yi oluşturup doğrulayacak olan servis sınıfını yazmamız gerekiyor. Bu sınıf, token’ın üretilmesinden, doğrulama ve güvenlik bağlamını (authentication) oluşturma sürecine kadar olan tüm işlemleri yönetecek.

@RequiredArgsConstructor
@Service
public class TokenService implements ITokenService {
    private final IUserRepository userRepository;
    private final int MILLISECOND_IN_MINUTE = 1000 * 60; // Zaman hesaplamalarında kullanmak için milisaniye cinsinden dakika çevirisi
    private final SignatureAlgorithm SIGNATURE_ALGORITHM = SignatureAlgorithm.HS256; // Kullanılacak imzalama algoritması

    @Value("${security.token.access.secret}")
    private String accessTokenSecret; // Access token için gizli anahtar

    @Value("${security.token.access.expires}")
    private int accessTokenExpires; // Access token'in geçerlilik süresi (dakika cinsinden)

    // Kullanıcı objesinden JWT'nin oluşturulması
    @Override
    public String generateAccessToken(User user) {
        Date now = new Date(); // Şu anki zaman
        int expiresIn = accessTokenExpires * MILLISECOND_IN_MINUTE; // Token'ın geçerlilik süresi

        // JWT token'ı oluşturuluyor
        return Jwts.builder()
                .setSubject(user.getEmail()) // Kullanıcının email adresi
                .claim("id", user.getId()) // Kullanıcının id'si
                .claim("roles", rolesToString(user.getRoles())) // Kullanıcının rollerini string olarak ekliyoruz
                .setIssuedAt(now) // Token'ın oluşturulma zamanı
                .setExpiration(new Date(now.getTime() + expiresIn)) // Token'ın son kullanma tarihi
                .signWith(jwtKeyGenerator(accessTokenSecret), SIGNATURE_ALGORITHM) // JWT imzalaması
                .compact(); // Token'ı oluştur
    }

    // JWT token'ından kullanıcı kimlik doğrulaması (Authentication) oluşturulması
    @Override
    public Authentication getAuthenticationFromAccessToken(String accessToken) {
        Claims claims = Jwts.parserBuilder()
                .setSigningKey(jwtKeyGenerator(accessTokenSecret)) // JWT'nin imzasını doğrulamak için gizli anahtar
                .build().parseClaimsJws(accessToken).getBody(); // JWT token'ını çöz

        // Token'dan roller bilgisini alıyoruz ve GrantedAuthority objelerine dönüştürüyoruz
        List<GrantedAuthority> roles = stringToRoles(claims.get("roles", String.class));

        // Authentication objesini oluşturuyoruz
        AuthDto authDto = AuthDto.builder()
                .id(UUID.fromString(claims.get("id", String.class))) // Kullanıcının UUID'sini al
                .email(claims.getSubject()) // Kullanıcının e-posta adresini al
                .roles(roles) // Kullanıcının rollerini ekle
                .build();

        // Kullanıcı bilgisiyle birlikte authentication objesini döndür
        return new UsernamePasswordAuthenticationToken(authDto, null, roles);
    }

    // Kullanıcının rollerini string formatına dönüştüren yardımcı metot
    private String rolesToString(Collection<Role> roles) {
        return roles.stream()
                .map(Role::name) // Rollerin isimlerini al
                .collect(Collectors.joining(",")); // Virgülle ayırarak tek bir string'e çevir
    }

    // String formatındaki rollerin GrantedAuthority listesine dönüştürülmesi
    private List<GrantedAuthority> stringToRoles(String rolesString) {
        if (rolesString == null || rolesString.isEmpty()) {
            return Collections.emptyList(); // Eğer roller boşsa, boş bir liste döndür
        }

        // String içindeki rollerden her birini GrantedAuthority'ye dönüştür
        return Arrays.stream(rolesString.split(",\\s*"))
                .map(role -> Role.valueOf(role)) // Her rolü Role enum'ına dönüştür
                .filter(Objects::nonNull) // Null olmayanları filtrele
                .map(role -> new SimpleGrantedAuthority(role.name())) // GrantedAuthority nesnelerine dönüştür
                .collect(Collectors.toList());
    }

    // JWT için gizli anahtarın oluşturulması
    private Key jwtKeyGenerator(String secret) {
        return Keys.hmacShaKeyFor(Decoders.BASE64.decode(secret)); // BASE64 ile decode edilmiş anahtarı kullanıyoruz
    }
}

4. JWT Kontrolü İçin AuthTokenFilter

Her gelen istekte, JWT’nin doğruluğunu kontrol etmek için bir filtre kullanıyoruz. Bu filtre, JWT’nin geçerli olup olmadığını kontrol eder ve geçerli ise kullanıcıyı doğrular.

@RequiredArgsConstructor
@Component
public class AuthTokenFilter extends OncePerRequestFilter {
    private final ITokenService tokenService; // TokenService, JWT doğrulama işlemleri için kullanılır

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        String url = request.getRequestURI(); // Gelen isteğin URL'sini al

        // Eğer URL '/api/auth/*' ile başlıyorsa, JWT kontrolü yapma
        if (url.matches("/api/auth/*")) {
            filterChain.doFilter(request, response);
            return;
        }

        // Cookie içinde access token'ı alıyoruz
        Cookie accessTokenCookie = WebUtils.getCookie(request, "accessToken");

        // Eğer token yoksa, hata mesajı ekleyip, filtreyi geçiyoruz
        if (accessTokenCookie == null) {
            attachError(request, ErrorCode.ACCESS_TOKEN_DOES_NOT_EXIST); // Hata mesajı ekle
            filterChain.doFilter(request, response); // Filtreyi geç
            return;
        }

        try {
            // Token'ı doğrulayıp Authentication objesini alıyoruz
            Authentication authentication = tokenService.getAuthenticationFromAccessToken(accessTokenCookie.getValue());

            // SecurityContext'e doğrulama bilgilerini yerleştiriyoruz
            SecurityContextHolder.getContext().setAuthentication(authentication);
        } catch (ExpiredJwtException e) {
            // Token süresi dolmuşsa, hata mesajı ekleyip, SecurityContext'i temizliyoruz
            attachErrorAndClearAuthContext(request, ErrorCode.ACCESS_TOKEN_EXPIRED);
        } catch (MalformedJwtException e) {
            // Token bozulmuşsa, hata mesajı ekleyip, SecurityContext'i temizliyoruz
            attachErrorAndClearAuthContext(request, ErrorCode.ACCESS_TOKEN_MALFORMED);
        } catch (RuntimeException e) {
            // Diğer hata durumlarında, hata mesajı ekleyip, SecurityContext'i temizliyoruz
            attachErrorAndClearAuthContext(request, ErrorCode.INTERNAL_SERVER);
        }

        filterChain.doFilter(request, response); // Filtreyi geç
    }

    private void attachError(HttpServletRequest request, ErrorCode errorCode) {
        // Hata mesajlarını request'e ekliyoruz
        request.setAttribute("exceptionStatus", errorCode.getHttpStatusCode());
        request.setAttribute("exceptionCode", errorCode.getCode());
        request.setAttribute("exceptionMessage", errorCode.getMessage());
    }

    private void attachErrorAndClearAuthContext(HttpServletRequest request, ErrorCode errorCode) {
        // Hata mesajlarını ekleyip, SecurityContext'i temizliyoruz
        attachError(request, errorCode);
        SecurityContextHolder.clearContext(); // Authentication bilgilerini temizle
    }
}

5. JWT Hatalarını Yönetmek İçin AuthExceptionHandler

JWT doğrulama sırasında oluşabilecek hataları yönetmek için kullanılan AuthenticationEntryPoint, kullanıcılara doğru hata mesajları göndermek için yapılandırılır.

@Slf4j
@Component
public class AuthExceptionHandler implements AuthenticationEntryPoint {
    final ObjectMapper MAPPER = new ObjectMapper(); // JSON yanıtları için ObjectMapper

    @Override
    public void commence(HttpServletRequest request, HttpServletResponse response, AuthenticationException authException) throws IOException, ServletException {
        log.error("Unauthorized error: {}", authException.getMessage()); // Hata mesajını logluyoruz

        response.setContentType(MediaType.APPLICATION_JSON_VALUE); // Yanıt türünü JSON olarak belirliyoruz
        response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); // HTTP 401 (Unauthorized) durum kodu

        final Map<String, Object> body = new HashMap<>(); // Yanıt için hata bilgilerini içeren bir map

        // Eğer hata bilgileri request içinde varsa, bunları alıp JSON yanıt olarak gönderiyoruz
        if (request.getAttribute("exceptionCode") != null &&
            request.getAttribute("exceptionMessage") != null &&
            request.getAttribute("exceptionStatus") != null) {
            body.put("code", request.getAttribute("exceptionCode").toString());
            body.put("message", request.getAttribute("exceptionMessage").toString());
            body.put("status", Integer.parseInt(request.getAttribute("exceptionStatus").toString()));
        } else {
            // Eğer hata bilgileri yoksa, genel bir yetkilendirme hatası mesajı gönderiyoruz
            body.put("code", ErrorCode.AUTHENTICATION_REQUIRED.getCode());
            body.put("message", ErrorCode.AUTHENTICATION_REQUIRED.getMessage());
            body.put("status", ErrorCode.AUTHENTICATION_REQUIRED.getHttpStatusCode());
        }

        // JSON yanıtı gönder
        MAPPER.writeValue(response.getOutputStream(), body);
    }
}

6. Spring Security Yapılandırması: SecurityConfig

Son olarak, JWT doğrulama filtremizi ve hata yönetimi sınıfımızı Spring Security yapılandırmasına dahil ediyoruz. Bu yapılandırma, tüm uygulama genelinde güvenliği sağlayacak şekilde filtre ve doğrulama işlemleri yapar.

@RequiredArgsConstructor
@Configuration
@EnableWebSecurity
public class SecurityConfig {
    private final AuthTokenFilter authTokenFilter; // AuthTokenFilter, her gelen istekte JWT doğrulamasını yapacak
    private final AuthExceptionHandler authExceptionHandler; // AuthExceptionHandler, hata mesajlarını yönetir

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity httpSecurity) throws Exception {
        return httpSecurity
                .exceptionHandling(customizer -> customizer.authenticationEntryPoint(authExceptionHandler)) // Hata yönetimi sınıfını kullan
                .addFilterBefore(authTokenFilter, BasicAuthenticationFilter.class) // JWT doğrulama filtresini yerleştir
                .csrf(AbstractHttpConfigurer::disable) // CSRF korumasını devre dışı bırak
                .sessionManagement(customizer -> customizer.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) // Stateless oturum yönetimi
                .authorizeHttpRequests(requests -> requests
                        .requestMatchers("/api/auth/*").permitAll() // Kimlik doğrulaması gerektirmeyen yollar
                        .anyRequest().authenticated()) // Diğer tüm isteklerde kimlik doğrulaması gerektir
                .build(); // Yapılandırmayı uygula
    }
}

Sonuç

Bu yazıda, Spring Boot uygulamalarında JWT tabanlı kimlik doğrulama ve yetkilendirme işlemleri nasıl yapılır, bunu inceledik. JWT’nin oluşturulması, doğrulanması, güvenlik filtreleri ve hata yönetimi gibi temel adımları olabildiğince yalın ve detaylı anlatmaya çalıştım. Sorularınız için benimle iletişime geçebilirsiniz.

>
CC BY-NC-SA 4.0 2021-PRESENT © Adem TONAY