← Back to all posts

JWT ve Refresh Token İmplementasyonu

JWT ve Refresh Token İmplementasyonu

JWT ve Refresh Token İmplementasyonu

Neden JWT Tek Başına Yetmez?

JWT (JSON Web Token) stateless authentication'ın temel taşıdır. Sunucu tarafında oturum saklamak yerine token doğrulama yeterlidir. Ama tek bir sorun var:

JWT iptal edilemez.

Token imzalandı mı, süresi dolana kadar geçerlidir. Kullanıcı şifresini değiştirdi, hesabı askıya alındı, çıkış yaptı — hiçbirinin önemi yoktur. Eski token hâlâ çalışır.

Bu yüzden access token kısa ömürlü (15 dakika) tutulur. Kısa ömürlü token kullanıcıyı her 15 dakikada bir tekrar giriş yapmaya zorlar — bu da kabul edilemez.

Çözüm: Refresh Token.

Access Token  → Kısa ömürlü (15 dakika), stateless, API erişimi için
Refresh Token → Uzun ömürlü (7 gün), veritabanında saklı, yeni access token almak için
Kullanıcı giriş yapar
       ↓
Access Token (15 dk) + Refresh Token (7 gün) verilir
       ↓
İstemci her istekte Access Token kullanır
       ↓
Access Token süresi doldu (401)
       ↓
İstemci Refresh Token ile yeni Access Token ister
       ↓
Refresh Token geçerliyse → Yeni Access Token + Yeni Refresh Token
(Rotation — eski refresh token geçersiz)
       ↓
Refresh Token de süresi dolduysa → Tekrar giriş

Proje Yapısı

src/
├── MyApp.Domain/
│   └── Entities/
│       ├── User.cs
│       └── RefreshToken.cs
├── MyApp.Application/
│   └── Auth/
│       ├── Commands/
│       │   ├── Login/
│       │   ├── RefreshToken/
│       │   ├── Logout/
│       │   └── RevokeToken/
│       └── Interfaces/
│           ├── ITokenService.cs
│           └── ICurrentUserService.cs
├── MyApp.Infrastructure/
│   └── Auth/
│       ├── TokenService.cs
│       ├── CurrentUserService.cs
│       └── JwtSettings.cs
└── MyApp.API/
    └── Controllers/
        └── AuthController.cs

Domain Modeli

// MyApp.Domain/Entities/User.cs
public class User : BaseEntity
{
    public string Email { get; private set; } = string.Empty;
    public string PasswordHash { get; private set; } = string.Empty;
    public string Name { get; private set; } = string.Empty;
    public string Role { get; private set; } = "User";
    public bool IsActive { get; private set; } = true;
    public DateTime? LastLoginAt { get; private set; }
    public int FailedLoginAttempts { get; private set; }
    public DateTime? LockoutEnd { get; private set; }

    private readonly List<RefreshToken> _refreshTokens = [];
    public IReadOnlyCollection<RefreshToken> RefreshTokens => _refreshTokens.AsReadOnly();

    private User() { }

    public static User Create(string email, string name, string passwordHash, string role = "User")
    {
        return new User
        {
            Id = Guid.NewGuid(),
            Email = email.ToLowerInvariant(),
            Name = name,
            PasswordHash = passwordHash,
            Role = role,
            CreatedAt = DateTime.UtcNow
        };
    }

    public bool IsLockedOut() =>
        LockoutEnd.HasValue && LockoutEnd.Value > DateTime.UtcNow;

    public void RecordFailedLogin()
    {
        FailedLoginAttempts++;

        // 5 başarısız denemede 15 dakika kilitle
        if (FailedLoginAttempts >= 5)
        {
            LockoutEnd = DateTime.UtcNow.AddMinutes(15);
            FailedLoginAttempts = 0;
        }
    }

    public void RecordSuccessfulLogin()
    {
        FailedLoginAttempts = 0;
        LockoutEnd = null;
        LastLoginAt = DateTime.UtcNow;
    }

    public void AddRefreshToken(RefreshToken token)
    {
        // Eski ve kullanılmış token'ları temizle
        var expiredTokens = _refreshTokens
            .Where(t => t.IsExpired || t.IsRevoked)
            .ToList();

        foreach (var expired in expiredTokens)
            _refreshTokens.Remove(expired);

        _refreshTokens.Add(token);
    }

    public RefreshToken? GetActiveRefreshToken(string token) =>
        _refreshTokens.FirstOrDefault(t =>
            t.Token == token && t.IsActive);

    public void RevokeAllRefreshTokens(string reason)
    {
        foreach (var token in _refreshTokens.Where(t => t.IsActive))
            token.Revoke(reason);
    }

    public void Deactivate() => IsActive = false;
}

// MyApp.Domain/Entities/RefreshToken.cs
public class RefreshToken
{
    public Guid Id { get; private set; } = Guid.NewGuid();
    public Guid UserId { get; private set; }
    public string Token { get; private set; } = string.Empty;
    public DateTime ExpiresAt { get; private set; }
    public DateTime CreatedAt { get; private set; } = DateTime.UtcNow;
    public string CreatedByIp { get; private set; } = string.Empty;
    public DateTime? RevokedAt { get; private set; }
    public string? RevokedByIp { get; private set; }
    public string? RevokedReason { get; private set; }
    public string? ReplacedByToken { get; private set; }

    public bool IsExpired => DateTime.UtcNow >= ExpiresAt;
    public bool IsRevoked => RevokedAt.HasValue;
    public bool IsActive => !IsExpired && !IsRevoked;

    private RefreshToken() { }

    public static RefreshToken Create(Guid userId, string token, string ipAddress, int expiryDays = 7)
    {
        return new RefreshToken
        {
            UserId = userId,
            Token = token,
            ExpiresAt = DateTime.UtcNow.AddDays(expiryDays),
            CreatedByIp = ipAddress
        };
    }

    public void Revoke(string reason, string? ipAddress = null, string? replacedByToken = null)
    {
        RevokedAt = DateTime.UtcNow;
        RevokedReason = reason;
        RevokedByIp = ipAddress;
        ReplacedByToken = replacedByToken;
    }
}

JWT Ayarları

// MyApp.Infrastructure/Auth/JwtSettings.cs
public class JwtSettings
{
    public string SecretKey { get; set; } = string.Empty;
    public string Issuer { get; set; } = string.Empty;
    public string Audience { get; set; } = string.Empty;
    public int AccessTokenExpiryMinutes { get; set; } = 15;
    public int RefreshTokenExpiryDays { get; set; } = 7;
}
// appsettings.json
{
  "JwtSettings": {
    "Issuer": "MyApp",
    "Audience": "MyApp.Client",
    "AccessTokenExpiryMinutes": 15,
    "RefreshTokenExpiryDays": 7
  }
}
// appsettings.Production.json — SecretKey buraya değil, environment variable'a
{
  "JwtSettings": {
    "Issuer": "MyApp",
    "Audience": "MyApp.Client",
    "AccessTokenExpiryMinutes": 15,
    "RefreshTokenExpiryDays": 7
  }
}
# Environment variable — asla appsettings.json'a yazmayın
export JwtSettings__SecretKey="base64-encoded-256-bit-minimum-secret-key-here"

Token Service

// MyApp.Application/Auth/Interfaces/ITokenService.cs
public interface ITokenService
{
    string GenerateAccessToken(User user);
    string GenerateRefreshToken();
    ClaimsPrincipal? GetPrincipalFromExpiredToken(string token);
}

// MyApp.Infrastructure/Auth/TokenService.cs
public class TokenService : ITokenService
{
    private readonly JwtSettings _jwtSettings;
    private readonly ILogger<TokenService> _logger;

    public TokenService(IOptions<JwtSettings> jwtSettings, ILogger<TokenService> logger)
    {
        _jwtSettings = jwtSettings.Value;
        _logger = logger;
    }

    public string GenerateAccessToken(User user)
    {
        var key = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(_jwtSettings.SecretKey));

        var credentials = new SigningCredentials(
            key, SecurityAlgorithms.HmacSha256);

        var claims = new[]
        {
            new Claim(JwtRegisteredClaimNames.Sub, user.Id.ToString()),
            new Claim(JwtRegisteredClaimNames.Email, user.Email),
            new Claim(JwtRegisteredClaimNames.Name, user.Name),
            new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()),
            new Claim(JwtRegisteredClaimNames.Iat,
                DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(),
                ClaimValueTypes.Integer64),
            new Claim(ClaimTypes.Role, user.Role)
        };

        var token = new JwtSecurityToken(
            issuer: _jwtSettings.Issuer,
            audience: _jwtSettings.Audience,
            claims: claims,
            notBefore: DateTime.UtcNow,
            expires: DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpiryMinutes),
            signingCredentials: credentials);

        return new JwtSecurityTokenHandler().WriteToken(token);
    }

    public string GenerateRefreshToken()
    {
        // Kriptografik açıdan güvenli rastgele token
        var randomBytes = new byte[64];
        using var rng = RandomNumberGenerator.Create();
        rng.GetBytes(randomBytes);
        return Convert.ToBase64String(randomBytes);
    }

    public ClaimsPrincipal? GetPrincipalFromExpiredToken(string token)
    {
        var tokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(
                Encoding.UTF8.GetBytes(_jwtSettings.SecretKey)),
            ValidateIssuer = true,
            ValidIssuer = _jwtSettings.Issuer,
            ValidateAudience = true,
            ValidAudience = _jwtSettings.Audience,
            ValidateLifetime = false // Süresi dolmuş token'ı da kabul et
        };

        var tokenHandler = new JwtSecurityTokenHandler();

        try
        {
            var principal = tokenHandler.ValidateToken(
                token, tokenValidationParameters, out var securityToken);

            if (securityToken is not JwtSecurityToken jwtToken ||
                !jwtToken.Header.Alg.Equals(
                    SecurityAlgorithms.HmacSha256,
                    StringComparison.InvariantCultureIgnoreCase))
            {
                return null;
            }

            return principal;
        }
        catch (Exception ex)
        {
            _logger.LogWarning(ex, "Geçersiz token işleme girişimi.");
            return null;
        }
    }
}

Command Handler'lar

Login

// Application/Auth/Commands/Login/LoginCommand.cs
public record LoginCommand(
    string Email,
    string Password,
    string IpAddress) : IRequest<AuthResult>;

public record AuthResult(
    string AccessToken,
    string RefreshToken,
    DateTime AccessTokenExpiry,
    UserDto User);

public record UserDto(
    Guid Id,
    string Email,
    string Name,
    string Role);

// Application/Auth/Commands/Login/LoginCommandHandler.cs
public class LoginCommandHandler : IRequestHandler<LoginCommand, AuthResult>
{
    private readonly AppDbContext _context;
    private readonly ITokenService _tokenService;
    private readonly ILogger<LoginCommandHandler> _logger;
    private readonly JwtSettings _jwtSettings;

    public LoginCommandHandler(
        AppDbContext context,
        ITokenService tokenService,
        ILogger<LoginCommandHandler> logger,
        IOptions<JwtSettings> jwtSettings)
    {
        _context = context;
        _tokenService = tokenService;
        _logger = logger;
        _jwtSettings = jwtSettings.Value;
    }

    public async Task<AuthResult> Handle(LoginCommand command, CancellationToken ct)
    {
        var email = command.Email.ToLowerInvariant();

        var user = await _context.Users
            .Include(u => u.RefreshTokens)
            .FirstOrDefaultAsync(u => u.Email == email, ct);

        // Kullanıcı bulunamadı — timing attack'ı önlemek için aynı süre harca
        if (user is null)
        {
            await SimulatePasswordHashingAsync();
            throw new UnauthorizedAccessException("Email veya şifre hatalı.");
        }

        // Hesap kilitli mi?
        if (user.IsLockedOut())
        {
            _logger.LogWarning(
                "Kilitli hesaba giriş denemesi. Email: {Email} | IP: {IP}",
                email, command.IpAddress);

            throw new UnauthorizedAccessException(
                "Hesabınız geçici olarak kilitlendi. Lütfen 15 dakika sonra tekrar deneyin.");
        }

        // Şifre doğrulama
        if (!BCrypt.Net.BCrypt.Verify(command.Password, user.PasswordHash))
        {
            user.RecordFailedLogin();
            await _context.SaveChangesAsync(ct);

            _logger.LogWarning(
                "Başarısız giriş denemesi. Email: {Email} | IP: {IP} | Deneme: {Attempt}",
                email, command.IpAddress, user.FailedLoginAttempts);

            throw new UnauthorizedAccessException("Email veya şifre hatalı.");
        }

        // Hesap aktif mi?
        if (!user.IsActive)
            throw new UnauthorizedAccessException("Hesabınız aktif değil.");

        // Token üret
        var accessToken = _tokenService.GenerateAccessToken(user);
        var refreshTokenValue = _tokenService.GenerateRefreshToken();

        var refreshToken = RefreshToken.Create(
            user.Id,
            refreshTokenValue,
            command.IpAddress,
            _jwtSettings.RefreshTokenExpiryDays);

        user.AddRefreshToken(refreshToken);
        user.RecordSuccessfulLogin();

        await _context.SaveChangesAsync(ct);

        _logger.LogInformation(
            "Başarılı giriş. UserId: {UserId} | Email: {Email} | IP: {IP}",
            user.Id, user.Email, command.IpAddress);

        return new AuthResult(
            accessToken,
            refreshTokenValue,
            DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpiryMinutes),
            new UserDto(user.Id, user.Email, user.Name, user.Role));
    }

    private static async Task SimulatePasswordHashingAsync()
    {
        // Kullanıcı bulunamadığında da aynı süre geçmeli
        // Yoksa email enumeration saldırısı mümkün olur
        await Task.Delay(Random.Shared.Next(200, 400));
    }
}

Refresh Token

// Application/Auth/Commands/RefreshToken/RefreshTokenCommand.cs
public record RefreshTokenCommand(
    string AccessToken,
    string RefreshToken,
    string IpAddress) : IRequest<AuthResult>;

// Application/Auth/Commands/RefreshToken/RefreshTokenCommandHandler.cs
public class RefreshTokenCommandHandler
    : IRequestHandler<RefreshTokenCommand, AuthResult>
{
    private readonly AppDbContext _context;
    private readonly ITokenService _tokenService;
    private readonly ILogger<RefreshTokenCommandHandler> _logger;
    private readonly JwtSettings _jwtSettings;

    public RefreshTokenCommandHandler(
        AppDbContext context,
        ITokenService tokenService,
        ILogger<RefreshTokenCommandHandler> logger,
        IOptions<JwtSettings> jwtSettings)
    {
        _context = context;
        _tokenService = tokenService;
        _logger = logger;
        _jwtSettings = jwtSettings.Value;
    }

    public async Task<AuthResult> Handle(
        RefreshTokenCommand command,
        CancellationToken ct)
    {
        // Süresi dolmuş access token'dan kullanıcı bilgisi al
        var principal = _tokenService.GetPrincipalFromExpiredToken(command.AccessToken);

        if (principal is null)
        {
            _logger.LogWarning(
                "Geçersiz access token ile refresh denemesi. IP: {IP}",
                command.IpAddress);
            throw new UnauthorizedAccessException("Geçersiz token.");
        }

        var userIdClaim = principal.FindFirst(ClaimTypes.NameIdentifier)?.Value;
        if (!Guid.TryParse(userIdClaim, out var userId))
            throw new UnauthorizedAccessException("Geçersiz token.");

        var user = await _context.Users
            .Include(u => u.RefreshTokens)
            .FirstOrDefaultAsync(u => u.Id == userId, ct)
            ?? throw new UnauthorizedAccessException("Kullanıcı bulunamadı.");

        if (!user.IsActive)
            throw new UnauthorizedAccessException("Hesabınız aktif değil.");

        // Refresh token geçerli mi?
        var existingRefreshToken = user.GetActiveRefreshToken(command.RefreshToken);

        if (existingRefreshToken is null)
        {
            // Token zaten kullanılmış veya geçersiz
            // Bu durum token çalınmış olduğuna işaret edebilir — tümünü iptal et
            var revokedToken = user.RefreshTokens
                .FirstOrDefault(t => t.Token == command.RefreshToken);

            if (revokedToken is not null)
            {
                // Daha önce kullanılmış token tekrar kullanıldı — olası güvenlik ihlali
                _logger.LogError(
                    "Kullanılmış refresh token yeniden kullanım denemesi. " +
                    "UserId: {UserId} | IP: {IP} — Tüm token'lar iptal ediliyor.",
                    userId, command.IpAddress);

                // Tüm refresh token'ları iptal et
                user.RevokeAllRefreshTokens("Kullanılmış token yeniden kullanım denemesi tespit edildi.");
                await _context.SaveChangesAsync(ct);
            }

            throw new UnauthorizedAccessException("Refresh token geçersiz veya süresi dolmuş.");
        }

        // Token rotation — eski iptal, yeni üret
        var newRefreshTokenValue = _tokenService.GenerateRefreshToken();
        var newRefreshToken = RefreshToken.Create(
            user.Id,
            newRefreshTokenValue,
            command.IpAddress,
            _jwtSettings.RefreshTokenExpiryDays);

        // Eski token'ı iptal et, yenisiyle değiştir
        existingRefreshToken.Revoke(
            "Rotation — yeni token üretildi.",
            command.IpAddress,
            newRefreshTokenValue);

        user.AddRefreshToken(newRefreshToken);

        // Yeni access token üret
        var newAccessToken = _tokenService.GenerateAccessToken(user);

        await _context.SaveChangesAsync(ct);

        _logger.LogInformation(
            "Token yenilendi. UserId: {UserId} | IP: {IP}",
            user.Id, command.IpAddress);

        return new AuthResult(
            newAccessToken,
            newRefreshTokenValue,
            DateTime.UtcNow.AddMinutes(_jwtSettings.AccessTokenExpiryMinutes),
            new UserDto(user.Id, user.Email, user.Name, user.Role));
    }
}

Logout

// Application/Auth/Commands/Logout/LogoutCommand.cs
public record LogoutCommand(
    string RefreshToken,
    string IpAddress) : IRequest<Unit>;

public class LogoutCommandHandler : IRequestHandler<LogoutCommand, Unit>
{
    private readonly AppDbContext _context;
    private readonly ICurrentUserService _currentUser;
    private readonly ILogger<LogoutCommandHandler> _logger;

    public LogoutCommandHandler(
        AppDbContext context,
        ICurrentUserService currentUser,
        ILogger<LogoutCommandHandler> logger)
    {
        _context = context;
        _currentUser = currentUser;
        _logger = logger;
    }

    public async Task<Unit> Handle(LogoutCommand command, CancellationToken ct)
    {
        var user = await _context.Users
            .Include(u => u.RefreshTokens)
            .FirstOrDefaultAsync(u => u.Id == _currentUser.UserId, ct);

        if (user is null) return Unit.Value;

        var refreshToken = user.GetActiveRefreshToken(command.RefreshToken);

        if (refreshToken is not null)
        {
            refreshToken.Revoke("Kullanıcı çıkış yaptı.", command.IpAddress);
            await _context.SaveChangesAsync(ct);
        }

        _logger.LogInformation(
            "Kullanıcı çıkış yaptı. UserId: {UserId} | IP: {IP}",
            user.Id, command.IpAddress);

        return Unit.Value;
    }
}

Controller

// API/Controllers/AuthController.cs
[ApiController]
[Route("api/auth")]
public class AuthController : ControllerBase
{
    private readonly IMediator _mediator;

    public AuthController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpPost("login")]
    [AllowAnonymous]
    public async Task<IActionResult> Login(
        [FromBody] LoginRequest request,
        CancellationToken ct)
    {
        var result = await _mediator.Send(new LoginCommand(
            request.Email,
            request.Password,
            GetIpAddress()), ct);

        // Refresh token'ı HTTP-only cookie'ye koy
        SetRefreshTokenCookie(result.RefreshToken);

        // Access token response body'de döndür
        return Ok(new LoginResponse(
            result.AccessToken,
            result.AccessTokenExpiry,
            result.User));
    }

    [HttpPost("refresh")]
    [AllowAnonymous]
    public async Task<IActionResult> Refresh(
        [FromBody] RefreshTokenRequest request,
        CancellationToken ct)
    {
        // Refresh token cookie'den okunur
        var refreshToken = Request.Cookies["refreshToken"];

        if (string.IsNullOrEmpty(refreshToken))
            return Unauthorized(new ProblemDetails
            {
                Status = 401,
                Title = "Refresh token bulunamadı."
            });

        var result = await _mediator.Send(new RefreshTokenCommand(
            request.AccessToken,
            refreshToken,
            GetIpAddress()), ct);

        SetRefreshTokenCookie(result.RefreshToken);

        return Ok(new LoginResponse(
            result.AccessToken,
            result.AccessTokenExpiry,
            result.User));
    }

    [HttpPost("logout")]
    [Authorize]
    public async Task<IActionResult> Logout(CancellationToken ct)
    {
        var refreshToken = Request.Cookies["refreshToken"] ?? string.Empty;

        await _mediator.Send(new LogoutCommand(refreshToken, GetIpAddress()), ct);

        // Cookie'yi temizle
        Response.Cookies.Delete("refreshToken");

        return NoContent();
    }

    [HttpPost("revoke-all")]
    [Authorize]
    public async Task<IActionResult> RevokeAll(CancellationToken ct)
    {
        await _mediator.Send(new RevokeAllTokensCommand(GetIpAddress()), ct);
        Response.Cookies.Delete("refreshToken");
        return NoContent();
    }

    private void SetRefreshTokenCookie(string refreshToken)
    {
        var cookieOptions = new CookieOptions
        {
            HttpOnly = true,        // JavaScript erişemez — XSS koruması
            Secure = true,          // Sadece HTTPS
            SameSite = SameSiteMode.Strict, // CSRF koruması
            Expires = DateTime.UtcNow.AddDays(7)
        };

        Response.Cookies.Append("refreshToken", refreshToken, cookieOptions);
    }

    private string GetIpAddress()
    {
        // Reverse proxy arkasındaysa gerçek IP
        if (Request.Headers.TryGetValue("X-Forwarded-For", out var forwardedFor))
            return forwardedFor.ToString().Split(',')[0].Trim();

        return HttpContext.Connection.RemoteIpAddress?.ToString() ?? "unknown";
    }
}

// Request / Response DTO'ları
public record LoginRequest(string Email, string Password);

public record RefreshTokenRequest(string AccessToken);

public record LoginResponse(
    string AccessToken,
    DateTime ExpiresAt,
    UserDto User);

Program.cs Yapılandırması

// Program.cs
builder.Services.Configure<JwtSettings>(
    builder.Configuration.GetSection("JwtSettings"));

builder.Services.AddScoped<ITokenService, TokenService>();
builder.Services.AddScoped<ICurrentUserService, CurrentUserService>();

// JWT Authentication
builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
})
.AddJwtBearer(options =>
{
    var jwtSettings = builder.Configuration.GetSection("JwtSettings").Get<JwtSettings>()!;

    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidateIssuerSigningKey = true,
        IssuerSigningKey = new SymmetricSecurityKey(
            Encoding.UTF8.GetBytes(jwtSettings.SecretKey)),

        ValidateIssuer = true,
        ValidIssuer = jwtSettings.Issuer,

        ValidateAudience = true,
        ValidAudience = jwtSettings.Audience,

        ValidateLifetime = true,
        ClockSkew = TimeSpan.Zero  // Varsayılan 5 dk toleransı kaldır
    };

    // Token geçersiz olduğunda detaylı hata
    options.Events = new JwtBearerEvents
    {
        OnAuthenticationFailed = context =>
        {
            if (context.Exception is SecurityTokenExpiredException)
            {
                context.Response.Headers["Token-Expired"] = "true";
            }

            return Task.CompletedTask;
        },
        OnChallenge = context =>
        {
            context.HandleResponse();
            context.Response.StatusCode = 401;
            context.Response.ContentType = "application/problem+json";

            var result = JsonSerializer.Serialize(new ProblemDetails
            {
                Status = 401,
                Title = "Yetkisiz Erişim",
                Detail = context.Error ?? "Token geçersiz veya süresi dolmuş."
            });

            return context.Response.WriteAsync(result);
        },
        OnForbidden = context =>
        {
            context.Response.StatusCode = 403;
            context.Response.ContentType = "application/problem+json";

            var result = JsonSerializer.Serialize(new ProblemDetails
            {
                Status = 403,
                Title = "Erişim Reddedildi",
                Detail = "Bu işlem için yetkiniz bulunmuyor."
            });

            return context.Response.WriteAsync(result);
        }
    };
});

builder.Services.AddAuthorization();

var app = builder.Build();

app.UseAuthentication();
app.UseAuthorization();

Current User Service

// Application/Auth/Interfaces/ICurrentUserService.cs
public interface ICurrentUserService
{
    Guid? UserId { get; }
    string? Email { get; }
    string? Name { get; }
    string? Role { get; }
    bool IsAuthenticated { get; }
}

// Infrastructure/Auth/CurrentUserService.cs
public class CurrentUserService : ICurrentUserService
{
    private readonly IHttpContextAccessor _httpContextAccessor;

    public CurrentUserService(IHttpContextAccessor httpContextAccessor)
    {
        _httpContextAccessor = httpContextAccessor;
    }

    private ClaimsPrincipal? User =>
        _httpContextAccessor.HttpContext?.User;

    public Guid? UserId
    {
        get
        {
            var claim = User?.FindFirst(ClaimTypes.NameIdentifier)?.Value;
            return Guid.TryParse(claim, out var id) ? id : null;
        }
    }

    public string? Email => User?.FindFirst(ClaimTypes.Email)?.Value;
    public string? Name => User?.FindFirst(ClaimTypes.Name)?.Value;
    public string? Role => User?.FindFirst(ClaimTypes.Role)?.Value;
    public bool IsAuthenticated => User?.Identity?.IsAuthenticated ?? false;
}

EF Core Konfigürasyonu

// Infrastructure/Persistence/Configurations/UserConfiguration.cs
public class UserConfiguration : IEntityTypeConfiguration<User>
{
    public void Configure(EntityTypeBuilder<User> builder)
    {
        builder.HasKey(u => u.Id);

        builder.Property(u => u.Email)
            .IsRequired()
            .HasMaxLength(256);

        builder.HasIndex(u => u.Email)
            .IsUnique()
            .HasFilter("[IsActive] = 1");

        builder.Property(u => u.PasswordHash)
            .IsRequired()
            .HasMaxLength(512);

        builder.Property(u => u.Name)
            .IsRequired()
            .HasMaxLength(100);

        builder.Property(u => u.Role)
            .IsRequired()
            .HasMaxLength(50)
            .HasDefaultValue("User");

        builder.HasMany(u => u.RefreshTokens)
            .WithOne()
            .HasForeignKey(rt => rt.UserId)
            .OnDelete(DeleteBehavior.Cascade);
    }
}

// Infrastructure/Persistence/Configurations/RefreshTokenConfiguration.cs
public class RefreshTokenConfiguration : IEntityTypeConfiguration<RefreshToken>
{
    public void Configure(EntityTypeBuilder<RefreshToken> builder)
    {
        builder.HasKey(rt => rt.Id);

        builder.Property(rt => rt.Token)
            .IsRequired()
            .HasMaxLength(512);

        builder.HasIndex(rt => rt.Token)
            .IsUnique();

        // Aktif token'ları hızlı bulmak için
        builder.HasIndex(rt => new { rt.UserId, rt.RevokedAt, rt.ExpiresAt });
    }
}

Token Temizleme Job'u

Süresi dolmuş refresh token'lar zamanla birikir. Hangfire ile periyodik temizlik:

// Infrastructure/Jobs/ExpiredTokenCleanupJob.cs
public class ExpiredTokenCleanupJob
{
    private readonly AppDbContext _context;
    private readonly ILogger<ExpiredTokenCleanupJob> _logger;

    public ExpiredTokenCleanupJob(AppDbContext context, ILogger<ExpiredTokenCleanupJob> logger)
    {
        _context = context;
        _logger = logger;
    }

    public async Task RunAsync()
    {
        var cutoffDate = DateTime.UtcNow.AddDays(-30); // 30 günden eski

        var deletedCount = await _context.Database.ExecuteSqlRawAsync(@"
            DELETE FROM RefreshTokens
            WHERE (ExpiresAt < {0} AND RevokedAt IS NULL)
               OR (RevokedAt IS NOT NULL AND RevokedAt < {0})",
            cutoffDate);

        _logger.LogInformation(
            "Token temizliği tamamlandı. Silinen: {Count}", deletedCount);
    }
}

// Program.cs — Hangfire ile her gece 03:00
RecurringJob.AddOrUpdate<ExpiredTokenCleanupJob>(
    "cleanup-expired-tokens",
    job => job.RunAsync(),
    Cron.Daily(3, 0));

Güvenlik Kontrol Listesi

✅ Access token süresi kısa — maksimum 15-30 dakika
✅ Refresh token HTTP-only cookie'de — JavaScript erişemiyor
✅ Cookie Secure = true — sadece HTTPS
✅ Cookie SameSite = Strict — CSRF koruması
✅ Token rotation — refresh token her kullanımda değişiyor
✅ Kullanılmış token tekrar kullanılırsa tüm token'lar iptal ediliyor
✅ Hesap kilitleme — 5 başarısız denemede 15 dakika kilit
✅ Timing attack koruması — kullanıcı bulunamasa da aynı süre geçiyor
✅ Email enumeration koruması — "email veya şifre hatalı" mesajı
✅ ClockSkew = Zero — token süresi kesin
✅ SecretKey environment variable'da — appsettings.json'da değil
✅ SecretKey minimum 256 bit (32 byte)
✅ Süresi dolmuş token'lar periyodik olarak temizleniyor
✅ Tüm auth işlemleri loglanıyor — UserId, IP, sonuç
✅ Şüpheli kullanım (reuse detection) loglanıyor ve alert üretiyor

Temel Çıkarımlar

  • JWT tek başına yetmez — refresh token olmadan güvenli oturum yönetimi mümkün değildir
  • Access token kısa, refresh token uzun ömürlü olmalıdır
  • Refresh token HTTP-only cookie'de saklanmalıdır — localStorage XSS'e açıktır
  • Token rotation her kullanımda yeni refresh token üretir — eski hemen geçersiz olur
  • Kullanılmış token tespiti kritik bir güvenlik önlemidir — tüm token'ları iptal edin
  • Hesap kilitleme brute force'a karşı zorunludur
  • Timing attack'a karşı kullanıcı bulunamasa da şifre hashleme süresi simüle edilmelidir
  • SecretKey asla kod veya appsettings.json'a yazılmamalıdır

JWT bir kimlik kartıdır. Kimlik kartı çalınırsa süresi dolana kadar geçerlidir. Refresh token rotasyonu bu riski minimize eder — kısa ömürlü kart, sık yenileme, çalınmış kartı işe yaramaz kılar.