次の方法で共有


ASP.NET Core Identity を使用して ASP.NET Core Blazor WebAssembly で TOTP 認証アプリの QR コード生成を有効にする

メモ

これは、この記事の最新バージョンではありません。 現在のリリースについては、この記事の .NET 9 バージョンを参照してください。

重要

この情報はリリース前の製品に関する事項であり、正式版がリリースされるまでに大幅に変更される可能性があります。 Microsoft はここに示されている情報について、明示か黙示かを問わず、一切保証しません。

現在のリリースについては、この記事の .NET 9 バージョンを参照してください。

この記事では、時間ベースのワンタイム パスワード アルゴリズム (TOTP) 認証アプリによって生成された QR コードを使用して、2 要素認証 (2FA) 用の Identity で ASP.NET Core Blazor WebAssembly アプリを構成する方法について説明します。

TOTP 認証アプリを使用した 2FA の概要については、「ASP.NET Core で TOTP 認証アプリ用の QR コードを生成できるようにする」を参照してください。

警告

TOTP コードは期限切れになる前に複数回認証するために使用できるため、シークレットを保持する必要があります。

名前空間と記事コードの例

この記事の例で使用される名前空間は次のとおりです。

  • バックエンド サーバー Web API プロジェクト用の Backend。この記事では「サーバー プロジェクト」として説明されています。
  • フロントエンド クライアント スタンドアロン Blazor WebAssembly アプリ用の BlazorWasmAuth。この記事では「クライアント プロジェクト」として説明されています。

これらの名前空間は、dotnet/blazor-samples GitHub リポジトリBlazorWebAssemblyStandaloneWithIdentity サンプル ソリューション内のプロジェクトに対応します。 詳細については、「ASP.NET Core Blazor WebAssembly を使用して ASP.NET Core Identity をセキュリティで保護する」を参照してください。

BlazorWebAssemblyStandaloneWithIdentity サンプルを使用していない場合は、プロジェクトの名前空間を使用するようにコード サンプルの名前空間を変更します。

この記事で説明するソリューションに対するすべての変更は、BlazorWebAssemblyStandaloneWithIdentity ソリューションの BlazorWasmAuth プロジェクトで行われます。

記事の例では、水平スクロールを減らすためにコード行が分割されています。 こうした分割は実行には影響しませんが、プロジェクトに貼り付けるときに削除できます。

オプションのアカウントの確認とパスワードの回復

2FA を実装するアプリでは通常、アカウントの確認とパスワードの回復機能が採用されますが、2FA では必要ありません。 この記事のガイダンスに従えば、ASP.NET Core を用いて、アカウント確認やパスワード回復についてのガイダンスに従うことなく、ASP.NET Core における 2FA を実装できます。

アプリに QR コード ライブラリを追加する

TOTP 認証アプリで 2FA を設定するためにアプリによって生成された QR コードは、QR コード ライブラリによって生成される必要があります。

この記事のガイダンスでは manuelbl/QrCodeGenerator を使用しますが、どの QR コード生成ライブラリを使用しても構いません。

Net.Codecrete.QrCodeGenerator NuGet パッケージのパッケージ参照をクライアント プロジェクトに追加します。

メモ

.NET アプリへのパッケージの追加に関するガイダンスについては、「パッケージ利用のワークフロー」 (NuGet ドキュメント) の "パッケージのインストールと管理" に関する記事を参照してください。 NuGet.org で正しいパッケージ バージョンを確認します。

TOTP 組織名を設定する

クライアント プロジェクトのアプリ設定ファイルでサイト名を設定します。 ユーザーが認証アプリで簡単に識別できるわかりやすいサイト名を使用します。 開発者は通常、会社の名前と一致するサイト名を設定します。 モバイル デバイスの狭い画面にサイト名を表示できるようにするには、サイト名の長さを 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 フォルダーに追加します。 このクラスは、サーバー アプリ内の MapIdentityApi/login エンドポイントへの要求に対して設定されます。

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 フォルダーに追加します。 このクラスは、サーバー アプリ内の MapIdentityApi/manage/2fa エンドポイントへの要求に対して設定されます。

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 フォルダーに追加します。 このクラスは、サーバー アプリの MapIdentityApi/manage/2fa エンドポイントに対して行われた 2FA 要求への応答によって設定されます。

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 ファイルの先頭に、System.Text.Json.Serializationusing ステートメントを追加します。

using System.Text.Json.Serialization;

JsonSerializerOptions で、DefaultIgnoreCondition オプションを JsonIgnoreCondition.WhenWritingNull に設定して追加します。これによって null プロパティのシリアル化を回避します。

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

LoginAsync メソッドは次のロジックで更新されます。

  • メール アドレスとパスワードを使用して、/login エンドポイントで通常のログインを試みます。
  • サーバーが成功状態コードで応答すると、メソッドは Succeeded プロパティが true に設定された FormResult を返します。
  • サーバーが 401 - Unauthorized 状態コードと詳細コード "RequiresTwoFactor" で応答した場合、FormResult が返され、Succeededfalse に設定され、エラー一覧に RequiresTwoFactor の詳細が含まれます。

Identity/CookieAuthenticationStateProvider.cs で、LoginAsync メソッドを次のコードに置き換えます。

public async Task<FormResult> LoginAsync(string email, string password)
{
    try
    {
        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)
        {
            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 メソッドが追加され、2FA TOTP コード (twoFactorCode) を使用して /login エンドポイントに要求が送信されます。 このメソッドは、通常の 2FA 以外のログイン要求と同様の方法で応答を処理します。

次のメソッドとクラスを Identity/CookieAuthenticationStateProvider.cs に追加します (クラス ファイルの下部に次のコードを貼り付けます)。

public async Task<FormResult> LoginTwoFactorCodeAsync(
    string email, string password, string twoFactorCode)
{
    try
    {
        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 メソッドが追加され、2FA 回復コード (twoFactorRecoveryCode) を使用して /login エンドポイントに要求が送信されます。 このメソッドは、通常の 2FA 以外のログイン要求と同様の方法で応答を処理します。

次のメソッドとクラスを Identity/CookieAuthenticationStateProvider.cs に追加します (クラス ファイルの下部に次のコードを貼り付けます)。

public async Task<FormResult> LoginTwoFactorRecoveryCodeAsync(string email, 
    string password, string twoFactorRecoveryCode)
{
    try
    {
        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 を管理するために使用されます。

  • TwoFactorRequest.ResetSharedKeytrue の場合は、共有 2FA キーをリセットします。 共有キーをリセットすると、2FA が暗黙的に無効になります。 これにより、新しい共有キーを受信した後、認証アプリから有効な TOTP コードを提供して 2FA を有効にできることを証明するようユーザーに求められます。
  • TwoFactorRequest.ResetRecoveryCodestrue の場合は、ユーザーの回復コードをリセットします。
  • TwoFactorRequest.ForgetMachinetrue の場合はマシンを忘れてください。つまり、次のログイン試行時に新しい 2FA TOTP コードが必要になります。
  • TwoFactorRequest.Enabletrue で、TwoFactorRequest.TwoFactorCode に有効な TOTP 値がある場合は、TOTP 認証アプリから TOTP コードを使用して 2FA を有効にします。
  • TwoFactorRequestのすべてのプロパティが nullである場合、空のリクエストで 2FA 状態を取得します。

次の TwoFactorRequestAsync メソッドを Identity/CookieAuthenticationStateProvider.cs に追加します (クラス ファイルの下部に次のコードを貼り付けます)。

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

    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 入力要素は、認証アプリまたは回復コードから 2FA TOTP コードを受信するように見えます。 ユーザーが入力するコードに応じて、TOTP コードの LoginTwoFactorCodeAsync か回復コードの 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 コードを常に要求し、マシンを記憶しない場合は、2 要素ログインが成功した直後に TwoFactorRequest.ForgetMachinetrue に設定して TwoFactorRequestAsync メソッドを呼び出します。

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

[2 要素認証を管理する] ページ

次の Manage2fa コンポーネントをアプリに追加して、ユーザーの 2FA を管理します。

2FA が有効になっていない場合、コンポーネントは QR コードを含むフォームを読み込み、TOTP 認証アプリで 2FA を有効にします。 ユーザーは認証アプリにアプリを追加してから認証アプリを検証し、認証アプリから TOTP コードを提供することで 2FA を有効にします。

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 コンポーネント ページにアクセスするためのナビゲーション メニューへのリンクを追加します。

Components/Layout/NavMenu.razor<AuthorizeView><Authorized> コンテンツに、次のマークアップを追加します。

<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 サービスに同期されます。

その他の技術情報