Anasayfa / Asp.NET Core / ASP.NET Core’da JWT Kimlik Doğrulaması

ASP.NET Core’da JWT Kimlik Doğrulaması

JSON Web Token(JWT), endüstri standardı RFC 7519 tarafından tanımlanan belirli bir formattaki talepleri temsil eden kompakt ve URL güvenli bir dizedir. JWT, talepleri iki taraf arasında güvenli bir şekilde iletmek için standart bir yöntemdir. İnsanlar genellikle JWT’leri web uygulamalarında ve mobil uygulamalarda kimlik kanıtı olarak kullanır.
JWT hakkında konuşan birçok makale var ve bence JWT gerçekten heyecanlanıyor. Sorumlu bir yazar olarak, JWT’yi kullanıp kullanmamanız gerektiğini iyice düşünmenizi şiddetle tavsiye ederim.

Uygulamanızda JWT kullanmak için bazı nedenleriniz olduğunu varsayıyorum.
Bu yazımda sizlere JWT kimlik doğrulama ve yetkilendirme kullanarak bir ASP.NET Core web API uygulamasının nasıl gerçekleştirileceğini göstereceğim. Bu web API uygulaması, oturum açma, oturumu kapatma, yenileme belirteci, kimliğe bürünme vb. Süreçleri uygular. Aşağıdaki ekran görüntüsü, bu makalede ele alacağımız API uç noktalarını göstermektedir.

Çözümümü iki kısma ayırıyorum: Angular’da bir ön uç uygulaması ve ASP.NET Core’da bir arka uç uygulaması. Tam çözümü GitHub depomda bulabilirsiniz. Hem ön uç hem de arka uç uygulamalar Docker’ı destekler ve bunları Docker Compose kullanarak Linux kapsayıcılarında eşzamanlı olarak çalıştırabilirsiniz.

Bu yazıda, iki proje içeren arka uç çözümüne odaklanacağız: JwtAuthDemo ve JwtAuthDemo.IntegrationTests. Entegrasyon testi projesi, web API projesindeki tüm normal JWT süreçlerini kapsar.

JWT Nasıl Kullanır?

Web, mobil  ve farklı projelerde uygulanabilirler, bu nedenle JWT’yi kullanmanın çeşitli yaklaşımlarını görmüş oluruz.

Ancak en yaygın JWT akışı şu şekilde çalışır:

  1. Bir kullanıcı, oturum açmak için web sitesine kimlik bilgilerini gönderir.
  2. Web sitesi arka ucu, kimlik bilgilerini doğrular, uygun talepleri beyan eder, ardından bir JWT oluşturur ve bunu kullanıcıya geri gönderir.
  3. Kullanıcı süresi dolana kadar JWT’yi elinde tutar ve sonraki isteklerde JWT’yi web sitesine gönderir.
  4. Web sitesi JWT’yi doğrular ve kaynağın erişilebilir olup olmadığına karar verir, ardından isteği buna göre işler.

Akıştan JWT’nin güvenliğinin hayati önem taşıdığını biliyoruz, bu nedenle insanlar genellikle JWT’leri HTTPS üzerinden göndermeyi tavsiye ediyorlar ve JWT erişim belirteçleri kısa ömürlü olmalı ve hassas veriler içermemelidir.
Basit olması için yukarıdaki akışta yenileme belirteci sürecini atladım. Genellikle, 2. adımda JWT erişim belirteciyle birlikte rastgele bir dizi, yenileme belirteci oluşturulur. JWT erişim belirtecinin süresi dolmak üzereyken, istemci yeni bir JWT erişim belirteci almak için yenileme belirtecini sunucu tarafına gönderir. Sistemin, yeni erişim belirteciyle birlikte yeni bir yenileme belirteci döndürmesi önerilir. Bu nedenle, uygulamanın artık uzun ömürlü bir yenileme belirteci yoktur. Bu teknik, Refresh Token Rotation olarak bilinir.
En iyi uygulamaların her zaman zaman geçtikçe ve teknolojiler ilerledikçe geliştiğini unutmayın.
Demo amaçlı olarak, bir ASP.NET Core web API projesi JwtAuthDemo ve bir MS Test projesi JwtAuthDemo.IntegrationTests oluşturuyoruz. Öncelikle web API projemiz için JWT kimlik doğrulamasını yapılandıracağız. Ardından oturum açma, oturum kapatma ve token yenileme işlemlerini uygulayacağız.

JWT Authentication Configuration

Normalde uygulamalarımız tarafından oluşturulan JWT erişim belirteçlerini özelleştirmek ve güvenliğini sağlamak istiyoruz. Aşağıdaki JwtTokenConfig classında bir dizi ortak yapılandırma tanımlanmıştır.

JwtTokenConfig.cs
public class JwtTokenConfig
{
    public string Secret { get; set; }
    public string Issuer { get; set; }
    public string Audience { get; set; }
    public int AccessTokenExpiration { get; set; }
    public int RefreshTokenExpiration { get; set; }
}
Startup.cs
public void ConfigureServices(IServiceCollection services)
{
    var jwtTokenConfig = Configuration.GetSection("jwtTokenConfig").Get<JwtTokenConfig>();
    services.AddSingleton(jwtTokenConfig);
    services.AddAuthentication(x =>
    {
        x.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme;
        x.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
    }).AddJwtBearer(x =>
    {
        x.RequireHttpsMetadata = true;
        x.SaveToken = true;
        x.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuer = true,
            ValidIssuer = jwtTokenConfig.Issuer,
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(Encoding.ASCII.GetBytes(jwtTokenConfig.Secret)),
            ValidAudience = jwtTokenConfig.Audience,
            ValidateAudience = true,
            ValidateLifetime = true,
            ClockSkew = TimeSpan.FromMinutes(1)
        };
    });
    // ...
}
Startup.cs
public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    // ...
    app.UseRouting();
    app.UseAuthentication();
    app.UseAuthorization();

    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

Token Generation and Login

JwtAuthManager.cs
public class JwtAuthManager : IJwtAuthManager
{
    public IImmutableDictionary<string, RefreshToken> UsersRefreshTokensReadOnlyDictionary => _usersRefreshTokens.ToImmutableDictionary();
    private readonly ConcurrentDictionary<string, RefreshToken> _usersRefreshTokens;  // can store in a database or a distributed cache
    private readonly JwtTokenConfig _jwtTokenConfig;
    private readonly byte[] _secret;

    public JwtAuthManager(JwtTokenConfig jwtTokenConfig)
    {
        _jwtTokenConfig = jwtTokenConfig;
        _usersRefreshTokens = new ConcurrentDictionary<string, RefreshToken>();
        _secret = Encoding.ASCII.GetBytes(jwtTokenConfig.Secret);
    }

    public JwtAuthResult GenerateTokens(string username, Claim[] claims, DateTime now)
    {
        var shouldAddAudienceClaim = string.IsNullOrWhiteSpace(claims?.FirstOrDefault(x => x.Type == JwtRegisteredClaimNames.Aud)?.Value);
        var jwtToken = new JwtSecurityToken(
            _jwtTokenConfig.Issuer,
            shouldAddAudienceClaim ? _jwtTokenConfig.Audience : string.Empty,
            claims,
            expires: now.AddMinutes(_jwtTokenConfig.AccessTokenExpiration),
            signingCredentials: new SigningCredentials(new SymmetricSecurityKey(_secret), SecurityAlgorithms.HmacSha256Signature));
        var accessToken = new JwtSecurityTokenHandler().WriteToken(jwtToken);

        var refreshToken = new RefreshToken
        {
            UserName = username,
            TokenString = GenerateRefreshTokenString(),
            ExpireAt = now.AddMinutes(_jwtTokenConfig.RefreshTokenExpiration)
        };
        _usersRefreshTokens.AddOrUpdate(refreshToken.TokenString, refreshToken, (s, t) => refreshToken);

        return new JwtAuthResult
        {
            AccessToken = accessToken,
            RefreshToken = refreshToken
        };
    }

    private static string GenerateRefreshTokenString()
    {
        var randomNumber = new byte[32];
        using var randomNumberGenerator = RandomNumberGenerator.Create();
        randomNumberGenerator.GetBytes(randomNumber);
        return Convert.ToBase64String(randomNumber);
    }
}
AccountController.cs
[ApiController]
[Authorize]
[Route("api/[controller]")]
public class AccountController : ControllerBase
{
    private readonly ILogger<AccountController> _logger;
    private readonly IUserService _userService;
    private readonly IJwtAuthManager _jwtAuthManager;

    public AccountController(ILogger<AccountController> logger, IUserService userService, IJwtAuthManager jwtAuthManager)
    {
        _logger = logger;
        _userService = userService;
        _jwtAuthManager = jwtAuthManager;
    }

    [AllowAnonymous]
    [HttpPost("login")]
    public ActionResult Login([FromBody] LoginRequest request)
    {
        if (!ModelState.IsValid)
        {
            return BadRequest();
        }

        if (!_userService.IsValidUserCredentials(request.UserName, request.Password))
        {
            return Unauthorized();
        }

        var role = _userService.GetUserRole(request.UserName);
        var claims = new[]
        {
            new Claim(ClaimTypes.Name,request.UserName),
            new Claim(ClaimTypes.Role, role)
        };

        var jwtResult = _jwtAuthManager.GenerateTokens(request.UserName, claims, DateTime.Now);
        _logger.LogInformation($"User [{request.UserName}] logged in the system.");
        return Ok(new LoginResult
        {
            UserName = request.UserName,
            Role = role,
            AccessToken = jwtResult.AccessToken,
            RefreshToken = jwtResult.RefreshToken.TokenString
        });
    }
}

Logout

AccountController.cs
[HttpPost("logout")]
[Authorize]
public ActionResult Logout()
{
    var userName = User.Identity.Name;
    _jwtAuthManager.RemoveRefreshTokenByUserName(userName); // can be more specific to ip, user agent, device name, etc.
    _logger.LogInformation($"User [{userName}] logged out the system.");
    return Ok();
}

Refresh the JWT Access Token

AccountController.cs
[HttpPost("refresh-token")]
[Authorize]
public async Task<ActionResult> RefreshToken([FromBody] RefreshTokenRequest request)
{
    try
    {
        var userName = User.Identity.Name;
        _logger.LogInformation($"User [{userName}] is trying to refresh JWT token.");

        if (string.IsNullOrWhiteSpace(request.RefreshToken))
        {
            return Unauthorized();
        }

        var accessToken = await HttpContext.GetTokenAsync("Bearer", "access_token");
        var jwtResult = _jwtAuthManager.Refresh(request.RefreshToken, accessToken, DateTime.Now);
        _logger.LogInformation($"User [{userName}] has refreshed JWT token.");
        return Ok(new LoginResult
        {
            UserName = userName,
            Role = User.FindFirst(ClaimTypes.Role)?.Value ?? string.Empty,
            AccessToken = jwtResult.AccessToken,
            RefreshToken = jwtResult.RefreshToken.TokenString
        });
    }
    catch (SecurityTokenException e)
    {
        return Unauthorized(e.Message); // return 401 so that the client side can redirect the user to login page
    }
}
JwtAuthManager.cs
public JwtAuthResult Refresh(string refreshToken, string accessToken, DateTime now)
{
    var (principal, jwtToken) = DecodeJwtToken(accessToken);
    if (jwtToken == null || !jwtToken.Header.Alg.Equals(SecurityAlgorithms.HmacSha256Signature))
    {
        throw new SecurityTokenException("Invalid token");
    }

    var userName = principal.Identity.Name;
    if (!_usersRefreshTokens.TryGetValue(refreshToken, out var existingRefreshToken))
    {
        throw new SecurityTokenException("Invalid token");
    }
    if (existingRefreshToken.UserName != userName || existingRefreshToken.ExpireAt < now)
    {
        throw new SecurityTokenException("Invalid token");
    }

    return GenerateTokens(userName, principal.Claims.ToArray(), now); // need to recover the original claims
}

public (ClaimsPrincipal, JwtSecurityToken) DecodeJwtToken(string token)
{
    if (string.IsNullOrWhiteSpace(token))
    {
        throw new SecurityTokenException("Invalid token");
    }
    var principal = new JwtSecurityTokenHandler()
        .ValidateToken(token,
            new TokenValidationParameters
            {
                ValidateIssuer = true,
                ValidIssuer = _jwtTokenConfig.Issuer,
                ValidateIssuerSigningKey = true,
                IssuerSigningKey = new SymmetricSecurityKey(_secret),
                ValidAudience = _jwtTokenConfig.Audience,
                ValidateAudience = true,
                ValidateLifetime = true,
                ClockSkew = TimeSpan.FromMinutes(1)
            },
            out var validatedToken);
    return (principal, validatedToken as JwtSecurityToken);
}