Поделиться через


Включение создания QR-кода для приложений проверки подлинности TOTP в ASP.NET Core Blazor WebAssembly с помощью ASP.NET Core Identity

Заметка

Это не последняя версия этой статьи. Сведения о текущем выпуске см. в версии .NET 9 этой статьи.

Важный

Эта информация относится к предварительному выпуску продукта, который может быть существенно изменен до его коммерческого выпуска. Корпорация Майкрософт не предоставляет никаких гарантий, явных или подразумеваемых в отношении информации, предоставленной здесь.

Сведения о текущем выпуске см. в версии .NET 9 этой статьи.

В этой статье объясняется, как настроить приложение ASP.NET Core Blazor WebAssembly с помощью Identity для двухфакторной аутентификации (2FA) с использованием QR-кодов, созданных приложениями проверки подлинности, реализующими алгоритм одноразовых паролей на основе времени (TOTP).

Общие сведения о 2FA с приложениями проверки подлинности TOTP см. в статье Включение создания QR-кода для приложений проверки подлинности TOTP в ASP.NET Core.

Предупреждение

Коды TOTP должны храниться в секрете, так как их можно использовать для проверки подлинности несколько раз до истечения срока их действия.

Примеры пространств имен и примеры кода для статей

Пространства имен, используемые примерами в этой статье, являются:

  • Backend для проекта веб-API серверного сервера, описанного как "серверный проект" в этой статье.
  • BlazorWasmAuth для автономного приложения фронтенд-клиента Blazor WebAssembly, описанного как "клиентский проект" в этой статье.

Эти пространства имен соответствуют проектам в примере решения BlazorWebAssemblyStandaloneWithIdentity в dotnet/blazor-samples репозитории GitHub. Ознакомьтесь с разделом Secure ASP.NET Core Blazor WebAssembly с ASP.NET Core Identityдля получения дополнительной информации.

Если вы не используете пример BlazorWebAssemblyStandaloneWithIdentity, измените пространства имен в примерах кода, чтобы использовать пространства имен проектов.

Все изменения решения, описанные в этой статье, происходят в проекте BlazorWasmAuth решения BlazorWebAssemblyStandaloneWithIdentity.

В примерах статей строки кода разделены для уменьшения горизонтальной прокрутки. Эти разрывы не влияют на выполнение, но их можно удалить при вставке в проект.

Необязательное подтверждение учетной записи и восстановление паролей

Хотя приложения, реализующие 2FA, обычно применяют функции подтверждения учетной записи и восстановления паролей, 2FA не требует его. Рекомендации, приведенные в этой статье, можно использовать для реализации 2FA без следования рекомендациям по подтверждению учетной записи и восстановлению паролей в ASP.NET Core Blazor WebAssembly с ASP.NET Core Identity.

Добавление в приложение библиотеки QR-кода

QR-код, созданный приложением для настройки 2FA с приложением toTP authenticator, должен быть создан библиотекой QR-кода.

В этом руководстве используется manuelbl/QrCodeGenerator, но вы можете использовать любую библиотеку создания QR-кода.

Добавьте ссылку на пакет в клиентский проект для пакета NuGet Net.Codecrete.QrCodeGenerator.

Заметка

Рекомендации по добавлению пакетов в приложения .NET см. в статьях, приведенных в разделе Установка пакетов и управление ими в рабочий процесс использования пакетов (документация по NuGet). Проверьте правильные версии пакетов в NuGet.org.

Назначьте имя организации TOTP

Задайте имя сайта в файле параметров приложения клиентского проекта. Используйте понятное имя сайта, которое пользователи могут легко определить в приложении authenticator. Разработчики обычно задают имя сайта, соответствующее имени компании. Мы рекомендуем ограничить длину имени сайта до 30 символов или меньше, чтобы имя сайта отображалось на узких экранах мобильных устройств.

В следующем примере название компании — Weyland-Yutani Corporation (©1986 20th Century Studios Aliens).

Добавлено в wwwroot/appsettings.json:

"TotpOrganizationName": "Weyland-Yutani Corporation"

Файл параметров приложения после добавления конфигурации имени организации TOTP:

{
  "BackendUrl": "https://localhost:7211",
  "FrontendUrl": "https://localhost:7171",
  "TotpOrganizationName": "Weyland-Yutani Corporation"
}

Добавление классов моделей

Добавьте следующий класс LoginResponse в папку Models. Этот класс заполняется для запросов к конечной точке /loginMapIdentityApi в серверном приложении.

Identity/Models/LoginResponse.cs:

namespace BlazorWasmAuth.Identity.Models;

public class LoginResponse
{
    public string? Type { get; set; }
    public string? Title { get; set; }
    public int Status { get; set; }
    public string? Detail { get; set; }
}

Добавьте следующий класс TwoFactorRequest в папку Models. Этот класс заполняется для запросов к конечной точке /manage/2faMapIdentityApi в серверном приложении.

Identity/Models/TwoFactorRequest.cs:

namespace BlazorWasmAuth.Identity.Models;

public class TwoFactorRequest
{
    public bool? Enable { get; set; }
    public string? TwoFactorCode { get; set; }
    public bool? ResetSharedKey { get; set; }
    public bool? ResetRecoveryCodes { get; set; }
    public bool? ForgetMachine {  get; set; }
}

Добавьте следующий класс TwoFactorResponse в папку Models. Этот класс заполняется ответом на запрос 2FA, сделанный в конечную точку /manage/2fa, принадлежащую MapIdentityApi, в серверном приложении.

Identity/Models/TwoFactorResponse.cs:

namespace BlazorWasmAuth.Identity.Models;

public class TwoFactorResponse
{
    public string SharedKey { get; set; } = string.Empty;
    public int RecoveryCodesLeft { get; set; } = 0;
    public string[] RecoveryCodes { get; set; } = [];
    public bool IsTwoFactorEnabled { get; set; }
    public bool IsMachineRemembered { get; set; }
    public string[] ErrorList { get; set; } = [];
}

интерфейс IAccountManagement

Добавьте следующие подписи класса в интерфейс IAccountManagement. Подписи классов представляют методы, добавленные в поставщик состояния проверки подлинности cookie для следующих клиентских запросов:

  • Войдите с кодом 2FA TOTP (конечная точка/login): LoginTwoFactorCodeAsync
  • Войдите с помощью кода восстановления 2FA (конечная точка/login): LoginTwoFactorRecoveryCodeAsync
  • Сделайте запрос на управление 2FA (точка доступа/manage/2fa): TwoFactorRequestAsync

Identity/IAccountManagement.cs (вставьте следующий код в нижней части файла):

public Task<FormResult> LoginTwoFactorCodeAsync(
    string email, 
    string password, 
    string twoFactorCode);

public Task<FormResult> LoginTwoFactorRecoveryCodeAsync(
    string email, 
    string password, 
    string twoFactorRecoveryCode);

public Task<TwoFactorResponse> TwoFactorRequestAsync(
    TwoFactorRequest twoFactorRequest);

Обновите CookieAuthenticationStateProvider, добавив следующие возможности:

  • Проверка подлинности пользователей с помощью кода приложения TOTP или кода восстановления.
  • Управление 2FA в приложении.

В верхней части файла CookieAuthenticationStateProvider.cs добавьте инструкцию using для System.Text.Json.Serialization:

using System.Text.Json.Serialization;

В JsonSerializerOptionsдобавьте параметр DefaultIgnoreCondition для JsonIgnoreCondition.WhenWritingNull, что позволяет избежать сериализации свойств NULL:

private readonly JsonSerializerOptions jsonSerializerOptions =
    new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
+       DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    };

Метод LoginAsync обновляется со следующей логикой:

  • Попробуйте выполнить обычный вход на конечной точке /login, используя адрес электронной почты и пароль.
  • Если сервер отвечает с кодом состояния успешности, метод возвращает FormResult со свойством Succeeded, заданным для true.
  • Если сервер отвечает с неавторизованным кодом состояния 401 — и подробным кодом "RequiresTwoFactor", возвращается FormResult, где Succeeded задано как false, а RequiresTwoFactor указываются в списке ошибок.

В Identity/CookieAuthenticationStateProvider.csзамените метод LoginAsync следующим кодом:

public async Task<FormResult> LoginAsync(string email, string password)
{
    try
    {
        using var result = await httpClient.PostAsJsonAsync(
            "login?useCookies=true", new
            {
                email,
                password
            });

        if (result.IsSuccessStatusCode)
        {
            NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());

            return new FormResult { Succeeded = true };
        }
        else if (result.StatusCode == HttpStatusCode.Unauthorized)
        {
            using var responseJson = await result.Content.ReadAsStringAsync();
            var response = JsonSerializer.Deserialize<LoginResponse>(
                responseJson, jsonSerializerOptions);

            if (response?.Detail == "RequiresTwoFactor")
            {
                return new FormResult
                {
                    Succeeded = false,
                    ErrorList = [ "RequiresTwoFactor" ]
                };
            }
        }
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "App error");
    }

    return new FormResult
    {
        Succeeded = false,
        ErrorList = [ "Invalid email and/or password." ]
    };
}

Добавлен метод LoginTwoFactorCodeAsync, который отправляет запрос в конечную точку /login с кодом 2FA TOTP (twoFactorCode). Метод обрабатывает ответ аналогично обычному запросу входа, отличному от 2FA.

Добавьте следующий метод и класс в Identity/CookieAuthenticationStateProvider.cs (вставьте следующий код в нижней части файла класса):

public async Task<FormResult> LoginTwoFactorCodeAsync(
    string email, string password, string twoFactorCode)
{
    try
    {
        using var result = await httpClient.PostAsJsonAsync(
            "login?useCookies=true", new
            {
                email,
                password,
                twoFactorCode
            });

        if (result.IsSuccessStatusCode)
        {
            NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());

            return new FormResult { Succeeded = true };
        }
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "App error");
    }

    return new FormResult
    {
        Succeeded = false,
        ErrorList = [ "Invalid two-factor code." ]
    };
}

Добавлен метод LoginTwoFactorRecoveryCodeAsync, который отправляет запрос в конечную точку /login с кодом восстановления 2FA (twoFactorRecoveryCode). Метод обрабатывает ответ аналогично обычному запросу входа, отличному от 2FA.

Добавьте следующий метод и класс в Identity/CookieAuthenticationStateProvider.cs (вставьте следующий код в нижней части файла класса):

public async Task<FormResult> LoginTwoFactorRecoveryCodeAsync(string email, 
    string password, string twoFactorRecoveryCode)
{
    try
    {
        using var result = await httpClient.PostAsJsonAsync(
            "login?useCookies=true", new
            {
                email,
                password,
                twoFactorRecoveryCode
            });

        if (result.IsSuccessStatusCode)
        {
            NotifyAuthenticationStateChanged(GetAuthenticationStateAsync());

            return new FormResult { Succeeded = true };
        }
    }
    catch (Exception ex)
    {
        logger.LogError(ex, "App error");
    }

    return new FormResult
    {
        Succeeded = false,
        ErrorList = [ "Invalid recovery code." ]
    };
}

Добавлен метод TwoFactorRequestAsync, который используется для управления 2FA для пользователя:

  • Сбросьте общий ключ 2FA, когда TwoFactorRequest.ResetSharedKey будет true. Сброс общего ключа автоматически отключает двухфакторную аутентификацию (2FA). Это заставляет пользователя доказать, что он может предоставить действительный код TOTP из приложения authenticator, чтобы включить 2FA после получения нового общего ключа.
  • Сброс кодов восстановления пользователя при TwoFactorRequest.ResetRecoveryCodestrue.
  • Забудьте об устройстве при TwoFactorRequest.ForgetMachine, true, что означает, что для следующей попытки входа требуется новый код 2FA TOTP.
  • Включите 2FA с помощью кода TOTP из приложения проверки подлинности TOTP, если TwoFactorRequest.Enabletrue и TwoFactorRequest.TwoFactorCode имеет допустимое значение TOTP.
  • Получите статус 2FA с пустым запросом, когда все свойства TwoFactorRequestявляются null.

Добавьте следующий метод TwoFactorRequestAsync в Identity/CookieAuthenticationStateProvider.cs (вставьте следующий код в нижней части файла класса):

public async Task<TwoFactorResponse> TwoFactorRequestAsync(TwoFactorRequest twoFactorRequest)
{
    string[] defaultDetail = 
        [ "An unknown error prevented two-factor authentication." ];

    using var response = await httpClient.PostAsJsonAsync("manage/2fa", twoFactorRequest, 
        jsonSerializerOptions);

    // successful?
    if (response.IsSuccessStatusCode)
    {
        return await response.Content
            .ReadFromJsonAsync<TwoFactorResponse>() ??
            new()
            { 
                ErrorList = [ "There was an error processing the request." ]
            };
    }

    // body should contain details about why it failed
    var details = await response.Content.ReadAsStringAsync();
    var problemDetails = JsonDocument.Parse(details);
    var errors = new List<string>();
    var errorList = problemDetails.RootElement.GetProperty("errors");

    foreach (var errorEntry in errorList.EnumerateObject())
    {
        if (errorEntry.Value.ValueKind == JsonValueKind.String)
        {
            errors.Add(errorEntry.Value.GetString()!);
        }
        else if (errorEntry.Value.ValueKind == JsonValueKind.Array)
        {
            errors.AddRange(
                errorEntry.Value.EnumerateArray().Select(
                    e => e.GetString() ?? string.Empty)
                .Where(e => !string.IsNullOrEmpty(e)));
        }
    }

    // return the error list
    return new TwoFactorResponse
    {
        ErrorList = problemDetails == null ? defaultDetail : [.. errors]
    };
}

Замена компонента Login

Замените компонент Login. Следующая версия компонента Login:

  • Принимает адрес электронной почты пользователя и пароль для первоначальной попытки входа.
  • Если вход выполнен успешно (2FA отключено), компонент уведомляет пользователя о том, что они аутентифицированы.
  • Если попытка входа приводит к ответу, указывающему, что требуется 2FA, элемент ввода 2FA отображается для получения либо кода TOTP из приложения-аутентификатора, либо кода восстановления. В зависимости от того, какой код вводит пользователь, попытка входа выполняется повторно путем вызова LoginTwoFactorCodeAsync для кода TOTP или LoginTwoFactorRecoveryCodeAsync для кода восстановления.

Components/Identity/Login.razor:

@page "/login"
@using System.ComponentModel.DataAnnotations
@using BlazorWasmAuth.Identity
@using BlazorWasmAuth.Identity.Models
@inject IAccountManagement Acct
@inject ILogger<Login> Logger
@inject NavigationManager Navigation

<PageTitle>Login</PageTitle>

<h1>Login</h1>

<AuthorizeView>
    <Authorized>
        <div class="alert alert-success">
            You're logged in as @context.User.Identity?.Name.
        </div>
    </Authorized>
    <NotAuthorized>
        @foreach (var error in formResult.ErrorList)
        {
            <div class="alert alert-danger">@error</div>
        }
        <div class="row">
            <div class="col">
                <section>
                    <EditForm Model="Input" method="post" OnValidSubmit="LoginUser" 
                            FormName="login" Context="editform_context">
                        <DataAnnotationsValidator />
                        <h2>Use a local account to log in.</h2>
                        <hr />
                        <div style="display:@(requiresTwoFactor ? "none" : "block")">
                            <div class="form-floating mb-3">
                                <InputText @bind-Value="Input.Email" 
                                    id="Input.Email" 
                                    class="form-control" 
                                    autocomplete="username" 
                                    aria-required="true" 
                                    placeholder="name@example.com" />
                                <label for="Input.Email" class="form-label">
                                    Email
                                </label>
                                <ValidationMessage For="() => Input.Email" 
                                    class="text-danger" />
                            </div>
                            <div class="form-floating mb-3">
                                <InputText type="password" 
                                    @bind-Value="Input.Password" 
                                    id="Input.Password" 
                                    class="form-control" 
                                    autocomplete="current-password" 
                                    aria-required="true" 
                                    placeholder="password" />
                                <label for="Input.Password" class="form-label">
                                    Password
                                </label>
                                <ValidationMessage For="() => Input.Password" 
                                    class="text-danger" />
                            </div>
                        </div>
                        <div style="display:@(requiresTwoFactor ? "block" : "none")">
                            <div class="form-floating mb-3">
                                <InputText @bind-Value="Input.TwoFactorCodeOrRecoveryCode" 
                                    id="Input.TwoFactorCodeOrRecoveryCode" 
                                    class="form-control" 
                                    autocomplete="off" 
                                    placeholder="###### or #####-#####" />
                                <label for="Input.TwoFactorCodeOrRecoveryCode" class="form-label">
                                    Two-factor Code or Recovery Code
                                </label>
                                <ValidationMessage For="() => Input.TwoFactorCodeOrRecoveryCode" 
                                    class="text-danger" />
                            </div>
                        </div>
                        <div>
                            <button type="submit" class="w-100 btn btn-lg btn-primary">
                                Log in
                            </button>
                        </div>
                        <div class="mt-3">
                            <p>
                                <a href="forgot-password">Forgot password</a>
                            </p>
                            <p>
                                <a href="register">Register as a new user</a>
                            </p>
                        </div>
                    </EditForm>
                </section>
            </div>
        </div>
    </NotAuthorized>
</AuthorizeView>

@code {
    private FormResult formResult = new();
    private bool requiresTwoFactor;

    [SupplyParameterFromForm]
    private InputModel Input { get; set; } = new();

    [SupplyParameterFromQuery]
    private string? ReturnUrl { get; set; }

    public async Task LoginUser()
    {
        if (requiresTwoFactor)
        {
            if (!string.IsNullOrEmpty(Input.TwoFactorCodeOrRecoveryCode))
            {
                // The [RegularExpression] data annotation ensures that the input 
                // is either a six-digit authenticator code (######) or an 
                // eleven-character alphanumeric recovery code (#####-#####)
                if (Input.TwoFactorCodeOrRecoveryCode.Length == 6)
                {
                    formResult = await Acct.LoginTwoFactorCodeAsync(
                        Input.Email, Input.Password, 
                        Input.TwoFactorCodeOrRecoveryCode);
                }
                else
                {
                    formResult = await Acct.LoginTwoFactorRecoveryCodeAsync(
                        Input.Email, Input.Password, 
                        Input.TwoFactorCodeOrRecoveryCode);

                    if (formResult.Succeeded)
                    {
                        var twoFactorResponse = await Acct.TwoFactorRequestAsync(new());
                    }
                }
            }
            else
            {
                formResult = 
                    new FormResult
                    {
                        Succeeded = false,
                        ErrorList = [ "Invalid two-factor code." ]
                    };
            }
        }
        else
        {
            formResult = await Acct.LoginAsync(Input.Email, Input.Password);
            requiresTwoFactor = formResult.ErrorList.Contains("RequiresTwoFactor");
            Input.TwoFactorCodeOrRecoveryCode = string.Empty;

            if (requiresTwoFactor)
            {
                formResult.ErrorList = [];
            }
        }

        if (formResult.Succeeded && !string.IsNullOrEmpty(ReturnUrl))
        {
            Navigation.NavigateTo(ReturnUrl);
        }
    }

    private sealed class InputModel
    {
        [Required]
        [EmailAddress]
        [Display(Name = "Email")]
        public string Email { get; set; } = string.Empty;

        [Required]
        [DataType(DataType.Password)]
        [Display(Name = "Password")]
        public string Password { get; set; } = string.Empty;

        [RegularExpression(@"^([0-9]{6})|([A-Z0-9]{5}[-]{1}[A-Z0-9]{5})$", 
            ErrorMessage = "Must be a six-digit authenticator code (######) or " +
            "eleven-character alphanumeric recovery code (#####-#####, dash " +
            "required)")]
        [Display(Name = "Two-factor Code or Recovery Code")]
        public string TwoFactorCodeOrRecoveryCode { get; set; } = string.Empty;
    }
}

Используя предыдущий компонент, данные пользователя запоминаются после успешной авторизации с допустимым кодом TOTP из приложения аутентификации. Если вы хотите всегда требовать код TOTP для входа и не запоминать компьютер, вызовите метод TwoFactorRequestAsync с TwoFactorRequest.ForgetMachine для параметра true сразу после успешного двухфакторного входа:

if (Input.TwoFactorCodeOrRecoveryCode.Length == 6)
{
    formResult = await Acct.LoginTwoFactorCodeAsync(Input.Email, Input.Password, 
        Input.TwoFactorCodeOrRecoveryCode);

+    if (formResult.Succeeded)
+    {
+        var forgetMachine = 
+            await Acct.TwoFactorRequestAsync(new() { ForgetMachine = true });
+    }
}

Добавление компонента для отображения кодов восстановления

Добавьте следующий компонент ShowRecoveryCodes в приложение, чтобы отобразить коды восстановления пользователю.

Components/Identity/ShowRecoveryCodes.razor:

<h3>Recovery codes</h3>

<div class="alert alert-warning" role="alert">
    <p>
        <strong>Put these codes in a safe place.</strong>
    </p>
    <p>
        If you lose your device and don't have an unused 
        recovery code, you can't access your account.
    </p>
</div>
<div class="row">
    <div class="col-md-12">
        @foreach (var recoveryCode in RecoveryCodes)
        {
            <div>
                <code class="recovery-code">@recoveryCode</code>
            </div>
        }
    </div>
</div>

@code {
    [Parameter]
    public string[] RecoveryCodes { get; set; } = [];
}

Страница управления 2FA

Добавьте следующий компонент Manage2fa в приложение для управления 2FA для пользователей.

Если 2FA не включена, компонент загружает форму с QR-кодом, чтобы включить 2FA с приложением проверки подлинности TOTP. Пользователь добавляет приложение в приложение authenticator, а затем проверяет приложение authenticator и включает 2FA, предоставляя код TOTP из приложения authenticator.

Если включена 2FA, кнопки отображаются для отключения 2FA и повторного создания кодов восстановления.

Components/Identity/Manage2fa.razor:

@page "/manage-2fa"
@using System.ComponentModel.DataAnnotations
@using System.Globalization
@using System.Text
@using System.Text.Encodings.Web
@using Net.Codecrete.QrCodeGenerator
@using BlazorWasmAuth.Identity
@using BlazorWasmAuth.Identity.Models
@attribute [Authorize]
@inject IAccountManagement Acct
@inject IAuthorizationService AuthorizationService
@inject IConfiguration Config
@inject ILogger<Manage2fa> Logger

<PageTitle>Manage 2FA</PageTitle>

<h1>Manage Two-factor Authentication</h1>
<hr />
<div class="row">
    <div class="col">
        @if (loading)
        {
            <p>Loading ...</p>
        }
        else
        {
            @if (twoFactorResponse is not null)
            {
                @foreach (var error in twoFactorResponse.ErrorList)
                {
                    <div class="alert alert-danger">@error</div>
                }
                @if (twoFactorResponse.IsTwoFactorEnabled)
                {
                    <div class="alert alert-success" role="alert">
                        Two-factor authentication is enabled for your account.
                    </div>

                    <div class="m-1">
                        <button @onclick="Disable2FA" class="btn btn-lg btn-primary">
                            Disable 2FA
                        </button>
                    </div>

                    @if (twoFactorResponse.RecoveryCodes is null)
                    {
                        <div class="m-1">
                            Recovery Codes Remaining: 
                            @twoFactorResponse.RecoveryCodesLeft
                        </div>
                        <div class="m-1">
                            <button @onclick="GenerateNewCodes" 
                                    class="btn btn-lg btn-primary">
                                Generate New Recovery Codes
                            </button>
                        </div>
                    }
                    else
                    {
                        <ShowRecoveryCodes 
                            RecoveryCodes="twoFactorResponse.RecoveryCodes" />
                    }
                }
                else
                {
                    <h3>Configure authenticator app</h3>
                    <div>
                        <p>To use an authenticator app:</p>
                        <ol class="list">
                            <li>
                                <p>
                                    Download a two-factor authenticator app, such 
                                    as either of the following:
                                    <ul>
                                        <li>
                                            Microsoft Authenticator for
                                            <a href="https://go.microsoft.com/fwlink/?Linkid=825072">
                                                Android
                                            </a> and
                                            <a href="https://go.microsoft.com/fwlink/?Linkid=825073">
                                                iOS
                                            </a>
                                        </li>
                                        <li>
                                            Google Authenticator for
                                            <a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2">
                                                Android
                                            </a> and
                                            <a href="https://itunes.apple.com/us/app/google-authenticator/id388497605?mt=8">
                                                iOS
                                            </a>
                                        </li>
                                    </ul>
                                </p>
                            </li>
                            <li>
                                <p>
                                    Scan the QR Code or enter this key 
                                    <kbd>@twoFactorResponse.SharedKey</kbd> into your 
                                    two-factor authenticator app. Spaces and casing 
                                    don't matter.
                                </p>
                                <div>
                                    <svg xmlns="http://www.w3.org/2000/svg" height="300" 
                                            width="300" stroke="none" version="1.1" 
                                            viewBox="0 0 50 50">
                                        <rect width="300" height="300" fill="#ffffff" />
                                        <path d="@svgGraphicsPath" fill="#000000" />
                                    </svg>
                                </div>
                            </li>
                            <li>
                                <p>
                                    After you have scanned the QR code or input the 
                                    key above, your two-factor authenticator app 
                                    will provide you with a unique two-factor code. 
                                    Enter the code in the confirmation box below.
                                </p>
                                <div class="row">
                                    <div class="col-xl-6">
                                        <EditForm Model="Input" 
                                                FormName="send-code" 
                                                OnValidSubmit="OnValidSubmitAsync" 
                                                method="post">
                                            <DataAnnotationsValidator />
                                            <div class="form-floating mb-3">
                                                <InputText 
                                                    @bind-Value="Input.Code" 
                                                    id="Input.Code" 
                                                    class="form-control" 
                                                    autocomplete="off" 
                                                    placeholder="Enter the code" />
                                                <label for="Input.Code" 
                                                        class="control-label form-label">
                                                    Verification Code
                                                </label>
                                                <ValidationMessage 
                                                    For="() => Input.Code" 
                                                    class="text-danger" />
                                            </div>
                                            <button type="submit" 
                                                    class="w-100 btn btn-lg btn-primary">
                                                Verify
                                            </button>
                                        </EditForm>
                                    </div>
                                </div>
                            </li>
                        </ol>
                    </div>
                }
            }
        }
    </div>
</div>

@code {
    private TwoFactorResponse twoFactorResponse = new();
    private bool loading = true;
    private string? svgGraphicsPath;

    [SupplyParameterFromForm]
    private InputModel Input { get; set; } = new();

    [CascadingParameter]
    private Task<AuthenticationState>? authenticationState { get; set; }

    protected override async Task OnInitializedAsync()
    {
        twoFactorResponse = await Acct.TwoFactorRequestAsync(new());
        svgGraphicsPath = await GetQrCode(twoFactorResponse.SharedKey);
        loading = false;
    }

    private async Task<string> GetQrCode(string sharedKey)
    {
        if (authenticationState is not null && !string.IsNullOrEmpty(sharedKey))
        {
            var authState = await authenticationState;
            var email = authState?.User?.Identity?.Name!;
            var uri = string.Format(
                CultureInfo.InvariantCulture,
                "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6",
                UrlEncoder.Default.Encode(Config["TotpOrganizationName"]!),
                email,
                twoFactorResponse.SharedKey);
            var qr = QrCode.EncodeText(uri, QrCode.Ecc.Medium);

            return qr.ToGraphicsPath();
        }

        return string.Empty;
    }

    private async Task Disable2FA()
    {
        await Acct.TwoFactorRequestAsync(new() { ForgetMachine = true });
        twoFactorResponse = 
            await Acct.TwoFactorRequestAsync(new() { ResetSharedKey = true });
        svgGraphicsPath = await GetQrCode(twoFactorResponse.SharedKey);
    }

    private async Task GenerateNewCodes()
    {
        twoFactorResponse = 
            await Acct.TwoFactorRequestAsync(new() { ResetRecoveryCodes = true });
    }

    private async Task OnValidSubmitAsync()
    {
        twoFactorResponse = await Acct.TwoFactorRequestAsync(
            new() 
            { 
                Enable = true, 
                TwoFactorCode = Input.Code 
            });
        Input.Code = string.Empty;

        // When 2FA is first enabled, recovery codes are returned.
        // However, subsequently disabling and re-enabling 2FA
        // leaves the existing codes in place and doesn't generate
        // a new set of recovery codes. The following code ensures
        // that a new set of recovery codes is generated each
        // time 2FA is enabled.
        if (twoFactorResponse.RecoveryCodes is null || 
            twoFactorResponse.RecoveryCodes.Length == 0)
        {
            await GenerateNewCodes();
        }
    }

    private sealed class InputModel
    {
        [Required]
        [RegularExpression(@"^([0-9]{6})$", 
            ErrorMessage = "Must be a six-digit authenticator code (######)")]
        [DataType(DataType.Text)]
        [Display(Name = "Verification Code")]
        public string Code { get; set; } = string.Empty;
    }
}

Добавьте ссылку в меню навигации, чтобы пользователи достигли страницы компонента Manage2fa.

В <Authorized> содержимом <AuthorizeView> в Components/Layout/NavMenu.razorдобавьте следующую разметку:

<AuthorizeView>
    <Authorized>

        ...

+       <div class="nav-item px-3">
+           <NavLink class="nav-link" href="manage-2fa">
+               <span class="bi bi-key" aria-hidden="true"></span> Manage 2FA
+           </NavLink>
+       </div>

        ...

    </Authorized>
</AuthorizeView>

Сбои из-за отклонения времени TOTP

Проверка подлинности TOTP зависит от точной синхронизации времени на устройстве с приложением для проверки подлинности TOTP и хост-системы приложения. Коды TOTP действительны только на 30 секунд. Если вход не удается из-за отклоненных кодов TOTP, убедитесь, что точность синхронизации времени поддерживается, предпочтительно путем синхронизации с точным NTP-сервером.

Дополнительные ресурсы