غیرمعتبر کردن توکن و یا کوکی سرقت شده در برنامه‌های مبتنی بر ASP.NET Core
اندازه‌ی قلم متن
تخمین مدت زمان مطالعه‌ی مطلب: پنج دقیقه

چند روز قبل، یکی از کانال‌های فنی معروف یوتیوب با بیش از 15 میلیون مشترک، هک و پاک شد! که داستان آن‌را در اینجا می‌توانید پیگیری کنید. در این هک، مهاجم در سعی اول، پیشنهاد پشتیبانی مالی از شبکه را داده و در ایمیل دوم، پس از جلب اعتماد اولیه، یک فایل به ظاهر PDF مفاد قرارداد را ارسال کرده که با کلیک بر روی آن، تمام کوکی‌های یوتیوب مالک کانال، سرقت و مورد سوء استفاده قرار گرفته! در یک چنین حالتی، مهم نیست که شما اعتبارسنجی دو مرحله‌ای را فعال کرده‌اید و یا از بهترین روش‌های رمزنگاری برای امن کردن اطلاعات کوکی و یا توکن خود استفاده کرده‌اید، همینقدر که اصل محتوای کوکی و یا توکن شما در اختیار شخص دیگری قرار گیرد، می‌تواند بدون نیاز به لاگین و دانستن کلمه‌ی عبور شما، بجای شما وارد سیستم شده و تغییرات دلخواهی را اعمال کند!
بنابراین سؤال اینجاست که ما (توسعه دهندگان) چگونه می‌توانیم یک چنین حملاتی را مشکل‌تر کنیم؟ در این مطلب روشی را در جهت سعی در غیرمعتبر کردن توکن‌ها و یا کوکی‌های سرقت شده، در برنامه‌های مبتنی بر ASP.NET Core بررسی خواهیم کرد.


توسعه‌ی یک سرویس تشخیص مرورگر و سیستم عامل شخص وارد شده‌ی به سیستم

یکی از روش‌های غیرممکن کردن یک چنین حملاتی، درج مشخصات سیستم عامل و مرورگر شخص وارد شده‌ی به سیستم، در کوکی و همچنین توکن صادر شده‌ی حاصل از اعتبارسنجی موفق است. سپس زمانیکه قرار است از اطلاعات این کوکی و یا توکن در برنامه استفاده شود، این اطلاعات را با اطلاعات درخواست جاری کاربر مقایسه کرده و در صورت عدم تطابق، درخواست او را برگشت می‌زنیم. برای مثال اگر عملیات لاگین، در ویندوز انجام شده و اکنون توکن و یا کوکی حاصل، در سیستم عامل اندروید در حاصل استفاده‌است، یعنی ... این عملیات مشکوک است و باید خاتمه یابد و کاربر باید مجبور به لاگین مجدد شود و نه اعتبارسنجی خودکار بدون زحمت!
برای این منظور می‌توان از کتابخانه‌ی UA-Parser استفاده کرد و توسط آن سرویس زیر را توسعه داد:
using System.Security.Claims;
using Microsoft.AspNetCore.Http;
using Microsoft.Net.Http.Headers;
using UAParser;

namespace ASPNETCore2JwtAuthentication.Services;

/// <summary>
///     To invalidate an old user's token from a new device
/// </summary>
public class DeviceDetectionService : IDeviceDetectionService
{
    private readonly IHttpContextAccessor _httpContextAccessor;
    private readonly ISecurityService _securityService;

    public DeviceDetectionService(ISecurityService securityService, IHttpContextAccessor httpContextAccessor)
    {
        _securityService = securityService ?? throw new ArgumentNullException(nameof(securityService));
        _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor));
    }

    public string GetCurrentRequestDeviceDetails() => GetDeviceDetails(_httpContextAccessor.HttpContext);

    public string GetDeviceDetails(HttpContext context)
    {
        var ua = GetUserAgent(context);
        if (ua is null)
        {
            return "unknown";
        }

        var client = Parser.GetDefault().Parse(ua);
        var deviceInfo = client.Device.Family;
        var browserInfo = $"{client.UA.Family}, {client.UA.Major}.{client.UA.Minor}";
        var osInfo = $"{client.OS.Family}, {client.OS.Major}.{client.OS.Minor}";
        //TODO: Add the user's IP address here, if it's a banking system.
        return $"{deviceInfo}, {browserInfo}, {osInfo}";
    }

    public string GetDeviceDetailsHash(HttpContext context) =>
        _securityService.GetSha256Hash(GetDeviceDetails(context));

    public string GetCurrentRequestDeviceDetailsHash() => GetDeviceDetailsHash(_httpContextAccessor.HttpContext);

    public string GetCurrentUserTokenDeviceDetailsHash() =>
        GetUserTokenDeviceDetailsHash(_httpContextAccessor.HttpContext?.User.Identity as ClaimsIdentity);

    public string GetUserTokenDeviceDetailsHash(ClaimsIdentity claimsIdentity)
    {
        if (claimsIdentity?.Claims == null || !claimsIdentity.Claims.Any())
        {
            return null;
        }

        return claimsIdentity.FindFirst(ClaimTypes.System)?.Value;
    }

    public bool HasCurrentUserTokenValidDeviceDetails() =>
        HasUserTokenValidDeviceDetails(_httpContextAccessor.HttpContext?.User.Identity as ClaimsIdentity);

    public bool HasUserTokenValidDeviceDetails(ClaimsIdentity claimsIdentity) =>
        string.Equals(GetCurrentRequestDeviceDetailsHash(), GetUserTokenDeviceDetailsHash(claimsIdentity),
                      StringComparison.Ordinal);

    private static string GetUserAgent(HttpContext context)
    {
        if (context is null)
        {
            return null;
        }

        return context.Request.Headers.TryGetValue(HeaderNames.UserAgent, out var userAgent)
                   ? userAgent.ToString()
                   : null;
    }
}
توضیحات:
اصل کار این سرویس در متد زیر رخ می‌دهد:
    public string GetDeviceDetails(HttpContext context)
    {
        var ua = GetUserAgent(context);
        if (ua is null)
        {
            return "unknown";
        }

        var client = Parser.GetDefault().Parse(ua);
        var deviceInfo = client.Device.Family;
        var browserInfo = $"{client.UA.Family}, {client.UA.Major}.{client.UA.Minor}";
        var osInfo = $"{client.OS.Family}, {client.OS.Major}.{client.OS.Minor}";
        //TODO: Add the user's IP address here, if it's a banking system.
        return $"{deviceInfo}, {browserInfo}, {osInfo}";
    }
در اینجا با استفاده از کتابخانه‌ی UA-Parser، سعی می‌کنیم تا جزئیات مرورگر و سیستم عامل شخص را تهیه کنیم. سپس در قسمت دیگری از این سرویس، این اطلاعات را هش می‌کنیم. از این جهت که هم حجم آن کاهش یابد و بی‌جهت کوکی و یا توکن ما را حجیم نکند و هم بررسی محتوای آن جهت شبیه سازی آن، غیرممکن شود. هر مشخصات دریافتی در حین لاگین، همواره یک هش مشخص و یکتا را دارد. به همین جهت متدهای هش کردن اطلاعات را هم در اینجا مشاهده می‌کنید. به علاوه‌ی متد HasUserTokenValidDeviceDetails که کار آن، دریافت Claim مرتبط با این اطلاعات، از کوکی و یا توکن جاری و مقایسه‌ی آن با اطلاعات Http Request جاری است. اگر این دو یکی نبودند، یعنی احتمال سوء استفاده‌ی از اطلاعات شخص، وجود دارد.


اضافه کردن اطلاعات مشخصات دستگاه کاربر به کوکی و یا توکن او

همانطور که عنوان شد، در متد HasUserTokenValidDeviceDetails، ابتدا مشخصات دستگاه موجود در کوکی و یا توکن دریافتی، استخراج می‌شود. به همین جهت نیاز است این مشخصات را دقیقا در حین لاگین موفق، به صورت یک Claim جدید، برای مثال از نوع ClaimTypes.System به مجموعه‌ی Claims کاربر اضافه کرد:
new(ClaimTypes.System, _deviceDetectionService.GetCurrentRequestDeviceDetailsHash(),
ClaimValueTypes.String, _configuration.Value.Issuer),


یکپارچه کردن DeviceDetectionService با اعتبارسنج‌های کوکی‌ها و توکن‌ها

پس از افزودن مشخصات سیستم کاربر وارد شده‌ی به سیستم، به صورت یک Claim جدید به توکن‌ها، روش اعتبارسنجی اطلاعات موجود در توکن رسیده، در رخ‌داد گردان OnTokenValidated است که امکان دسترسی به HttpContext و محتوای توکن را میسر می‌کند:
.AddJwtBearer(cfg =>
{
      cfg.Events = new JwtBearerEvents
      {
           OnTokenValidated = context =>
           {
               var tokenValidatorService = context.HttpContext.RequestServices.GetRequiredService<ITokenValidatorService>();
              return tokenValidatorService.ValidateAsync(context);
           },
       };
  });
و یا اگر از کوکی‌ها استفاده می‌کنید، معادل آن به صورت زیر است:
.AddCookie(options =>
{
    options.Events = new CookieAuthenticationEvents
    {
       OnValidatePrincipal = context =>
       {
         var cookieValidatorService = context.HttpContext.RequestServices.GetRequiredService<ICookieValidatorService>();
         return cookieValidatorService.ValidateAsync(context);
       }
    };
});

در کل تمام تغییرات مورد نیاز مرتبط را جهت یک برنامه‌ی تولید کننده‌ی JWT در اینجا و برای یک برنامه‌ی مبتنی بر کوکی‌ها در اینجا می‌توانید مشاهده کنید.
  • #
    ‫۱ سال و ۴ ماه قبل، سه‌شنبه ۸ فروردین ۱۴۰۲، ساعت ۱۴:۲۵
    با توجه به اینکه متد DeviceDetectionService با HttpContext اطلاعات مربوط به سیستم کاربر را استخراج می‌کند، در رابطه با اپلیکیشن Blazor Server که از مکانیزم کوکی استفاده می‌کند (مثال) و دسترسی به HttpContext پس از تشکیل هاب، مقدار معتبری را در بر ندارد، چکار باید کرد؟
    • #
      ‫۱ سال و ۴ ماه قبل، سه‌شنبه ۸ فروردین ۱۴۰۲، ساعت ۱۶:۵۶
      context مورد استفاده در حین تعیین اعتبار کوکی در متد ValidateAsync(CookieValidatePrincipalContext context):
      OnValidatePrincipal = context =>
      به همراه HttpContext پر شده‌ی در حین لاگین هست که حاوی user-agent کاربر جاری هم هست.
  • #
    ‫۱ سال و ۴ ماه قبل، شنبه ۲ اردیبهشت ۱۴۰۲، ساعت ۱۱:۰۴
    یک نکته‌ی تکمیلی
    « fingerprintjs » کتابخانه‌ای است برای انتساب یک device-id به کاربر (بر اساس user-agent، اندازه‌ی صفحه، فونت‌های نصب شده، پلاگین‌های در حال استفاده و غیره). برای استفاده‌ی از آن، ابتدا اسکریپت آن باید اضافه شود:
    <script src="https://cdn.jsdelivr.net/npm/@fingerprintjs/fingerprintjs@3.12.1/dist/fingerprint2.min.js"></script>
    سپس یک کوکی از نتیجه‌ی آن تهیه می‌شود:
    string fingerprint = "";
    string fingerprintScript = @"<script>
        const fpPromise = FingerprintJS.load();
        fpPromise.then(fp => {
            fp.get().then(result => {
                const values = result.values;
                const fingerprint = values.join('');
                document.cookie = 'DeviceFingerprint=' + fingerprint + '; path=/';
            });
        });
    </script>";
    Response.Write(fingerprintScript);
    و این کوکی که با نام DeviceFingerprint ذخیره شده، به صورت زیر قابل خواندن است:
    string fingerprint = Request.Cookies["DeviceFingerprint"]?.Value;
    if (fingerprint == null)
    {
        // Device fingerprint cookie not found
    }
    • #
      ‫۱ سال و ۳ ماه قبل، پنجشنبه ۷ اردیبهشت ۱۴۰۲، ساعت ۲۱:۱۴
      سلام؛ من از این روش « fingerprintjs » در پروژم استفاده کردم. دیدیم بعضی وقت‌ها به طور تصادفی کد fingerprint تغییر میکنه و باعث logout کاربر میشه ولی وقتی صفحه رو refresh میکنم درست میشه و همون کد اولی رو ارسال میکنه مشکل از کجاست؟
  • #
    ‫۱ سال و ۴ ماه قبل، سه‌شنبه ۵ اردیبهشت ۱۴۰۲، ساعت ۰۵:۴۱
    پروژه مشابه جهت دریافت اطلاعات کلاینت (انجین مرورگر، سیستم عامل و...) :
    DeviceDetector.NET  
  • #
    ‫۱ سال قبل، چهارشنبه ۱۸ مرداد ۱۴۰۲، ساعت ۱۱:۴۵
    من اخیرا متوجه شدم که اکثر سایتها این مشکلو دارن. از جمله سایتهایی که خودم با ام وی سی یا کور نوشتم. حتی جالبه که بعد از لاگ اوت هم با کپی کردن کوکی میشه لاگین شد!!
    توی یکی از پروژه هام که از نسخه قدیمی احراز هویت (Form Authentication) استفاده کردم دقیقا این مشکل هست. حتی بعد از فراخوانی FormAuthentication.SignOut() هم باز میشه با ساختن کوکی لاگین به صورت دستی توی کروم، وارد شد!
    توی استک این قطعه کد رو گذاشته بودن که تست کردم ولی بازم حل نشد! 
    FormsAuthentication.SignOut();
    Session.Abandon();
    
    // clear authentication cookie HttpCookie cookie1 = new HttpCookie(FormsAuthentication.FormsCookieName, "");
    cookie1.Expires = DateTime.Now.AddYears(-1);
    Response.Cookies.Add(cookie1);
    
    // clear session cookie (not necessary for your current problem but i would recommend you do it anyway) SessionStateSection sessionStateSection = (SessionStateSection)WebConfigurationManager.GetSection("system.web/sessionState");
    HttpCookie cookie2 = new HttpCookie(sessionStateSection.CookieName, "");
    cookie2.Expires = DateTime.Now.AddYears(-1);
    Response.Cookies.Add(cookie2);
    
    FormsAuthentication.RedirectToLoginPage();

    اصلا چرا این مشکل وجود داره؟ چرا بعد از خروج، سشن کاربر حذف نمیشه؟
    • #
      ‫۱ سال قبل، چهارشنبه ۱۸ مرداد ۱۴۰۲، ساعت ۱۴:۰۳
      این مشکل نیست. این توکن‌ها متکی به خود (self contained) هستند و همه چیز را جهت استفاده‌ی کامل از آن‌ها، درون خود دارند و تا زمانیکه یکسری از claims موجود در آن‌ها منقضی نشود (مانند طول عمر تنظیم شده‌ی آن‌ها) یا تغییر نکنند، از سمت سرور بدون هیچ مشکلی، اعتبارسنجی خواهند شد. زمانیکه logout می‌کنید، فقط این توکن‌ها را از کش‌های مختلف مرورگر حذف می‌کنید؛ اما ردی از حذف شدن و غیرمعتبر شدن آن، در سمت سرور ذخیره نمی‌شود و اگر کاربری قبلا این توکن را ذخیره کرده باشد، باز هم باتوجه به متکی به خود بودن آن، می‌تواند از آن استفاده‌ی مجدد کند. برای برگشت زدن توکن‌های متکی به خود، در سمت سرور، باید داخل آن‌ها یک claim سفارشی مانند serial number، قرار داد و همچنین در سمت سرور هم این serial number را ذخیره کرد. بعد در حین logout، این serial number را در بانک اطلاعاتی تغییر داد تا دفعه‌ی بعدی که قرار است از توکن متکی به خود استفاده شود، اعتبارسنجی ثانویه‌ای بر روی این claim دریافتی از کاربر و مقایسه‌ی آن با مقدار موجود در بانک اطلاعاتی در سمت سرور هم انجام شود (حالت پیش‌فرض اکثر سیستم‌های اعتبارسنجی، فاقد این مرحله است). اینکار در ASP.NET Core Identity تحت عنوان مفهوم security stamp پیاده سازی شده و وجود دارد؛ در JWTها توسط TokenValidatorService و در کوکی‌ها، توسط CookieValidatorService قابل پیاده سازی است. در نگارش‌های قبلی ASP.NET و حالت استفاده‌ی از Forms authentication، امکان بررسی سفارشی وضعیت کاربر جاری در authenticate request هم وجود دارد. مطلب جاری، به غنی‌سازی Validator Service‌های اشاره شده، می‌پردازد.
  • #
    ‫۱۰ ماه قبل، چهارشنبه ۳ آبان ۱۴۰۲، ساعت ۱۱:۰۳
    استاندارد جدیدی در مرورگرها در حال پیاده سازی است، جهت محدود کردن استفاده از یک توکن یک کلاینت، فقط در همان مرورگر اولیه‌ای که آن‌را دریافت کرده و نه در سایر مرورگرها. در این لحظه فقط Microsoft edge این استاندارد را پیاده سازی کرده‌است.
  • #
    ‫۱ ماه قبل، جمعه ۱۲ مرداد ۱۴۰۳، ساعت ۰۷:۴۰

    یک نکته‌ی تکمیلی: گوگل برای مواجه شدن با یک چنین مشکلاتی،‌ در حال پیاده سازی «device bound sessions» است. همچنین نحوه‌ی رمزنگاری اطلاعات کوکی‌های کروم نیز در ویندوز به‌روز خواهد شد و دیگر صرفا از API خود ویندوز استفاده نمی‌کنند.

  • #
    ‫۱ ماه قبل، سه‌شنبه ۲۳ مرداد ۱۴۰۳، ساعت ۱۹:۳۷

    یک نکته‌ی تکمیلی: به روز رسانی کتابخانه‌ی UAParser

    در این مطلب از کتابخانه‌ی UAParser استفاده شد. این کتابخانه، چندسالی است که به‌روز نشده؛ البته چون نیازی نبوده! در اصل، این کتابخانه از فایل yaml مخصوصی که به صورت جاسازی شده (embedded) در آن قرار دارد، برای شناسایی مرورگرها استفاده می‌کند (مفهوم استفاده از متد ()Parser.GetDefault همین مورد است). بنابراین یا باید خودتان این فایل yaml را دستی به روز کرده (کار مخزن کد فعال UAParser-Core، فقط همین یک مورد است) و سپس کتابخانه را مجددا کامپایل و استفاده کنید و یا می‌توانید محتویات فایل yaml ذکر شده را دریافت و سپس با استفاده از متد Parser.FromYaml این کتابخانه، اطلاعات جدید دریافتی را پردازش و استفاده کنید؛ مانند UAParserService.