ASP.NET Core Identity: Token-Based Authentication
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.
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.
.Net Core Web projesi oluşturuyorum. Oluştururken Scaffolded yöntemiyle Identity ekledim. Kullanıcı kayıt ve giriş işlemi yapabiliyor. Fakat amacım API ile bu işlemleri yapmak. Yani kullanıcı giriş yapmak istediğinde API den token alması, token geçerlilik süresi (veya refreshtoken) sonrasında tekrar login olmaya yönlendirmek ve yetki tanımları yapmak istiyorum. Katmanlı mimari ile oluşturdum projemi. API den aldığım token değerini Claims olarak eklediğimde web tarafı otomatik tanıyacak mı? O taraf tam netleşmedi bende. Bilgi verebilirseniz sevinirim. Yazılarınız için ayrıca teşekkürler.