Дві проблеми двофакторної автентифікації ASP.NET Core Identity на основі TOTP

Identity прекрасне рішення, яке в зручний та швидкий спосіб допоможе під'єднати автентифікацію та авторизацію у твій проєкт ASP.NET Core. Розробники з коробки вже мають безліч сучасних інструментів, що, крім основної функціональності, також допоможуть швидко під'єднати такі можливості, як зміна логіну чи пароля, автентифікація через зовнішні провайдери та, крім іншого, двофакторну автентифікацію (2fa).

Одні з найбільш популярних підходів до реалізації 2fa це висилка коду на пошту, смс повідомлення, passwordless/FIDO2 рішення та TOTP (Time-based one-time password).

Висилка смс вважається найбільш незахищеним рішенням, а FIDO2 протокол ще не підтримується з коробки застосунками ASP.NET Core.

Тому найбільш популярним є TOTP протокол, що працює в парі з такими застосунками як Microsoft Authenticator (MA) та Google Authenticator (GA).

Як працює TOTP?

Коли користувач хоче додати новий пристрій для 2fa, сервер генерує спеціальний токен, який користувач має скопіювати до застосунку, що проводить автентифікацію або просканувати ним Qr Code. Після цього кожні 30 секунд застосунок буде генерувати новий код верифікації. Під час перевірки цього коду, сервер генерує свій код і, якщо вони збігаються, сервер авторизує користувача.

Що власне не так?

Код верифікації генерується на основі секретного ключа та часу (тому і TIME BASED). Відповідно час на мобільному пристрої та сервері має збігатися. Будь-яка значна розбіжність призведе до того, що застосунок буде генерувати код відмінний від того, що генерує сервер.

Щоб вирішити цю проблему часове вікно в Identity, в рамках якого діє код верифікації, в п’ять разів більше ніж те саме вікно в застосунках MA або GA.

Тут виникає інша проблема. Код верифікації буде дійсний протягом аж двох з половиною хвилин. Це вікно ніяк не можна налаштувати. Та розробники, з турботою про нас, залишили такий коментар:

// Allow codes from 90s in each direction (we could make this configurable?)

You must!

Щоб краще зрозуміти, про що йде мова давайте поглянемо у вихідний код:

// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Security.Cryptography;
using System.Threading.Tasks;

namespace Microsoft.AspNetCore.Identity;

/// <summary>
/// Used for authenticator code verification.
/// </summary>
public class AuthenticatorTokenProvider<TUser> : IUserTwoFactorTokenProvider<TUser> where TUser : class
{
    /// <summary>
    /// Checks if a two-factor authentication token can be generated for the specified <paramref name="user"/>.
    /// </summary>
    /// <param name="manager">The <see cref="UserManager{TUser}"/> to retrieve the <paramref name="user"/> from.</param>
    /// <param name="user">The <typeparamref name="TUser"/> to check for the possibility of generating a two-factor authentication token.</param>
    /// <returns>True if the user has an authenticator key set, otherwise false.</returns>
    public virtual async Task<bool> CanGenerateTwoFactorTokenAsync(UserManager<TUser> manager, TUser user)
    {
        var key = await manager.GetAuthenticatorKeyAsync(user).ConfigureAwait(false);

        return !string.IsNullOrWhiteSpace(key);
    }

    /// <summary>
    /// Returns an empty string since no authenticator codes are sent.
    /// </summary>
    /// <param name="purpose">Ignored.</param>
    /// <param name="manager">The <see cref="UserManager{TUser}"/> to retrieve the <paramref name="user"/> from.</param>
    /// <param name="user">The <typeparamref name="TUser"/>.</param>
    /// <returns>string.Empty.</returns>
    public virtual Task<string> GenerateAsync(string purpose, UserManager<TUser> manager, TUser user)
    {
        return Task.FromResult(string.Empty);
    }

    /// <summary>
    ///
    /// </summary>
    /// <param name="purpose"></param>
    /// <param name="token"></param>
    /// <param name="manager"></param>
    /// <param name="user"></param>
    /// <returns></returns>
    public virtual async Task<bool> ValidateAsync(string purpose, string token, UserManager<TUser> manager, TUser user)
    {
        var key = await manager.GetAuthenticatorKeyAsync(user).ConfigureAwait(false);
        int code;
        if (key == null || !int.TryParse(token, out code))
        {
            return false;
        }

        var keyBytes = Base32.FromBase32(key);

#if NET6_0_OR_GREATER
        var unixTimestamp = DateTimeOffset.UtcNow.ToUnixTimeSeconds();
#else
        using var hash = new HMACSHA1(keyBytes);
        var unixTimestamp = Convert.ToInt64(Math.Round((DateTime.UtcNow - new DateTime(1970, 1, 1, 0, 0, 0)).TotalSeconds));
#endif

        var timestep = Convert.ToInt64(unixTimestamp / 30);
        // Allow codes from 90s in each direction (we could make this configurable?)
        for (int i = -2; i <= 2; i++)
        {
#if NET6_0_OR_GREATER
            var expectedCode = Rfc6238AuthenticationService.ComputeTotp(keyBytes, (ulong)(timestep + i), modifierBytes: null);
#else
            var expectedCode = Rfc6238AuthenticationService.ComputeTotp(hash, (ulong)(timestep + i), modifierBytes: null);
#endif
            if (expectedCode == code)
            {
                return true;
            }
        }

        return false;
    }
}

Нас цікавить метод ValidateAsync та цикл for.

Перед циклом генерується номер поточного 30-секундного часового вікна, і потім в циклі обчислюються верифікаційні коди для двох попередніх, поточного і двох наступних часових вікон (разом 150 секунд). Якщо хоча б один з цих кодів збігається з кодом від користувача, то автентифікація буде успішною.

Потенційний злочинець, перехопивши такий код (наприклад атакою Man-in-the-middle), матиме достатньо часу, щоб автентифікуватися. 

Для захисту від такого типу атак можна просто  не приймати ті коди, які вже використовувались для проходження автентифікації. Специфікація протоколу TOTP прямо говорить, що так має бути:

Note that a prover may send the same OTP inside a given time-step window multiple times to a verifier.  The verifier MUST NOT accept the second attempt of the OTP after the successful validation has been issued for the first OTP, which ensures one-time only use of an OTP.

Але стандартна реалізація валідації в ASP.NET Core Identity не робить такої перевірки. 

Ба більше, розробники Майкрософт знають про цю проблему, бо є відповідне issue на Github. Та вони просто закинули це у backlog.

Що у підсумку?

Щоб підігнати часове вікно під потреби конкретного застосунку та повністю дотримуватись стандартизації TOTP (не допускати кодів, які вже були використані для автентифікації)  варто задуматися над написанням власного валідатора, що може базуватися на вже готовій реалізації від Майкрософт. 

Для скорочення тривалості часового вікна, можна зменшити число проходжень у циклі з п'яти до, скажімо, трьох. А для збереження вже введених верифікаційних кодів можна використати стандартний механізм ASP.NET Core – MemoryCache

Якщо вам було цікаво читати, то можете купити мені каву. Я від неї трішки залежний, так що буду безмежно вдячний кожній чашці ❤️
Поділись своїми ідеями в новій публікації.
Ми чекаємо саме на твій довгочит!
Петро Копил
Петро Копил@p.kopyl

👨‍💻 Розробник .NET

285Прочитань
8Автори
8Читачі
Підтримати
На Друкарні з 15 квітня

Більше від автора

Вам також сподобається

Коментарі (0)

Підтримайте автора першим.
Напишіть коментар!

Вам також сподобається