همچنین باید درنظر داشت، ASP.NET Core Identity یک سیستم اعتبارسنجی مبتنی بر کوکیها است. دقیقا زمانیکه کار AddIdentity را انجام میدهیم، در پشت صحنه همان services.AddAuthentication().AddCookie قسمت قبل فراخوانی میشود. بنابراین بکارگیری آن با JSON Web Tokens هرچند مشکلی را به همراه ندارد و میتوان یک سیستم اعتبارسنجی «دوگانه» را نیز در اینجا داشت، اما ... سربار اضافی تولید کوکیها را نیز به همراه دارد؛ هرچند برای کار با میانافزار اعتبارسنجی، الزامی به استفادهی از ASP.NET Core Identity نیست و عموما اگر از آن به همراه JWT استفاده میکنند، بیشتر به دنبال پیاده سازیهای پیشفرض مدیریت کاربران و نقشهای آن هستند و نه قسمت تولید کوکیهای آن. البته در مطلب جاری این موارد را نیز همانند مطلب اعتبارسنجی مبتنی بر کوکیها، خودمان مدیریت خواهیم کرد و در نهایت سیستم تهیه شده، هیچ نوع کوکی را تولید و یا مدیریت نمیکند.
تنظیمات آغازین برنامه جهت فعالسازی اعتبارسنجی مبتنی بر JSON Web Tokens
اولین تفاوت پیاده سازی یک سیستم اعتبارسنجی مبتنی بر JWT، با روش مبتنی بر کوکیها، تنظیمات متد ConfigureServices فایل آغازین برنامه است:
public void ConfigureServices(IServiceCollection services) { services.Configure<BearerTokensOptions>(options => Configuration.GetSection("BearerTokens").Bind(options)); services .AddAuthentication(options => { options.DefaultChallengeScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultSignInScheme = JwtBearerDefaults.AuthenticationScheme; options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; }) .AddJwtBearer(cfg => { cfg.RequireHttpsMetadata = false; cfg.SaveToken = true; cfg.TokenValidationParameters = new TokenValidationParameters { ValidIssuer = Configuration["BearerTokens:Issuer"], ValidAudience = Configuration["BearerTokens:Audience"], IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(Configuration["BearerTokens:Key"])), ValidateIssuerSigningKey = true, ValidateLifetime = true, ClockSkew = TimeSpan.Zero }; cfg.Events = new JwtBearerEvents { OnAuthenticationFailed = context => { var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger(nameof(JwtBearerEvents)); logger.LogError("Authentication failed.", context.Exception); return Task.CompletedTask; }, OnTokenValidated = context => { var tokenValidatorService = context.HttpContext.RequestServices.GetRequiredService<ITokenValidatorService>(); return tokenValidatorService.ValidateAsync(context); }, OnMessageReceived = context => { return Task.CompletedTask; }, OnChallenge = context => { var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger(nameof(JwtBearerEvents)); logger.LogError("OnChallenge error", context.Error, context.ErrorDescription); return Task.CompletedTask; } }; });
{ "BearerTokens": { "Key": "This is my shared key, not so secret, secret!", "Issuer": "http://localhost/", "Audience": "Any", "AccessTokenExpirationMinutes": 2, "RefreshTokenExpirationMinutes": 60 } }
سپس کار فراخوانی services.AddAuthentication صورت گرفتهاست. تفاوت این مورد با حالت اعتبارسنجی مبتنی بر کوکیها، ثوابتی است که با JwtBearerDefaults شروع میشوند. در حالت استفادهی از کوکیها، این ثوابت بر اساس CookieAuthenticationDefaults تنظیم خواهند شد.
البته میتوان متد AddAuthentication را بدون هیچگونه پارامتری نیز فراخوانی کرد. این حالت برای اعتبارسنجیهای دوگانه مفید است. برای مثال زمانیکه پس از AddAuthentication هم AddJwtBearer را ذکر کردهاید و هم AddCookie اضافه شدهاست. اگر چنین کاری را انجام دادید، اینبار باید درحین تعریف فیلتر Authorize، دقیقا مشخص کنید که حالت مبتنی بر JWT مدنظر شما است، یا حالت مبتنی بر کوکیها:
[Authorize(AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
بررسی تنظیمات متد AddJwtBearer
در کدهای فوق، تنظیمات متد AddJwtBearer یک چنین مفاهیمی را به همراه دارند:
- تنظیم SaveToken به true، به این معنا است که میتوان به توکن دریافتی از سمت کاربر، توسط متد HttpContext.GetTokenAsync در کنترلرهای برنامه دسترسی یافت.
در قسمت تنظیمات TokenValidationParameters آن:
- کار خواندن فایل appsettings.json برنامه جهت تنظیم صادر کننده و مخاطبین توکن انجام میشود. سپس IssuerSigningKey به یک کلید رمزنگاری متقارن تنظیم خواهد شد. این کلید نیز در تنظیمات برنامه قید میشود.
- تنظیم ValidateIssuerSigningKey به true سبب خواهد شد تا میانافزار اعتبارسنجی، بررسی کند که آیا توکن دریافتی از سمت کاربر توسط برنامهی ما امضاء شدهاست یا خیر؟
- تنظیم ValidateLifetime به معنای بررسی خودکار طول عمر توکن دریافتی از سمت کاربر است. اگر توکن منقضی شده باشد، اعتبارسنجی به صورت خودکار خاتمه خواهد یافت.
- ClockSkew به معنای تنظیم یک تلرانس و حد تحمل مدت زمان منقضی شدن توکن در حالت ValidateLifetime است. در اینجا به صفر تنظیم شدهاست.
سپس به قسمت JwtBearerEvents میرسیم:
- OnAuthenticationFailed زمانی فراخوانی میشود که اعتبارسنجهای تنظیمی فوق، با شکست مواجه شوند. برای مثال طول عمر توکن منقضی شده باشد و یا توسط ما امضاء نشدهباشد. در اینجا میتوان به این خطاها دسترسی یافت و درصورت نیاز آنها را لاگ کرد.
- OnChallenge نیز یک سری دیگر از خطاهای اعتبارسنجی را پیش از ارسال آنها به فراخوان در اختیار ما قرار میدهد.
- OnMessageReceived برای حالتی است که توکن دریافتی، توسط هدر مخصوص Bearer به سمت سرور ارسال نمیشود. عموما هدر ارسالی به سمت سرور یک چنین شکلی را دارد:
$.ajax({ headers: { 'Authorization': 'Bearer ' + jwtToken },
const string tokenKey = "my.custom.jwt.token.key"; if (context.HttpContext.Items.ContainsKey(tokenKey)) { context.Token = (string)context.HttpContext.Items[tokenKey]; }
تهیه یک اعتبارسنج توکن سفارشی
قسمت OnTokenValidated تنظیمات ابتدای برنامه به این صورت مقدار دهی شدهاست:
OnTokenValidated = context => { var tokenValidatorService = context.HttpContext.RequestServices.GetRequiredService<ITokenValidatorService>(); return tokenValidatorService.ValidateAsync(context); },
public class TokenValidatorService : ITokenValidatorService { private readonly IUsersService _usersService; private readonly ITokenStoreService _tokenStoreService; public TokenValidatorService(IUsersService usersService, ITokenStoreService tokenStoreService) { _usersService = usersService; _usersService.CheckArgumentIsNull(nameof(usersService)); _tokenStoreService = tokenStoreService; _tokenStoreService.CheckArgumentIsNull(nameof(_tokenStoreService)); } public async Task ValidateAsync(TokenValidatedContext context) { var userPrincipal = context.Principal; var claimsIdentity = context.Principal.Identity as ClaimsIdentity; if (claimsIdentity?.Claims == null || !claimsIdentity.Claims.Any()) { context.Fail("This is not our issued token. It has no claims."); return; } var serialNumberClaim = claimsIdentity.FindFirst(ClaimTypes.SerialNumber); if (serialNumberClaim == null) { context.Fail("This is not our issued token. It has no serial."); return; } var userIdString = claimsIdentity.FindFirst(ClaimTypes.UserData).Value; if (!int.TryParse(userIdString, out int userId)) { context.Fail("This is not our issued token. It has no user-id."); return; } var user = await _usersService.FindUserAsync(userId).ConfigureAwait(false); if (user == null || user.SerialNumber != serialNumberClaim.Value || !user.IsActive) { // user has changed his/her password/roles/stat/IsActive context.Fail("This token is expired. Please login again."); } var accessToken = context.SecurityToken as JwtSecurityToken; if (accessToken == null || string.IsNullOrWhiteSpace(accessToken.RawData) || !await _tokenStoreService.IsValidTokenAsync(accessToken.RawData, userId).ConfigureAwait(false)) { context.Fail("This token is not in our database."); return; } await _usersService.UpdateUserLastActivityDateAsync(userId).ConfigureAwait(false); } }
- آیا توکن دریافتی به همراه Claims تنظیم شدهی درحین لاگین هست یا خیر؟
- آیا توکن دریافتی دارای یک Claim سفارشی به نام SerialNumber است؟ این SerialNumber معادل چنین فیلدی در جدول کاربران است.
- آیا توکن دریافتی دارای user-id است؟
- آیا کاربر یافت شدهی بر اساس این user-id هنوز فعال است و یا اطلاعات او تغییر نکردهاست؟
- همچنین در آخر کار بررسی میکنیم که آیا اصل توکن دریافتی، در بانک اطلاعاتی ما پیشتر ثبت شدهاست یا خیر؟
اگر خیر، بلافاصله متد context.Fail فراخوانی شده و کار اعتبارسنجی را با اعلام شکست آن، به پایان میرسانیم.
در قسمت آخر، نیاز است اطلاعات توکنهای صادر شده را ذخیره کنیم. به همین جهت نسبت به مطلب قبلی، جدول UserToken ذیل به برنامه اضافه شدهاست:
public class UserToken { public int Id { get; set; } public string AccessTokenHash { get; set; } public DateTimeOffset AccessTokenExpiresDateTime { get; set; } public string RefreshTokenIdHash { get; set; } public DateTimeOffset RefreshTokenExpiresDateTime { get; set; } public int UserId { get; set; } // one-to-one association public virtual User User { get; set; } }
از اطلاعات آن در دو قسمت TokenValidatorService فوق و همچنین قسمت logout برنامه استفاده میکنیم. در سیستم JWT، مفهوم logout سمت سرور وجود خارجی ندارد. اما با ذخیره سازی هش توکنها در بانک اطلاعاتی میتوان لیستی از توکنهای صادر شدهی توسط برنامه را تدارک دید. سپس در حین logout فقط کافی است tokenهای یک کاربر را حذف کرد. همینقدر سبب خواهد شد تا قسمت آخر TokenValidatorService با شکست مواجه شود؛ چون توکن ارسالی به سمت سرور دیگر در بانک اطلاعاتی وجود ندارد.
سرویس TokenStore
public interface ITokenStoreService { Task AddUserTokenAsync(UserToken userToken); Task AddUserTokenAsync( User user, string refreshToken, string accessToken, DateTimeOffset refreshTokenExpiresDateTime, DateTimeOffset accessTokenExpiresDateTime); Task<bool> IsValidTokenAsync(string accessToken, int userId); Task DeleteExpiredTokensAsync(); Task<UserToken> FindTokenAsync(string refreshToken); Task DeleteTokenAsync(string refreshToken); Task InvalidateUserTokensAsync(int userId); Task<(string accessToken, string refreshToken)> CreateJwtTokens(User user); }
پیاده سازی کامل این سرویس را در اینجا میتوانید مشاهده کنید.
تولید Access Tokens و Refresh Tokens
پس از تنظیمات ابتدایی برنامه، اکنون میتوانیم دو نوع توکن را تولید کنیم:
تولید Access Tokens
private async Task<string> createAccessTokenAsync(User user, DateTime expires) { var claims = new List<Claim> { // Unique Id for all Jwt tokes new Claim(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString()), // Issuer new Claim(JwtRegisteredClaimNames.Iss, _configuration.Value.Issuer), // Issued at new Claim(JwtRegisteredClaimNames.Iat, DateTime.UtcNow.ToUnixEpochDate().ToString(), ClaimValueTypes.Integer64), new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), new Claim(ClaimTypes.Name, user.Username), new Claim("DisplayName", user.DisplayName), // to invalidate the cookie new Claim(ClaimTypes.SerialNumber, user.SerialNumber), // custom data new Claim(ClaimTypes.UserData, user.Id.ToString()) }; // add roles var roles = await _rolesService.FindUserRolesAsync(user.Id).ConfigureAwait(false); foreach (var role in roles) { claims.Add(new Claim(ClaimTypes.Role, role.Name)); } var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration.Value.Key)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var token = new JwtSecurityToken( issuer: _configuration.Value.Issuer, audience: _configuration.Value.Audience, claims: claims, notBefore: DateTime.UtcNow, expires: expires, signingCredentials: creds); return new JwtSecurityTokenHandler().WriteToken(token); }
<ItemGroup> <PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="2.0.0" /> </ItemGroup>
پس از تهیهی Claims، اینبار بجای یک کوکی، یک JSON Web Toekn را توسط متد new JwtSecurityTokenHandler().WriteToken تهیه خواهیم کرد. این توکن حاوی Claims، به همراه اطلاعات طول عمر و امضای مرتبطی است.
حاصل آن نیز یک رشتهاست که دقیقا به همین فرمت به سمت کلاینت ارسال خواهد شد. البته ما در اینجا دو نوع توکن را به سمت کلاینت ارسال میکنیم:
public async Task<(string accessToken, string refreshToken)> CreateJwtTokens(User user) { var now = DateTimeOffset.UtcNow; var accessTokenExpiresDateTime = now.AddMinutes(_configuration.Value.AccessTokenExpirationMinutes); var refreshTokenExpiresDateTime = now.AddMinutes(_configuration.Value.RefreshTokenExpirationMinutes); var accessToken = await createAccessTokenAsync(user, accessTokenExpiresDateTime.UtcDateTime).ConfigureAwait(false); var refreshToken = Guid.NewGuid().ToString().Replace("-", ""); await AddUserTokenAsync(user, refreshToken, accessToken, refreshTokenExpiresDateTime, accessTokenExpiresDateTime).ConfigureAwait(false); await _uow.SaveChangesAsync().ConfigureAwait(false); return (accessToken, refreshToken); }
جهت بالا رفتن امنیت سیستم، این Guid را هش کرد و سپس این هش را در بانک اطلاعاتی ذخیره میکنیم. به این ترتیب دسترسی غیرمجاز به این هشها، امکان بازیابی توکنهای اصلی را غیرممکن میکند.
پیاده سازی Login
پس از پیاده سازی متد CreateJwtTokens، کار ورود به سیستم به سادگی ذیل خواهد بود:
[AllowAnonymous] [HttpPost("[action]")] public async Task<IActionResult> Login([FromBody] User loginUser) { if (loginUser == null) { return BadRequest("user is not set."); } var user = await _usersService.FindUserAsync(loginUser.Username, loginUser.Password).ConfigureAwait(false); if (user == null || !user.IsActive) { return Unauthorized(); } var (accessToken, refreshToken) = await _tokenStoreService.CreateJwtTokens(user).ConfigureAwait(false); return Ok(new { access_token = accessToken, refresh_token = refreshToken }); }
پیاده سازی Refresh Token
پیاده سازی توکن به روز رسانی همانند عملیات لاگین است:
[AllowAnonymous] [HttpPost("[action]")] public async Task<IActionResult> RefreshToken([FromBody]JToken jsonBody) { var refreshToken = jsonBody.Value<string>("refreshToken"); if (string.IsNullOrWhiteSpace(refreshToken)) { return BadRequest("refreshToken is not set."); } var token = await _tokenStoreService.FindTokenAsync(refreshToken); if (token == null) { return Unauthorized(); } var (accessToken, newRefreshToken) = await _tokenStoreService.CreateJwtTokens(token.User).ConfigureAwait(false); return Ok(new { access_token = accessToken, refresh_token = newRefreshToken }); }
پیاده سازی Logout
در سیستمهای مبتنی بر JWT، پیاده سازی Logout سمت سرور بیمفهوم است؛ از این جهت که تا زمان انقضای یک توکن میتوان از آن توکن جهت ورود به سیستم و دسترسی به منابع آن استفاده کرد. بنابراین تنها راه پیاده سازی Logout، ذخیره سازی توکنها در بانک اطلاعاتی و سپس حذف آنها در حین خروج از سیستم است. به این ترتیب اعتبارسنج سفارشی توکنها، از استفادهی مجدد از توکنی که هنوز هم معتبر است و منقضی نشدهاست، جلوگیری خواهد کرد:
[AllowAnonymous] [HttpGet("[action]"), HttpPost("[action]")] public async Task<bool> Logout() { var claimsIdentity = this.User.Identity as ClaimsIdentity; var userIdValue = claimsIdentity.FindFirst(ClaimTypes.UserData)?.Value; // The Jwt implementation does not support "revoke OAuth token" (logout) by design. // Delete the user's tokens from the database (revoke its bearer token) if (!string.IsNullOrWhiteSpace(userIdValue) && int.TryParse(userIdValue, out int userId)) { await _tokenStoreService.InvalidateUserTokensAsync(userId).ConfigureAwait(false); } await _tokenStoreService.DeleteExpiredTokensAsync().ConfigureAwait(false); await _uow.SaveChangesAsync().ConfigureAwait(false); return true; }
آزمایش نهایی برنامه
در فایل index.html، نمونهای از متدهای لاگین، خروج و فراخوانی اکشن متدهای محافظت شده را مشاهده میکنید. این روش برای برنامههای تک صفحهای وب یا SPA نیز میتواند مفید باشد و به همین نحو کار میکنند.
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید.