به صورت پیشفرض دات نت از کلاس Rfc6238AuthenticationService برای ایجاد و اعتبارسنجی کد totp استفاده می‌کند. مشکلی که در این کلاس وجود دارد آن است که کد totp را که تولید می‌کند به مدت 9 دقیقه معتبر است. این زمان قابل تغییر نیست و به صورت هارد کد نوشته شده است. لینک مشکل

نحوه عملکرد این سرویس به اینصورت است که ابتدا Ticks مربوط به 3 دقیقه را که برابر است با 1800000000 دریافت می‌کند. سپس Ticks مربوط به زمان فعلی را از زمان UnixEpoch کم می‌کند.

private static ulong GetCurrentTimeStepNumber()
{
#if NETSTANDARD2_0 || NET461
    var delta = DateTime.UtcNow - _unixEpoch;
#else
    var delta = DateTimeOffset.UtcNow - DateTimeOffset.UnixEpoch;
#endif
    return (ulong)(delta.Ticks / _timestep.Ticks);
}

سپس مقدار به دست آمده delta را بر 1800000000 تقسیم می‌کند و آن را به عنوان GetCurrentTimeStepNumber برگشت می‌دهد. این عدد بازه زمانی سه دقیقه‌ای الان را نمایش می‌دهد. یعنی بازه 12:00 تا 12:03 یک عدد را نمایش می‌دهد.(در صورتی که این عدد از 3 دقیقه به 5 دقیقه تغییر کند، هر 5 دقیقه یک عدد جدید به شما می‌دهد. برای مثال بازه زمانی 12:40 تا 12:45 یک عدد به شما برگشت می‌دهد). سپس این عدد را به عنوان GetCurrentTimeStepNumber را به متد بعدی برای رمزنگاری ارسال می‌کند و کد برای کاربر ارسال می‌شود.

سپس در مرحله بعد که باید کد ارسالی از سمت کاربر اعتبار سنجی شود باید کد ارسالی را بررسی کنیم که آیا کد در بازه فعلی معتبر است یا نه. 

مشکلی که در این روش وجود دارد آن است که امکان دارد کاربر در ساعت 12:42 درخواست کد را ارسال کرده باشد و کد ایجاد شده برای کاربر فقط در بازه 12:40 تا 12:43 معتبر است و عملا کاربر فقط 1 دقیقه زمان دارد تا کد را وارد نمایید در غیراینصورت زمان وارد مرحله بعد یعنی 12:44 تا 12:46 می‌شود و کد ارسالی نامعتبر است. برای حل این مشکل در کلاس Rfc6238AuthenticationService به اینصورت  عمل کرده‌اند که از بازه زمانی 12:34 تا 12:49 کد مربوط به ساعت 12:42 معتبر باشد. این کار درون متد ValidateCode انجام شده است. از یک حلقه for استفاده شده است که زمان فعلی را از متد GetCurrentTimeStepNumber دریافت می‌کند و سپس آن را با بازه‌ -2 تا 2 جمع می‌کند در صورتی که با هرکدام از اعداد اعتبارسنجی با موفقیت انجام شود مقدار true را برمی‌گرداند. برای مثال عدد 456825 بازه 12:40 تا 12:43 را نمایش می‌دهد و اگر یک عدد به آن اضافه کنیم وارد بازه 12:43 تا 12:46 می‌شود.

مشکل این روش بازه زمانی آن است که قابل تغییر نیست و مدت زمان آن نسبتا زیاد است.

public static bool ValidateCode(byte[] securityToken, int code, string modifier = null)
{
    if (securityToken == null)
    {
        throw new ArgumentNullException(nameof(securityToken));
    }

    // Allow a variance of no greater than 9 minutes in either direction
    var currentTimeStep = GetCurrentTimeStepNumber();
    using (var hashAlgorithm = new HMACSHA1(securityToken))
    {
        for (var i = -2; i <= 2; i++)
        {
            var computedTotp = ComputeTotp(hashAlgorithm, (ulong)((long)currentTimeStep + i), modifier);
            if (computedTotp == code)
            {
                return true;
            }
        }
    }

    // No match
    return false;
}

برای حل این مشکل متد مربوطه را به صورتی تغییر دادیم که به جای آنکه intervalهای (12:34، 12:37، 12:40، 12:43، 12:46) را با کد ارسالی مقایسه کند، در زمان ایجاد کد در متد GetCurrentTimeStepNumber از بازه 1 دقیقه‌ای به جای 3 دقیقه‌ای استفاده کردیم، سپس در متد مربوط به ValidateCode از یک حلقه for استفاده کردیم که بازه‌ی آن از منفی زمان انقضای کاربر شروع شده و تا 1 ادامه دارد. سپس عدد i را به زمان utc اضافه می‌کنیم در متد GetNextTimeStepNumber. سپس در هرکدام از بازه‌های مربوطه کد معتبر باشد مقدار true برگشت داده می‌شود. در واقع با این کار به جای بررسی بازه‌های 3 دقیقه‌ای از بازه‌های 1 دقیقه‌ای استفاده کردیم که هم باعث امنیت بیشتر می‌شود و کد فقط تا زمان انقضای آن معتبر است و بیشتر از آن نیست. درواقع در این روش به صورت زیر عمل می‌شود: برای مثال اگر کد در ساعت 12:44 ایجاد شود و زمان انقضای کد 3 دقیقه باشد، کد در ساعت‌های 12:44، 12:45، 12:46، 12:47 معتبر است. در این روش فقط 1 دقیقه اضافه در نظرگرفته می‌شود برای زمانی که کاربر در ساعت 12:44:59 درخواست ارسال کد را کرده باشد و برای 1 ثانیه باقی مانده 1 دقیقه را از دست ندهد. با این کار کد کاربر در بازه زمانی 12:44، 12:45، 12:46، 12:47 معتبر است. در بدترین حالت کاربر در ساعت 12:44:01 درخواست کد را ارسال می‌کند و تا ساعت 12:47:59 کد معتبر است.

متد مربوط به ValidateCode به صورت زیر تغییر کرده است:

public static bool ValidateCode(
    string securityStampToken,
    int code,
    string modifier,
    int expirationInMinutes)
{
    if (securityStampToken == null)
    {
        throw new ArgumentNullException(nameof(securityStampToken));
    }

    byte[] securityTokenBytes = GetBytes(securityStampToken);

    using (var hashAlgorithm = new HMACSHA1(securityTokenBytes))
    {
        for (var i = -Math.Abs(expirationInMinutes); i <= 1; i++)
        {
            var currentTimeStep = GetNextTimeStepNumber(i);

            var computedTotp = ComputeTotp(hashAlgorithm, (ulong)((long)currentTimeStep), modifier);
            if (computedTotp == code)
            {
                return true;
            }
        }
    }

    // No match
    return false;
}

در کلاس نوشته شده شما فقط به دومتد GenerateCode و ValidateCode دسترسی دارید.

متد GenerateCode نیازمند یک SecurityStamp مربوط به کاربر است و یک modifier به صورت رشته. modifier باید شامل یک آیدی منحصر به فرد کاربر و هدف ایجاد کد باشد. برای مثال کاربر با آیدی 1 می‌خواهد 2fa را فعال کند می‌تواند modifier آن به صورت زیر باشد:

Totp:1:TwoFactorActivation

 متد ValidateCode نیازمند SecurityStamp کاربر، کد ارسالی برای کاربر، modifier و زمان انقضای توکن باشد.

public async ValueTask<bool> ValidateTwoFactorTotpCode(string email, int totpCode)
{
    var user = await GetUserByEmail(email);

    string modifier = GetModifier(user, TotpEnum.TwoFactor);

    var isValid = TotpService.ValidateCode(
        user.SecurityStamp.ToString(),
        totpCode,
        modifier,
        _userOption.TwoFactorTotpExpiration);

    return isValid;
}

نحوه استفاده از این سرویس به صورت زیر می‌باشد:

//Generate totp code
var totpCode = TotpService.GenerateCode(user.SecurityStamp.ToString(), modifier);
//Verify totp code
var isValid = TotpService.ValidateCode(
    user.SecurityStamp.ToString(), 
    totpCode, 
    modifier, 
    _userOption.TwoFactorTotpExpiration);

کدهای این مطلب را می‌توانید از github دانلود کنید. همچنین پروژه مربوط به نحوه استفاده از سرویس دراین مسیر قرار دارد.

نکته: بهتر است برای هرکدام از عملیات‌ها از یک modifier جدا استفاده نمایید. برای مثال زمانی که کاربر میخواهد 2fa را فعال کند، یک modifier به صورت "Totp:1:TwoFactorActivation" استفاده کنید و برای لاگین دو مرحله‌ای از "Totp:1:TwoFactorAuthentication" استفاده کنید. با این کار هر کدام از کدها فقط می‌تواند یک کار را انجام دهد و با کد مربوط به فعال سازی 2fa نمیتوانید 2fa را غیرفعال یا لاگین کنید.

در صورتی که بخواهید متد کد totp ایجاد شده توسط identity را تغییر دهید باید به صورت زیر عمل کنید:

ابتدا یک کلاس ایجاد کنید که از کلاس  TotpSecurityStampBasedTokenProvider ارث بری کرده باشد. سپس باید متدهای مربوط به GenerateAsync و ValidateAsync را override کنید و از TotpService به جای Rfc6238AuthenticationService  استفاده کنید.

متد GenerateAsync:

public override async Task<string> GenerateAsync(
    string purpose,
    UserManager<ApplicationUser> manager,
    ApplicationUser user)
{
    if (manager == null)
    {
        throw new ArgumentNullException(nameof(manager));
    }
    var token = await manager.CreateSecurityTokenAsync(user);
    var modifier = await GetUserModifierAsync(purpose, manager, user);

    return TotpService.GenerateCode(token, modifier).ToString("D6", CultureInfo.InvariantCulture);
}

متد ValidateAsync:

public override async Task<bool> ValidateAsync(
    string purpose,
    string token,
    UserManager<ApplicationUser> manager,
    ApplicationUser user)
{
    if (manager == null)
    {
        throw new ArgumentNullException(nameof(manager));
    }
    int code;
    if (!int.TryParse(token, out code))
    {
        return false;
    }
    var securityToken = await manager.CreateSecurityTokenAsync(user);
    var modifier = await GetUserModifierAsync(purpose, manager, user);

    return securityToken != null && TotpService.ValidateCode(securityToken, code, modifier, _userOption.TwoFactorTotpExpiration);
}

سپس کلاس ایجاد شده را به Identity معرفی نمایید:

services.AddIdentity<ApplicationUser, ApplicationRole>()
    .AddEntityFrameworkStores<IdentityContext>()
    .AddDefaultTokenProviders()
    .AddTokenProvider<CustomTotpSecurityStampBasedTokenProvider >("CustomTotp");

و در زمان ایجاد و اعتبار سنجی کد باید اسم provider آن را به UserManager ارسال کنید:

var verificationCode = await _userManager.GenerateTwoFactorTokenAsync(user, "CustomTotp");
var verify = await _userManager.VerifyTwoFactorTokenAsync(user, "CustomTotp", request.VerificationCode);

همچنین سرویس مربوطه در NuGet آپلود شده است و می‌توانید آن را به پروژه خود اضافه نمایید:

<PackageReference Include="TotpGenerator" Version="1.0.2" />

به روزرسانی:

در صورتی که بخواهید کد ایجاد شده دقیقا معادل با زمان انقضای آن منقضی شود، کافیست تغییرات زیر را در کلاس TotpService انجام دهید:

1 - تغییر زمان TimeStep

private static readonly TimeSpan _timeStep = TimeSpan.FromSeconds(1);

2 - تغییر متد ValidateAsync. 

public static bool ValidateCode(
    string securityStampToken,
    int code,
    string modifier,
    int expirationInMinutes)
{
    if (securityStampToken == null)
    {
        throw new ArgumentNullException(nameof(securityStampToken));
    }

    byte[] securityTokenBytes = GetBytes(securityStampToken);

    using (var hashAlgorithm = new HMACSHA1(securityTokenBytes))
    {
        for (var i = -Math.Abs(expirationInMinutes * 60); i <= 1; i++)
        {
            var currentTimeStep = GetNextTimeStepNumber(i);

            var computedTotp = ComputeTotp(hashAlgorithm, (ulong)((long)currentTimeStep), modifier);
            if (computedTotp == code)
            {
                return true;
            }
        }
    }

    // No match
    return false;
}

3 - تغییر متد GetNextTimeStepNumber. اضافه کردن ثانیه به جای دقیقه

private static ulong GetNextTimeStepNumber(int seconds)
{
#if NETSTANDARD2_0
    var delta = DateTime.UtcNow.AddSeconds(seconds) - _unixEpoch;
#else
    var delta = DateTimeOffset.UtcNow.AddSeconds(seconds) - DateTimeOffset.UnixEpoch;
#endif
    return (ulong)(delta.Ticks / _timeStep.Ticks);
}

با این کار بازه زمانی را از دقیقه به ثانیه تغییر داده‌ایم و کد دقیقا به مدت زمان ذخیره شده معتبر است و زمان اضافی  مربوط به 1 دقیقه هم از بین می‌رود.

 ;)

Powered by Froala Editor

نظرات