رمزنگاری JWT و افزایش امنیت آن در ASP.NET Core
اندازه‌ی قلم متن
تخمین مدت زمان مطالعه‌ی مطلب: چهار دقیقه

آموزش JSON Web Token (به اختصار JWT) و پیاده سازی آن در برنامه‌های ASP.NET Core درسایت موجود است.
توکن JWT در حالت عادی به صورت Base64 رمزنگاری می‌شود که این نوع رمزنگاری به راحتی قابل رمزگشایی و خواندن است. سایت‌های آنلاین زیادی برای رمزگشایی base64 موجود است؛ برای مثال کافی است توکن خود را در سایت jwt.io کپی کنید و به راحتی محتوای بدنه توکن (Payload) را مشاهده کنید.

پس توکن JWT هیچ امنیتی در برابر خوانده شدن ندارد.
ساده‌ترین راه حل، رمزنگاری دستی بدنه توکن می‌باشد که مثلا بر اساس کلیدی (که فقط سمت سرور نگهداری و مراقبت می‌شود) توکن را رمزنگاری کرده و به هنگام خواندن، آن را با همان کلید رمزگشایی کنیم. ولی این روش ضمن استاندارد نبودن، مشکلات خاص خودش را دارد و نیاز به سفارشی سازی زیادی، هم به هنگام تولید توکن و هم به هنگام خواندن توکن دارد.
 اصولی‌ترین راه، استفاده از رمزنگاری توکن به روش JSON Web Encryption (یا به اختصار JWE) است که در آن مشابه روش بالا ولی به صورت استاندارد تعریف شده (و قابل فهم برای همه استفاده کنندگانی که با این استاندارد آشنایی دارند) است.
نکته :
  1. اگر از JWE استفاده نمی‌کنید، بهتر است اطلاعات حساسی مانند شماره تلفن کاربر (و شاید در مواردی حتی آیدی کاربر) را در بدنه توکن قرار ندهیم چرا که قابل خوانده شدن است (که در این صورت استفاده از Guid برای آیدی کاربر می تواند کمی مفید باشد چرا که حداقل آیدی بقیه کاربران قابل پیش بینی نمی‌باشد).
  2. توکن JWT هیچ امنیتی در برابر خوانده شدن ندارد؛ ولی به لطف امضای (signature) آن، در برابر تغییر محتوا، ایمن است؛ چرا که در صورت تغییر محتوای آن، دیگر مقدار hash محتوا با امضای آن همخوانی نداشته و عملا از اعتبار ساقط می‌گردد.
برای رمزنگاری JWT باید در هر دو مرحله‌ی "تولید توکن" و "اعتبارسنجی توکن" کلید و الگوریتم لازم برای رمزنگاری را مشخص کنیم. بدین منظور در جایی که توکن را تولید می‌کنیم، خواهیم داشت :
var secretKey = Encoding.UTF8.GetBytes("LongerThan-16Char-SecretKey"); // must be 16 character or longer 
var signingCredentials = new SigningCredentials(new SymmetricSecurityKey(secretKey), SecurityAlgorithms.HmacSha256Signature);

var encryptionkey = Encoding.UTF8.GetBytes("16CharEncryptKey"); //must be 16 character
var encryptingCredentials = new EncryptingCredentials(new SymmetricSecurityKey(encryptionkey), SecurityAlgorithms.Aes128KW, SecurityAlgorithms.Aes128CbcHmacSha256);

var claims = new List<Claim>
{
   new Claim(ClaimTypes.Name, "UserName"), //user.UserName
   new Claim(ClaimTypes.NameIdentifier, "123"), //user.Id
};

var descriptor = new SecurityTokenDescriptor
{
   Issuer = _siteSetting.JwtSettings.Issuer,
   Audience = _siteSetting.JwtSettings.Audience,
   IssuedAt = DateTime.Now,
   NotBefore = DateTime.Now.AddMinutes(_siteSetting.JwtSettings.NotBeforeMinutes),
   Expires = DateTime.Now.AddMinutes(_siteSetting.JwtSettings.ExpirationMinutes),
   SigningCredentials = signingCredentials,
   EncryptingCredentials = encryptingCredentials,
   Subject = new ClaimsIdentity(claims)
};

var tokenHandler = new JwtSecurityTokenHandler();
var securityToken = tokenHandler.CreateToken(descriptor);
string encryptedJwt = tokenHandler.WriteToken(securityToken);
کد بالا، مانند کد تولید یک توکن jwt معمولی است؛ تنها تفاوت آن، ایجاد و معرفی شیء encryptingCredentials است.
در خط چهارم، آرایه بایتی کلید لازم برای رمزنگاری (encryptionkey) گرفته شده و از روی آن encryptingCredentials ایجاد شده‌است. این کلید باید 16 کاراکتر باشد؛ در غیر اینصورت به هنگام تولید توکن، خطا دریافت خواهید کرد. رمزنگاری توکن، توسط این کلید و الگوریتم مشخص شده انجام خواهد شد.
سپس شیء تولید شده، به خاصیت EncryptingCredentials کلاس SecurityTokenDescriptor معرفی شده‌است و  نهایتا متد tokenHandler.WriteToken توکن رمزنگاری شده‌ای را تولید می‌کند.
نتیجه کار این است که توکن تولید شده، بدون کلید مربوطه (که سمت سرور نگهداری می‌شود) قابل رمز گشایی نیست و اگر آن را در سایت jwt.io کپی کنید، جوابی دریافت نخواهید کرد.

در ادامه لازم است در مرحله اعتبار سنجی و رمزگشایی توکن در سمت سرور، کلید و الگوریتم لازم را به آن معرفی کنیم تا middleware مربوطه بتواند توکن دریافتی را رمزگشایی و سپس اعتبار سنجی کند. بدین منظور در متد ConfigureServices کلاس Startup.cs خواهیم داشت:

services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
.AddJwtBearer(options =>
{
   var secretkey = Encoding.UTF8.GetBytes("LongerThan-16Char-SecretKey");
   var encryptionkey = Encoding.UTF8.GetBytes("16CharEncryptKey");

   var validationParameters = new TokenValidationParameters
   {
      ClockSkew = TimeSpan.Zero, // default: 5 min
      RequireSignedTokens = true,

      ValidateIssuerSigningKey = true,
      IssuerSigningKey = new SymmetricSecurityKey(secretkey),

      RequireExpirationTime = true,
      ValidateLifetime = true,

      ValidateAudience = true, //default : false
      ValidAudience = "MyWebsite",

      ValidateIssuer = true, //default : false
      ValidIssuer = "MyWebsite",

      TokenDecryptionKey = new SymmetricSecurityKey(encryptionkey)
   };

   options.RequireHttpsMetadata = false;
   options.SaveToken = true;
   options.TokenValidationParameters = validationParameters;
});

کد بالا مانند کد فعال سازی احراز هویت توسط JWT معمولی در ASP.NET Core است؛ با این تفاوت که:

ابتدا آرایه بایتی همان کلید رمزنگاری (encryptionkey) که قبلا توکن را با آن رمزنگاری کرده بودیم، گرفته شده و سپس توسط مقداردهی خاصیت TokenDecryptionKey کلاس TokenValidationParameters، معرفی شده است. 

ولی شاید این سؤال برایتان پیش آید که چرا الگوریتم رمزنگاری مشخص نشده است؟ پس سرور از کجا می‌فهمد که این توکن بر اساس چه الگوریتمی رمزنگاری شده است؟ 

دلیل آن این است که به هنگام تولید توکن، اسم الگوریتم مربوطه، داخل بخش header توکن نوشته می‌شود. اگر تصویر قبل را مشاهده کنید مقدار header توکن به شرح زیر است.

{
  "alg": "A128KW",
  "enc": "A128CBC-HS256",
  "typ": "JWT"
}

پس سرور بر اساس این قسمت از توکن (header)، که هیچگاه رمزنگاری نمی‌شود، می‌فهمد که توسط چه الگوریتمی باید توکن را رمزگشایی کند که در اینجا A128CBC-HS256 (اختصار AES-128-CBC و HMAC-SHA256) است.

مثال کامل و قابل اجرای این مطلب را می‌توانید از این ریپازیتوری دریافت کنید.

    • #
      ‫۵ سال و ۷ ماه قبل، پنجشنبه ۱۸ بهمن ۱۳۹۷، ساعت ۱۷:۱۶
      ممنون بابت اصلاحیه. متاسفانه ترجمه بهتری برای آن در زبان فارسی وجود ندارد و هر دو کلمه encoding و encrypt به عبارت "رمزگذاری" معادل "رمزنگاری" ترجمه می‌شوند. ولی در واقع این دو متفاوت هستند.
      • #
        ‫۵ سال و ۷ ماه قبل، یکشنبه ۲۱ بهمن ۱۳۹۷، ساعت ۱۲:۱۴
        در رابطه با encoding می‌توان از واژه "کدگذاری" به جای "رمزگذاری" استفاده کرد.
  • #
    ‫۵ سال و ۷ ماه قبل، یکشنبه ۲۱ بهمن ۱۳۹۷، ساعت ۲۳:۲۲
    چه مزیتی در استفاده از JWT به جای استفاده از یک توکن شخصی (مثلا GUID)، در حالتی که یک تناظر بین شناسه کاربر و توکن کاربر در سمت سرور نگهداری می‌شود، وجود دارد؟ 
    • #
      ‫۵ سال و ۷ ماه قبل، دوشنبه ۲۲ بهمن ۱۳۹۷، ساعت ۰۰:۰۰
      این سؤال بیشتر مرتبط هست به مطلب «معرفی JSON Web Token» و تفاوت‌های آن با یک Guid، در داشتن امضای دیجیتال جهت اطمینان حاصل کردن از عدم دستکاری آن توسط کاربر، داشتن تاریخ انقضاء، تا امکان قرار دادن Claims و نقش‌های کاربر در آن جهت استفاده‌ی در برنامه‌های SPA و ... است. 
      • #
        ‫۵ سال و ۷ ماه قبل، دوشنبه ۲۲ بهمن ۱۳۹۷، ساعت ۰۰:۰۷
        درسته. منظورم این بود که به نظر میاد وقتی نیاز باشه سمت سرور دسترسی‌ها چک بشن، پیاده سازی بقیه موارد مثل نقش‌ها و تاریخ انقضا و ...هم کار سختی نباشه
        • #
          ‫۵ سال و ۷ ماه قبل، دوشنبه ۲۲ بهمن ۱۳۹۷، ساعت ۰۰:۱۸
          زمانیکه تبدیلش کردی به یک توکن دست ساز سفارشی، کل فریم یکپارچه موجود رو باید بازنویسی کنی؛ مثلا فیلتر Authorize، اون Guid یا توکن دست ساز شما رو شناسایی کنه و بعد کاربر رو به صورت خودکار اعتبارسنجی کنه. این تازه شروعش هست...
        • #
          ‫۵ سال و ۷ ماه قبل، دوشنبه ۲۲ بهمن ۱۳۹۷، ساعت ۰۰:۴۶
          پیاده سازیش سمت سرور غیر ممکن نیست ولی چه فایده؟ اگر تنها می‌خواهید یک Guid را جابجا کنید بهتر است همان را به عنوان یک Claim داخل بدنه JWT قرار بدهید و الکی خودتان را به زحمت نیاندازید.
          ضمنا در صورتی که Guid شما تغییر کند در روش معمولی به هیچ عنوان متوجه آن نمی‌شوید. ولی در JWT، کوچک‌ترین تغییر باعث غیر معتبر شدن توکن خواهد شد.
  • #
    ‫۵ سال و ۷ ماه قبل، جمعه ۲۶ بهمن ۱۳۹۷، ساعت ۰۷:۱۱
    با سلام
    در هنگامی که مقدار Bearer  در Athorization اشتباه باشه چطوری میشه هندل کرد خروجی دیگری چاپ کرد. 
      • #
        ‫۵ سال و ۷ ماه قبل، جمعه ۲۶ بهمن ۱۳۹۷، ساعت ۲۱:۲۳
        این مورد سمت ارسال java script است.
        از سمت #c چطوری میشه هندل کرد
        • #
          ‫۵ سال و ۷ ماه قبل، جمعه ۲۶ بهمن ۱۳۹۷، ساعت ۲۲:۱۳
          توسط فیلتر Authorize به صورت خودکار مدیریت میشه. کاربرد JWT در 99 درصد مواقع برای کار با Web API هست (به همین جهت مثال Ajax رو برای کار با Web API مشاهده کردید و یا در برنامه‌های SPA مانند Angular کاربرد داره) و در این زمان فیلتر Authorize، دسترسی غیرمجاز را بسته و status code=401 را بازگشت می‌ده. اینجاست که کلاینت تصمیم می‌گیره بر اساس این status code باید چکار کنه؛ پیامی رو نمایش بده یا کاربر رو به صفحه‌ی لاگین هدایت کنه (گردش کاریش به این صورت هست؛ از فیلتر Authorize شروع میشه و به بستن درخواست و بازگشت status code ویژه‌ای، خاتمه پیدا می‌کنه). این موارد در مطلب و نظرات «اعتبارسنجی مبتنی بر JWT در ASP.NET Core 2.0 بدون استفاده از سیستم Identity» بیشتر بحث شدن. مطلب جاری، یک مطلب تکمیلی هست و نه یک مطلب آغازین.
        • #
          ‫۵ سال و ۷ ماه قبل، شنبه ۲۷ بهمن ۱۳۹۷، ساعت ۲۲:۵۵
          توسط رخداد OnAuthenticationFailed  خاصیت Events شی JwtBearerOptions می توانید خروجی تولید شده به هنگام اشتباه بودن توکن را سفارشی سازی کنید.
          .AddJwtBearer(options =>
          {
              //...
              options.TokenValidationParameters = validationParameters;
              options.Events = new JwtBearerEvents
              {
                  OnAuthenticationFailed = context =>
                  {
                      //var logger = context.HttpContext.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger(nameof(JwtBearerEvents));
                      //logger.LogError("Authentication failed.", context.Exception);
          
                      if (context.Exception != null)
                      {
                          //context.HttpContext.Response.StatusCode = 401;
                          await context.HttpContext.Response.WriteAsync("خطا در اجراز هویت");
                      }
                  }
              }
          }  
  • #
    ‫۵ سال و ۲ ماه قبل، سه‌شنبه ۱۱ تیر ۱۳۹۸، ساعت ۱۴:۰۹
    یک نکته‌ی تکمیلی: نکته امنیتی در هنگام استفاده از توکن ها

    هنگامیکه کاربری اطلاعات خود را ویرایش میکند، معمولا یک ویوو مدل را از ورودی دریافت میکنیم و داده‌های آن کاربر را بر اساس آی‌دی که درون ویوو مدل ارسال شده‌است، ویرایش میکنیم. اما در این حالت کاربر میتواند با تغییر آیدی ارسالی در ویوو مدل، اطلاعات سایر کاربران را نیز تغییر دهد! برای جلوگیری از این کار میتوان به روش زیر عمل کرد. ابتدا در هنگام ساخت توکن، آیدی کاربر و یک امضا Signature (میتوان از یک GUID استفاده کرد) را در توکن نگهداری میکنیم و سپس توکن را به روش JWE رمزنگاری میکنیم تا اطلاعات توکن قابل مشاهده نباشد و در هنگام اعتبارسنجی توکن، امضای کاربر را با امضای درون توکن مقایسه میکنیم. اگر با هم تفاوت داشته باشند، به معنای آن است که توکن منقضی شده و قابل استفاده نیست. در هربار که کاربر درخواست توکنی را میدهد، باید امضای کاربر را تغییر داده و یک امضای جدید را برای او ثبت کنیم.
    سپس در هنگام اجرای اکشن مورد نظر، آیدی درون توکن و آیدی ارسالی جهت ویرایش اطلاعات را بررسی خواهیم کرد. اگر این دو با هم همخوانی نداشته باشند، اجازه‌ی اجرای اکشن مورد نظر را به او نخواهیم داد و سپس امضای کاربر را تغییر میدهیم تا توکن منقضی شود و یک استثناء را صادر میکنیم.
    برای پیاده سازی، ابتدا یک کلاس را برای بررسی مشخصات توکن و آیدی ارسالی میسازیم.
        public interface IJwtService
        {
            Task CheckId(int id, ClaimsPrincipal claimsPrincipal);
        }
        public class JwtService : IJwtService
        {
            private readonly IUserService _userService;
    
            public JwtService(IUserService userService)
            {
                _userService = userService;
            }
    
            public async Task CheckId(int id, ClaimsPrincipal claimsPrincipal)
            {
                var jwtId = Convert.ToInt32(claimsPrincipal.Identity.FindFirstValue(ClaimTypes.NameIdentifier));
                if (jwtId != id)
                {
                    var user = _userService.GetById(jwtId);
                    user.SecurityStamp = Guid.NewGuid();
                    await _userService.UpdateAsync(user);
                    throw new Exception("You are unauthorized to access this resource.");
                }
            }
        }
    و نحوه‌ی استفاده‌ی از آن در کنترلر و اکشن مورد نظر:
    private readonly IJwtService _jwtService;
    private readonly IUserService _userService;
    public UserController(IJwtService jwtService, IUserService userService)
            {
                _jwtService = jwtService;
                _userService = userService;
            }
    
            [HttpPut("Update")]
            public async Task<IActionResult> Update(UserEditViewModel editViewModel, CancellationToken cancellationToken)
            {
                _jwtService.CheckId(editViewModel.Id, HttpContext.User);
                await _userService.Update(editViewModel, cancellationToken);
            }
    • #
      ‫۵ سال و ۲ ماه قبل، سه‌شنبه ۱۱ تیر ۱۳۹۸، ساعت ۱۴:۲۲
      نیازی به درج آن به صورت جداگانه نیست (البته بحث تطابق Id دریافتی از توکن (و نه view-model البته)، با Id جاری شخص باید صورت گیرد). چون زمانیکه توکن را تولید می‌کنید، Id کاربر لاگین شده‌ی به سیستم هم در آن توکن به صورت یک ClaimTypes.NameIdentifier وجود دارد و پس از اعتبارسنجی درخواست با ارسال توکن به سمت سرور، جزئی از HttpContext.User.Identity as ClaimsIdentity استاندارد می‌شود. این اطلاعات هم توسط کاربر قابل تغییر و دستکاری نیست؛ حتی اگر توکن رمزنگاری هم نشده باشد. مرحله‌ی اعتبارسنجی توکن که رخ می‌دهد، یکی از کارهای آن، بررسی امضای دیجیتال توکن دریافتی از سمت کاربر هست. به همین جهت توکن دستکاری شده، هیچگاه به مرحله‌ی تولید و لحاظ شدن در HttpContext.User.Identity نمی‌رسد.
      بنابراین آیا باید به Id کاربر ارسالی توسط view-model اطمینان کرد؟ خیر. این Id کاربر اصلا نباید در view-model وجود داشته باشد. از id موجود در خود توکن برای تشخیص کاربر استفاده کنید. این Id پس از تعیین اعتبار توکن، معتبر است و نیاز به بررسی و لایه‌های بیشتری ندارد.
      برای ویرایش یک رکورد، ابتدا UserId اصلی آن‌را استخراج کنید. بعد این UserId ردیف بانک اطلاعاتی را با مقدار ClaimTypes.NameIdentifier استخراجی از توکن دریافتی از کاربر، تطابق دهید؛ اگر یکی نبودند، یعنی این رکورد متعلق به کاربر جاری لاگین شده‌ی به سیستم نیست و حق ویرایش آن‌را ندارد (البته اگر آن کاربر ادمین نیست).
      • #
        ‫۵ سال و ۲ ماه قبل، سه‌شنبه ۱۱ تیر ۱۳۹۸، ساعت ۱۴:۲۸
        ممنون از راهنماییتون. هدف من از این مطلب فقط بررسی آیدی ارسالی در ویو مدل بود که کاربر نتواند اطلاعات کاربر دیگری را ویرایش کند.