ASP.NET Core Identity: Token-Based Authentication

asp.net core identity api

ASP.NET Core Identity kullanıcılar üzerinde Authentication ve Authorization işlemlerini yürüten bir üyelik sistemidir. Cookie-Based Authentication ve Token-Based Authentication kimlik doğrulama işlemlerini gerçekleştirebiliriz. Bu yazıda Token-Based Authentication implementasyonu yapılacaktır.

Token Based Authentication via JWT

Görselde istemciler, korunan servisler ve token dağıtmakla yükümlü bir Authorization Server görülmektedir. İstemcilerden biri üyelik gerektirmekte ve diğeri gerektirmemektedir. Üyeliğe tabi istemciler bu servislerle iletişime geçmek istediklerinde authorization server üzerinden kullanıcı credential bilgileriyle geçerli birer access token ve refresh token almaktadır. İstemciler bu servislerle access token üzerinen haberleşerek token ömrü dolduğunda refresh token ile yeni bir token almaktadırlar. Servisler koruma altına alınarak dış dünyaya kapalı hale getirildiğinden üyeliğe tabi olmayan istemci bu servislere erişemeyecektir. Bu işlem için istemciler auth server üzerinden ClientId ve ClientSecret bilgileriyle valid bir token almalıdırlar.

Authorization Server

Öncelikle webapi türünden bir proje oluşturarak authorization server‘ı yazmaya başlayalım. Identity API kullanılacağından Entity Framework Core implementasyonu Microsoft.AspNetCore.Identity.EntityFrameworkCore ve Bearer Token kullanarak servislerimizle haberleşeceğimiz için Microsoft.AspNetCore.Authentication.JwtBearer ve verilerimizi SQL Server üzerinde depolayacağımız için Microsoft.EntityFrameworkCore.SqlServer NuGet paketlerini kuralım.

dotnet new webapi -n AuthServer
dotnet add package Microsoft.AspNetCore.Identity.EntityFrameworkCore
dotnet add package Microsoft.EntityFrameworkCore.SqlServer
dotnet add package Microsoft.AspNetCore.Authentication.JwtBearer

Kullanıcı, refresh token ve DbContext sınıflarımızı oluşturalım.

public class AppUser : IdentityUser
{
    public DateTime BirthDate { get; set; }
    public DateTime CreatedOn { get; set; }
}

public class AppUserRefreshToken
{
    public string UserId { get; set; } = default!;
    public string Token { get; set; } = default!;
    public DateTime Expiration { get; set; }
}

public class AppDbContext : IdentityDbContext<AppUser, IdentityRole, string>
{
    public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }

    public DbSet<AppUserRefreshToken> RefreshTokens => Set<AppUserRefreshToken>();
}

DbContext sınıfımızı servis olarak ekleyelim. Identity mekanizmasınıda ekleyerek yapılandıralım ve Entity Framework Core kullanacağımızı belirtelim.

builder.Services.AddDbContext<AppDbContext>(options =>
{
    options.UseSqlServer(builder.Configuration.GetConnectionString("SqlServer"));
});

builder.Services.AddIdentity<AppUser, IdentityRole>(options =>
{
    options.User.RequireUniqueEmail = true;
    options.Password.RequireNonAlphanumeric = false;
    options.Password.RequiredLength = 6;
}).AddEntityFrameworkStores<AppDbContext>();

Gerekli yapılandırmaları yaptığımıza göre yetkilendirme aşamasına geçebiliriz. Token ayarlarını appsettings.json dosyasında tutacağız.

{
  "TokenOption": {
    "Issuer": "authserver",
    "Audiences": ["orderapi", "discountapi", "productapi"],
    "AccessTokenExpiration": 5,
    "RefreshTokenExpiration": 10080,
    "SecurityKey": "mySecurityKey1234"
  }
}

Token bilgilerini içeren issuer (iss), audiences (aud), security key ve token ömürleri (exp) belirtilmiştir. Bu bilgiler doğrultusunda yetkilendirme için gerekli servis ayarlamalarını yapalım.

var tokenOption = builder.Configuration.GetSection("TokenOption").Get<TokenOption>();

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidIssuer = tokenOption.Issuer,
        ValidateIssuer = true,
        ValidAudience = tokenOption.Issuer,
        ValidateAudience = true,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(tokenOption.SecurityKey)),
        ValidateIssuerSigningKey = true,
        ValidateLifetime = true,
        ClockSkew = TimeSpan.Zero
    };

    options.Events = new JwtBearerEvents
    {
        OnTokenValidated = ctx => Task.CompletedTask,
        OnAuthenticationFailed = ctx => Task.CompletedTask
    };
});

Bir uygulamada birden fazla Authentication Handler kullanmak mümkündür. Çeşitli yetkilendirmeler için farklı isimlerde farklı cookie yapılandırılabilir. Uygulama aynı anda cookie ve token ile yetkilendirmeye tabi olabilir. Bu gibi durumlar için hangi Scheme‘in kullanılacağı belirtilmelidir. Authentication Scheme bilgileri AddAuthentication methodu içerisinde ayarlanmıştır. Scheme bilgisine Bearer değerine sahip AuthenticationScheme değeri atanmaktadır.

Bearer scheme kullanılarak Jwt-Bearer Authentication mekanizması AddJwtBearer methoduyla eklenmektedir. JwtBearerOptions ile Bearer Authentication Handler yapılandırılmaktadır. Token doğrulama işlemleri için TokenValidationParameters kullanılmaktadır.

  • ValidIssuer gelen jetonun kim tarafından oluşturulduğunu,
  • ValidateIssuer jeton doğrulanırken issuer alanının da doğrulanacağını,
  • ValidAudience gelen jetonun kim için oluşturulduğunu,
  • ValidateAudience jeton doğrulanırken audience alanının da doğrulanacağını,
  • IssuerSigningKey jeton imzasını doğrulayacak security key‘i,
  • ValidateIssuerSigningKey imzalı jetonun security key kullanılarak doğrulanacağını,
  • ValidateLifetime jeton doğrulanırken ömrünün de doğrulanacağını,
  • ClockSkew jeton ömrü dolduğunda zaman dilimi farklılıklarından dolayı belirtilen süre kadar daha geçerli olacağı belirtilmektedir.

Doğrulama işleminin nasıl yapılacağı ayarlanmıştır. Dikkat edilirse audience alanına issuer (yani kendisi) atanmıştır. Bu server ileride token dağıtırken aynı zamanda dışarıya açacağı ufak bir servisle token doğrulama işlemi de yapacağı için bu şekilde ayarlanmıştır.

Sonrasındaysa jetonun doğrulanması, yetkilendirmenin başarısız olması gibi olaylarda çalışması istenen iş parçacıkları belirtiliyor. Yetkilendirme mekanizması UseAuthentication methoduyla middleware olarak eklenmelidir.

Access Token

Artık kullanıcılar giriş yaparak geçerli bir access token ve refresh token edinebilirler.

private IEnumerable<Claim> GetClaims(AppUser user)
{
    var claims = new List<Claim>
    {
        new(ClaimTypes.NameIdentifier, user.Id),
        new(ClaimTypes.Email, user.Email),
        new(ClaimTypes.Name, user.UserName),
        new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
    };

    claims.AddRange(_tokenOption.Audiences.Select(s => new Claim(JwtRegisteredClaimNames.Aud, s)));

    return claims;
}

Jeton oluşturulurken kullanıcıların sahip olması istenen claim listesi oluşturuluyor.

private Token CreateAccessToken(IEnumerable<Claim> claims)
{
    var accessTokenExpiration = DateTime.Now.AddMinutes(_tokenOption.AccessTokenExpiration);

    var securityKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_tokenOption.SecurityKey));
    var signingCredentials = new SigningCredentials(securityKey, SecurityAlgorithms.HmacSha256Signature);
    var jwtSecurityToken = new JwtSecurityToken(
        issuer: _tokenOption.Issuer,
        expires: accessTokenExpiration,
        notBefore: DateTime.Now,
        claims: claims,
        signingCredentials: signingCredentials
    );
    var handler = new JwtSecurityTokenHandler();
    var token = handler.WriteToken(jwtSecurityToken);
    return new Token
    {
        Code = token,
        Expiration = accessTokenExpiration
    };
}

Jeton imzalanırken kullanılacak bilgiler SigningCredentials sınıfı örneklenerek belirtilmektedir. Bu bilgiler jetonun nasıl imzalanacağı ve imzalanırken hangi şifreleme algoritmasının kullanılacağıdır. Jeton simetrik olarak şifreleneceğinden SymmetricSecurityKey sınıfıyla bir simetrik anahtar oluşturulmakta ve şifreleme algoritması Sha256 olarak belirtilmektedir.

Sonrasındaysa token oluşturulurken ihtiyaç duyulan bilgiler (issuer, audience, claims gibi) JwtSecurityToken sınıfı yapılandırıcısına verilerek örneklenmektedir. Böylece JwtSecurityTokenHandler sınıfı üzerinden bir access token oluşturulmaktadır.

Refresh token üretecek methodumuzu yazalım.

private Token CreateRefreshToken()
{
    var bytes = new byte[32];
    using var rnd = RandomNumberGenerator.Create();
    rnd.GetBytes(bytes);

    return new Token
    {
        Code = Convert.ToBase64String(bytes),
        Expiration = DateTime.Now.AddMinutes(_tokenOption.RefreshTokenExpiration)
    };
}

Yukarıda tekrarlanması neredeyse imkansız olan bir değer RandomNumberGenerator sınıfı üzerinden oluşturularak refresh token üretilmektedir.

Kullanıcıları giriş yaptıracağımız action methodumuzu yazalım.

[ApiController]
[Route("api/[controller]/[action]")]
public class AuthController : ControllerBase
{
    private readonly UserManager<AppUser> _userManager;
    private readonly AppDbContext _context;

    public AuthController(UserManager<AppUser> userManager, AppDbContext context)
    {
        _userManager = userManager;
        _context = context;
    }

    [HttpPost]
    public async Task<IActionResult> SignIn(SignInViewModel viewModel)
    {
        var user = await _userManager.FindByEmailAsync(viewModel.Email);
        if (user == null) return BadRequest();

        if (!await _userManager.CheckPasswordAsync(user, viewModel.Password)) return BadRequest();

        var userClaims = GetClaims(user);
        var userToken = new UserToken
        {
            AccessToken = CreateAccessToken(userClaims)
        };

        var userRefreshToken = await _context.UserRefreshTokens.SingleOrDefaultAsync(s => s.UserId == user.Id);
        if (userRefreshToken == null)
        {
            userToken.RefreshToken = CreateRefreshToken();

            await _context.UserRefreshTokens.AddAsync(new()
            {
                UserId = user.Id,
                Token = userToken.RefreshToken.Code,
                Expiration = userToken.RefreshToken.Expiration
            });
            await _context.SaveChangesAsync();
        }
        else
        {
            userToken.RefreshToken = new Token
            {
                Code = userRefreshToken.Token,
                Expiration = userRefreshToken.Expiration
            };
        }

        return Ok(userToken);
    }
}

Öncelikle kullanıcıyla ilgili bir takım kontroller yapılmaktadır. Sonrasındaysa kullanıcı üzerinden bir claim listesi GetClaims methoduyla oluşturulmaktadır. Bu claim listesi CreateAccessToken methoduna argüman olarak verilerek bir access token üretilmektedir. Sonraki satırlarda veritabanında öncesinde kullanıcıya bir refresh token üretilip üretilmediği kontrol edilmekte ve CreateRefreshToken methoduyla bir refresh token üretilerek kullanıcıya dönülmektedir.

Access token ömrü dolduğunda refresh token ile yeni token üretecek action methodunu yazalım.

[HttpPost]
public async Task<IActionResult> RefreshToken(RefreshTokenViewModel viewModel)
{
    var refreshToken = await _context.UserRefreshTokens.SingleOrDefaultAsync(s => s.Token == viewModel.Token);
    if (refreshToken == null) return Unauthorized();

    if (refreshToken.Expiration < DateTime.Now)
    {
        _context.UserRefreshTokens.Remove(refreshToken);
        await _context.SaveChangesAsync();
        return Unauthorized();
    }

    var user = await _userManager.FindByIdAsync(refreshToken.UserId);
    if (user == null) return BadRequest();

    var userClaims = GetClaims(user);
    var userToken = new UserToken
    {
        AccessToken = CreateAccessToken(userClaims),
        RefreshToken = new Token
        {
            Code = refreshToken.Token,
            Expiration = refreshToken.Expiration
        }
    };
    return Ok(userToken);
}

Gönderilen refresh token veritabanından çekilerek ömrü kontrol edilmektedir. Sonrasındaysa oluşturulan claim listesi doğrultusunda yeni bir access token üretilerek kullanıcıya dönülmektedir.

Api Servisleri

Senaryomuza göre üç adet servisimiz olacaktır. Servislerimizi oluşturarak gerekli Microsoft.AspNetCore.Authentication.JwtBearer NuGet paketini kuralım.

dotnet new webapi -n ProductApi
dotnet new webapi -n OrderApi
dotnet new webapi -n DiscountApi

Sonrasındaysa token ayarlarını appsettings.json dosyasında saklayalım.

{
  "TokenOption": {
    "Issuer": "authserver",
    "Audience": "discountapi",
    "SecurityKey": "mySecurityKey1234"
  }
}

Audience alanına her servis için sırasıyla discountapi, orderapi ve productapi gelecek şekilde ayarlayalım. Sonrasındaysa yetkilendirme için gerekli servis ayalarmalarını authorization server‘da olduğu gibi tüm servislere uygulayalım.

builder.Services.AddAuthentication(options =>
{
    options.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
    options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme;
}).AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, options =>
{
    options.TokenValidationParameters = new TokenValidationParameters
    {
        ValidIssuer = builder.Configuration.GetValue<string>("TokenOption:Issuer"),
        ValidateIssuer = true,
        ValidAudience = builder.Configuration.GetValue<string>("TokenOption:Audience"),
        ValidateAudience = true,
        IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(builder.Configuration.GetValue<string>("TokenOption:SecurityKey"))),
        ValidateIssuerSigningKey = true,
        ValidateLifetime = true,
        ClockSkew = TimeSpan.Zero
    };
});

DiscountsController sınıfını aşağıdaki şekilde dolduralım.

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class DiscountsController : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        return Ok(new
        {
            Items = new[]
            {
                new { Id = 1, ProductId = 3, Discount = 12.25 },
                new { Id = 2, ProductId = 2, Discount = 20.50 },
                new { Id = 3, ProductId = 1, Discount = 15.0 },
            }
        });
    }
}

OrdersController sınıfını aşağıdaki gibi dolduralım.

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class OrdersController : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        return Ok(new
        {
            Items = new[]
            {
                new { Id = 1, Items = new[] { new { Id = 1, Price = 159.99 }, new { Id = 2, Price = 299.99 } }, CreatedOn = DateTime.Now },
                new { Id = 2, Items = new[] { new { Id = 3, Price = 189.00 } }, CreatedOn = DateTime.Now },
            }
        });
    }
}

Son servisimizin ProductsController sınıfını da dolduralım.

[Authorize]
[ApiController]
[Route("api/[controller]")]
public class ProductsController : ControllerBase
{
    [HttpGet]
    public IActionResult Get()
    {
        return Ok(new
        {
            Items = new[]
            {
                new { Id = 1, Name = "Phone", Price = 159.99, CreatedOn = DateTime.Now },
                new { Id = 2, Name = "Laptop", Price = 299.99, CreatedOn = DateTime.Now },
                new { Id = 3, Name = "Monitor", Price = 189.00, CreatedOn = DateTime.Now },
            }
        });
    }
}

Yukarıda tüm servisler koruma altına alınmıştır. Bu servislere erişmeye çalıştığımızda 401 durum kodunu alacağız. Servis kaynaklarına erişebilmek için öncelikle authorization server üzerinden geçerli credential bilgileriyle geçerli access token edinmemiz gerekmektedir. Sonrasındaysa göndereceğimiz isteklerin Authorization header başlığına Bearer token eklenmelidir.

Client Authentication

Üyeliğe tabi olmayan client uygulamalarının authorization server üzerinden ClientId ve ClientSecret bilgileriyle geçerli bir token almasını sağlayarak servislere erişmelerine imkan verelim. Öncelikle appsettings.json dosyasında istemcilerimizi tanımlayalım. Bu bilgiler istenirse veritabanında da saklanabilir.

{
  "Clients": [
    {
      "Id": "web",
      "Secret": "web.secret",
      "Audiences": ["productapi", "discountapi"]
    },
    {
      "Id": "mobile",
      "Secret": "mobile.secret",
      "Audiences": ["productapi"]
    }
  ]
}

Görüldüğü üzere client bilgileri ve hangi servislere istek yapabilecekleri Audiences alanında tanımlanmıştır.

private IEnumerable<Claim> GetClaims(Client client)
{
    var claims = new List<Claim>
{
    new(JwtRegisteredClaimNames.Sub, client.Id),
    new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};

    claims.AddRange(client.Audiences.Select(s => new Claim(JwtRegisteredClaimNames.Aud, s)));

    return claims;
}

Client uygulamalarının sahip olması istenen claim listesi oluşturuluyor. Buna göre jetonun kim için oluşturulduğu (sub), hedef kitleyle (aud) hangi servislere erişebileceği ve jeton için unique identifier (jti) tanımlanmıştır. Access token üretecek method yukarıda kullanılan methodla aynıdır. Claim listesini parametresi üzerinden almakta ve bu yüzden iki işlem için ortaklaşa kullanılmaktadır.

public class ClientSignInViewModel
{
    public string ClientId { get; set; }
    public string ClientSecret { get; set; }
}

[HttpPost]
public IActionResult ClientSignIn(ClientSignInViewModel viewModel)
{
    var client = _clients.SingleOrDefault(s => s.Id == viewModel.ClientId && s.Secret == viewModel.ClientSecret);
    if (client == null) return Unauthorized();

    var clientClaims = GetClaims(client);
    var clientToken = CreateAccessToken(clientClaims);
    return Ok(clientToken);
}

Yukarıda client uygulamalarının geçerli bir token alacağı action method yazılmıştır. İstemci bilgileri Options Pattern kullanılarak appsettings.json dosyasından okunmaktadır. Gelen bilgilerle eşleşen bir client mevcutsa access token üretilerek istemciye dönülmektedir.

Client uygulamalar elde ettiği bu token ile servislere artık erişim sağlayabilir.

Geliştirilen proje dosyalarına GitHub üzerinden ulaşabilirsiniz.

You may also like...

Bir cevap yazın

E-posta hesabınız yayımlanmayacak. Gerekli alanlar * ile işaretlenmişlerdir