Habilitar la generación de códigos QR para aplicaciones de autenticación TOTP en Blazor WebAssembly de ASP.NET Core con Identity de ASP.NET Core

Nota:

Esta no es la versión más reciente de este artículo. Para la versión actual, consulte la versión de .NET 10 de este artículo.

En este artículo se explica cómo configurar una aplicación de Blazor WebAssembly ASP.NET Core con Identity para la autenticación en dos fases (2FA) con códigos QR generados por aplicaciones de autenticación de algoritmo de contraseña de un solo uso y duración definida (TOTP).

Para obtener una introducción a 2FA con aplicaciones de autenticación TOTP, consulte Habilitación de la generación de código QR para la autenticación TOTP.

Advertencia

Los códigos TOTP deben ser secretos porque se pueden usar para autenticarse varias veces antes de caducar.

Espacios de nombres y ejemplos de código de artículo

Los espacios de nombres usados por los ejemplos de este artículo son:

  • Backend para el proyecto de API web de servidor backend, descrito como "proyecto de servidor" en este artículo.
  • BlazorWasmAuth para la aplicación Blazor WebAssembly cliente independiente de front-end, descrita como "proyecto cliente" en este artículo.

Estos espacios de nombres corresponden a los proyectos de la solución de ejemplo BlazorWebAssemblyStandaloneWithIdentity en el repositorio de GitHub dotnet/blazor-samples. Para obtener más información, consulte Secure ASP.NET Core Blazor WebAssembly con ASP.NET Core Identity.

Si no está usando el ejemplo BlazorWebAssemblyStandaloneWithIdentity, cambie los espacios de nombres en los ejemplos de código para usar los espacios de nombres de sus proyectos.

Todos los cambios realizados en la solución de la que se habla en este artículo tienen lugar en el proyecto BlazorWasmAuth de la solución BlazorWebAssemblyStandaloneWithIdentity.

En los ejemplos del artículo, las líneas de código se dividen para reducir el desplazamiento horizontal. Estas pausas no afectan a la ejecución, pero pueden eliminarse al pegarlas en el proyecto.

Confirmación de cuentas y recuperación de contraseñas opcional

Aunque las aplicaciones que implementan 2FA suelen incluir características para confirmar cuentas y recuperar contraseñas, el sistema 2FA no las necesita. Las instrucciones de este artículo se pueden seguir para implementar 2FA sin necesidad de seguir las instrucciones de Confirmación de la cuenta y recuperación de contraseñas en ASP.NET Core Blazor WebAssembly con ASP.NET Core Identity.

Añadir una biblioteca de códigos QR a la aplicación

Un código QR generado por la aplicación para configurar el método 2FA con una aplicación de autenticación TOTP debe generarse mediante una biblioteca de códigos QR.

En las instrucciones de este artículo se usa manuelbl/QrCodeGenerator, pero puede usar cualquier biblioteca de generación de códigos QR.

Añada una referencia al paquete NuGet Net.Codecrete.QrCodeGenerator en el proyecto cliente.

Nota:

Para obtener instrucciones sobre cómo agregar paquetes a aplicaciones .NET, consulte los artículos de Instalación y administración de paquetes en Flujo de trabajo de consumo de paquetes (NuGet documentación). Confirme las versiones correctas del paquete en NuGet.org.

Crear el nombre de la organización para TOTP

Cree el nombre del sitio en el archivo de configuración de la aplicación del proyecto cliente. Use un nombre de sitio reconocible que los usuarios puedan identificar fácilmente en su aplicación de autenticación. Los desarrolladores suelen establecer un nombre de sitio que coincida con el nombre de la empresa. Se recomienda limitar la longitud del nombre del sitio a 30 caracteres o menos para permitir que el nombre del sitio se pueda ver bien en pantallas pequeñas de dispositivos móviles.

En el ejemplo siguiente, el nombre de la compañía es Weyland-Yutani Corporation (©1986 20th Century Studios Aliens).

Añadido a wwwroot/appsettings.json:

"TotpOrganizationName": "Weyland-Yutani Corporation"

El archivo de configuración de la aplicación después de incluir la configuración del nombre de la organización para TOTP:

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

Adición de clases de modelo

Agregue la siguiente clase LoginResponse a la carpeta Models. Esta clase se rellena para las peticiones al punto de conexión /login de MapIdentityApi en la app servidor.

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; }
}

Agregue la siguiente clase TwoFactorRequest a la carpeta Models. Esta clase se rellena para las peticiones al punto de conexión /manage/2fa de MapIdentityApi en la app servidor.

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; }
}

Agregue la siguiente clase TwoFactorResponse a la carpeta Models. Esta clase se rellena con la respuesta a una solicitud 2FA realizada al punto de conexión /manage/2fa de MapIdentityApi en la app servidor.

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; } = [];
}

interfaz IAccountManagement

Agregue las siguientes firmas de clase a la interfaz IAccountManagement. Las firmas de clase representan los métodos añadidos al proveedor de estado de autenticación de cookie en las siguientes solicitudes de cliente:

  • Inicie sesión con el código de 2FA TOTP (endpoint/login): LoginTwoFactorCodeAsync
  • Inicie sesión con un código de recuperación de 2FA (endpoint/login): LoginTwoFactorRecoveryCodeAsync
  • Hacer una solicitud de gestión de 2FA (punto de conexión/manage/2fa): TwoFactorRequestAsync

Identity/IAccountManagement.cs (pegue el código siguiente al final del archivo):

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);

Actualice el CookieAuthenticationStateProvider con características para agregar las siguientes funciones.

  • Autentique a los usuarios con un código de aplicación de autenticación TOTP o un código de recuperación.
  • Configure los ajustes de 2FA en la aplicación.

En la parte superior del archivo CookieAuthenticationStateProvider.cs, agregue una instrucción using para System.Text.Json.Serialization:

using System.Text.Json.Serialization;

En el JsonSerializerOptions, agregue la opción DefaultIgnoreCondition con el valor JsonIgnoreCondition.WhenWritingNull, que evita serializar las propiedades null:

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

El método LoginAsync se actualiza con la siguiente lógica:

  • Realice un inicio de sesión normal en el punto de conexión de /login con una dirección de correo electrónico y una contraseña.
  • Si el servidor responde con un código de estado correcto, el método devolverá un FormResult con la propiedad Succeeded con el valor true.
  • Si el servidor responde con el código de estado 401 - No autorizado y un código de detalle de "RequiresTwoFactor," se devuelve un FormResult con Succeeded como false y el detalle RequiresTwoFactor en la lista de errores.

En Identity/CookieAuthenticationStateProvider.cs, reemplace el método LoginAsync por el código siguiente:

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." ]
    };
}

Se agrega el método LoginTwoFactorCodeAsync, que envía una solicitud al punto de conexión de /login con un código TOTP de 2FA (twoFactorCode). El método procesa la respuesta de forma similar a una solicitud de inicio de sesión normal que no use 2FA.

Agregue el siguiente método y clase a Identity/CookieAuthenticationStateProvider.cs (pegue el código siguiente al final del archivo de la clase):

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." ]
    };
}

Se agrega el método LoginTwoFactorRecoveryCodeAsync, que envía una solicitud al punto de conexión de /login con un código de recuperación de 2FA (twoFactorRecoveryCode). El método procesa la respuesta de forma similar a una solicitud de inicio de sesión normal que no use 2FA.

Agregue el siguiente método y clase a Identity/CookieAuthenticationStateProvider.cs (pegue el código siguiente al final del archivo de la clase):

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." ]
    };
}

Se agrega el método TwoFactorRequestAsync, que sirve para que el usuario use y ajuste el método 2FA:

  • Restablezca la clave 2FA compartida cuando TwoFactorRequest.ResetSharedKey sea true. Al restablecer la clave compartida, se deshabilita implícitamente el método 2FA. Esto obliga al usuario a demostrar que puede facilitar un código TOTP válido a través de la aplicación de autenticación para habilitar 2FA después de recibir una nueva clave compartida.
  • Restablezca los códigos de recuperación del usuario cuando TwoFactorRequest.ResetRecoveryCodes sea true.
  • Olvide la máquina cuando TwoFactorRequest.ForgetMachine es true, lo que significa que se requiere un nuevo código TOTP de 2FA en el siguiente intento de inicio de sesión.
  • Habilite 2FA mediante un código TOTP desde una aplicación de autenticación TOTP cuando TwoFactorRequest.Enable sea true y TwoFactorRequest.TwoFactorCode tenga un valor TOTP válido.
  • Obtenga el estado 2FA con una petición vacía cuando todas las propiedades de TwoFactorRequest son null.

Agregue el siguiente método TwoFactorRequestAsync a Identity/CookieAuthenticationStateProvider.cs (pegue el código siguiente al final del archivo de la clase):

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]
    };
}

Reemplazar el componente Login

Reemplace el componente Login. La siguiente versión del componente Login:

  • Acepta la dirección de correo electrónico y la contraseña de un usuario para realizar un inicio de sesión inicial.
  • Si el inicio de sesión se realiza correctamente (con 2FA deshabilitado), el componente informará al usuario de que se ha autenticado.
  • Si el intento de inicio de sesión resulta en una respuesta indicando que se requiere 2FA, aparece un elemento de entrada 2FA para recibir un código TOTP de 2FA de una app autenticadora o un código de recuperación. En función del código que escriba el usuario, se volverá a iniciar sesión llamando a LoginTwoFactorCodeAsync con un código TOTP o a LoginTwoFactorRecoveryCodeAsync con un código de recuperación.

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;
    }
}

Con el componente anterior, tras iniciar sesión correctamente al usuario se le recuerda el código TOTP válido a través de una aplicación de autenticación. Si siempre desea requerir un código TOTP para el inicio de sesión y no recordar la máquina, llame al método TwoFactorRequestAsync con TwoFactorRequest.ForgetMachine establecido en true inmediatamente después de un inicio de sesión con autenticación de dos factores exitoso.

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 });
+    }
}

Añadir un componente para ver los códigos de recuperación

Agregue el siguiente componente ShowRecoveryCodes a la aplicación para que el usuario vea los códigos de recuperación.

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; } = [];
}

Página administrar 2FA

Agregue el siguiente componente Manage2fa a la aplicación para que los usuarios usen y ajusten el método 2FA.

Si 2FA no está habilitado, el componente cargará un formulario con un código QR para habilitar 2FA en una aplicación de autenticación TOTP. El usuario agrega la aplicación a su aplicación de autenticación, comprueba la aplicación de autenticación y habilita 2FA usando un código TOTP a través de la aplicación de autenticación.

Si 2FA está habilitado, los botones deshabilitan 2FA y regeneran los códigos de recuperación.

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;
    }
}

Agregue un vínculo al menú de navegación para que los usuarios puedan acceder a la página del componente Manage2fa.

En el contenido <Authorized> de <AuthorizeView> en Components/Layout/NavMenu.razor, añada el siguiente marcado:

<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>

Errores debidos al sesgo de tiempo de TOTP

La autenticación TOTP depende del tiempo exacto que actúa en el dispositivo de la aplicación de autenticación TOTP y en el servidor de la aplicación. Los tokens de TOTP solo son válidos durante 30 segundos. Si los inicios de sesión fallan debido a códigos TOTP rechazados, confirme que se mantiene la hora exacta, preferiblemente sincronizada con un servicio NTP preciso.

Recursos adicionales