From df05640ddd447874cc52a9408979363409010acd Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Wed, 20 Nov 2024 15:31:06 -0500 Subject: [PATCH 01/25] 2FA/TOTP coverage for WASM+Identity --- ...ount-confirmation-and-password-recovery.md | 2 + .../qrcodes-for-authenticator-apps.md | 893 ++++++++++++++++++ aspnetcore/toc.yml | 4 +- 3 files changed, 898 insertions(+), 1 deletion(-) create mode 100644 aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md index 729507bb94ae..2a67461ca129 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md @@ -9,6 +9,8 @@ uid: blazor/security/webassembly/standalone-with-identity/account-confirmation-a --- # Account confirmation and password recovery in ASP.NET Core Blazor WebAssembly with ASP.NET Core Identity +[!INCLUDE[](~/includes/not-latest-version-without-not-supported-content.md)] + This article explains how to configure an ASP.NET Core Blazor WebAssembly app with ASP.NET Core Identity with email confirmation and password recovery. > [!NOTE] diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md new file mode 100644 index 000000000000..cbdaf7f15d23 --- /dev/null +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md @@ -0,0 +1,893 @@ +--- +title: Enable QR code generation for TOTP authenticator apps in ASP.NET Core Blazor WebAssembly with ASP.NET Core Identity +author: guardrex +description: Learn how to configure an ASP.NET Core Blazor WebAssembly app with Identity for QR code generation with TOTP authenticator app. +ms.author: riande +monikerRange: '>= aspnetcore-8.0' +ms.date: 11/20/2024 +uid: blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps +--- +# Enable QR code generation for TOTP authenticator apps in ASP.NET Core Blazor WebAssembly with ASP.NET Core Identity + +[!INCLUDE[](~/includes/not-latest-version-without-not-supported-content.md)] + +This article explains how to configure an ASP.NET Core Blazor WebAssembly app with Identity for QR code generation with TOTP authenticator app. + +> [!NOTE] +> This article only applies standalone Blazor WebAssembly apps with ASP.NET Core Identity. To implement QR code generation for Blazor Web Apps, see . + +For an introduction to two-factor authentication (2FA) with authenticator apps using a Time-based One-time Password Algorithm (TOTP), see . + +> [!WARNING] +> An ASP.NET Core TOTP code should be kept secret because it can be used to authenticate successfully multiple times before it expires. + +## Two-factor/TOTP processing + +... EXPLAIN API BASICS HERE ... + +## Namespace + +The namespaces used by the examples in this article are: + +* `Backend` for the backend server web API project ("server project" in this article). +* `BlazorWasmAuth` for the front-end client standalone Blazor WebAssembly app ("client project" in this article). + +These namespaces correspond to the projects in the `BlazorWebAssemblyStandaloneWithIdentity` sample solution in the [`dotnet/blazor-samples` GitHub repository](https://github.com/dotnet/blazor-samples). For more information, see . + +If you aren't using the `BlazorWebAssemblyStandaloneWithIdentity` sample solution, change the namespaces in the code examples to use the namespaces of your projects. + +**All of the changes to the app covered by this article outside of the *Configure account confirmation and password recovery* section take place in the `BlazorWasmAuth` project.** + +## Configure account confirmation and password recovery + +Follow the guidance in to establish account confirmation and password recovery features: + +* [Select and configure an email provider for the server project](xref:blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery#select-and-configure-an-email-provider-for-the-server-project) +* [Configure a user secret for the provider's security key](xref:blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery#configure-a-user-secret-for-the-providers-security-key) +* [Implement `IEmailSender` in the server project](xref:blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery#implement-iemailsender-in-the-server-project) + +Two-factor authentication doesn't strictly require account confirmation and password recovery features, so you aren't required to adopt the remaining guidance in the cross-linked article. + +## Adding QR codes to the app + +These instructions use [Shim Sangmin](https://hogangnono.com)'s [qrcode.js: Cross-browser QRCode generator for JavaScript](https://davidshimjs.github.io/qrcodejs/) ([`davidshimjs/qrcodejs` GitHub repository](https://github.com/davidshimjs/qrcodejs)). + +Download the [`qrcode.min.js`](https://davidshimjs.github.io/qrcodejs/) library to the `wwwroot` folder of the `BlazorWasmAuth` project. The library has no dependencies. + +## Set the TOTP organization name + +Set the site name in the app settings file of the `BlazorWasmAuth` project. Use a meaningful site name that users can identify easily in their authenticator app. Developers usually set a site name that matches the company's name. Examples: Yahoo, Amazon, Etsy, Microsoft, Zoho. We recommend limiting the site name length to 30 characters or less to allow the site name to display on narrow mobile device screens. + +In the following example, the the company name is `Weyland-Yutani Corporation` (©1986 20th Century Studios [*Aliens*](https://www.20thcenturystudios.com/movies/aliens)). + +Added to `wwwroot/appsettings.json` of the `BlazorWasmAuth` project: + +```json +"TotpOrganizationName": "Weyland-Yutani Corporation" +``` + +The file after the configuration key-value are added: + +```json +{ + "BackendUrl": "https://localhost:7211", + "FrontendUrl": "https://localhost:7171", + "TotpOrganizationName": "Weyland-Yutani Corporation" +} +``` + +## `TwoFactorResult` model + +Add the following `TwoFactorResult` class to the `Models` folder. + +`Identity/Models/TwoFactorResult.cs`: + +```csharp +namespace BlazorWasmAuth.Identity.Models +{ + /// + /// Response for login and registration. + /// + public class TwoFactorResult + { + /// + /// Gets or sets a value indicating the shared key. + /// + public string SharedKey { get; set; } = string.Empty; + + /// + /// Gets or sets a value indicating the number of remaining recovery codes. + /// + public int RecoveryCodesLeft { get; set; } = 0; + + /// + /// Gets or sets a value indicating the recovery codes. + /// + public string[] RecoveryCodes { get; set; } = []; + + /// + /// Gets or sets a value indicating if two-factor authentication is enabled. + /// + public bool IsTwoFactorEnabled { get; set; } + + /// + /// Gets or sets a value indicating if the machine is remembered. + /// + public bool IsMachineRemembered { get; set; } + + /// + /// On failure, the problem details are parsed and returned in this array. + /// + public string[] ErrorList { get; set; } = []; + } +} +``` + +## `IAccountManagement` interface + +Add the following class signatures to the `IAccountManagment` interface. + +`Identity/IAccountManagement.cs` (paste the following code at the bottom of the interface): + +```csharp +/// +/// Login service with two-factor authentication. +/// +/// User's email. +/// User's password. +/// User's 2FA code. +/// The result of the request serialized to . +public Task LoginTwoFactorCodeAsync( + string email, + string password, + string twoFactorCode); + +/// +/// Initial POST request to the two-factor authentication endpoint. +/// +/// A flag indicating 2FA status. +/// The two-factor authentication code supplied by the user's 2FA app. +/// A flag indicating if the shared key should be reset. +/// A flag indicating if the recovery codes should be reset. +/// A flag indicating if the machine should be forgotten. +/// The result serialized to a . +public Task TwoFactorRequest( + bool enable = false, + string twoFactorCode = "", + bool resetSharedKey = false, + bool resetRecoveryCodes = false, + bool forgetMachine = false); + +/// +/// Login service with two-factor recovery authentication. +/// +/// User's email. +/// User's password. +/// User's 2FA recovery code. +/// The result of the request serialized to . +public Task LoginTwoFactorRecoveryCodeAsync( + string email, + string password, + string twoFactorRecoveryCode); +``` + +## `CookieAuthenticationStateProvider` + +Replace the `LoginAsync` method with the following code in `Identity/CookieAuthenticationStateProvider.cs`: + +```csharp +public async Task LoginAsync(string email, string password) +{ + try + { + // login with cookies + var result = await httpClient.PostAsJsonAsync( + "login?useCookies=true", new + { + email, + password + }); + + // success? + if (result.IsSuccessStatusCode) + { + // need to refresh auth state + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + + // success! + return new FormResult { Succeeded = true }; + } + else if (result.StatusCode == HttpStatusCode.Unauthorized) + { + var responseJson = await result.Content.ReadAsStringAsync(); + var response = JsonSerializer.Deserialize( + responseJson, jsonSerializerOptions); + + if (response?.Detail == "RequiresTwoFactor") + { + return new FormResult + { + Succeeded = false, + ErrorList = ["RequiresTwoFactor"] + }; + } + } + } + catch { } + + // unknown error + return new FormResult + { + Succeeded = false, + ErrorList = [ "Invalid email and/or password." ] + }; +} +``` + +Add the following methods and class to `Identity/CookieAuthenticationStateProvider.cs` (paste the following code at the bottom of the class file): + +```csharp +/// +/// User login with two-factor authentication. +/// +/// The user's email address. +/// The user's password. +/// The user's password. +/// +/// The result of the login request serialized to a . +/// +public async Task LoginTwoFactorCodeAsync( + string email, string password, string twoFactorCode) +{ + try + { + // login with cookies + var result = await httpClient.PostAsJsonAsync( + "login?useCookies=true", new + { + email, + password, + twoFactorCode + }); + + // success? + if (result.IsSuccessStatusCode) + { + // need to refresh auth state + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + + // success! + return new FormResult { Succeeded = true }; + } + } + catch { } + + // unknown error + return new FormResult + { + Succeeded = false, + ErrorList = [ "Invalid email, password, or two-factor code."] + }; +} + +/// +/// Initial POST request to the two-factor authentication endpoint. +/// +/// A flag indicating 2FA status. +/// +/// The two-factor authentication code supplied by the user's 2FA app. +/// +/// +/// A flag indicating if the shared key should be reset. +/// +/// +/// A flag indicating if the recovery codes should be reset. +/// +/// +/// A flag indicating if the machine should be forgotten. +/// +/// The result serialized to a . +public async Task TwoFactorRequest( + bool enable, + string twoFactorCode, + bool resetSharedKey, + bool resetRecoveryCodes, + bool forgetMachine) +{ + string[] defaultDetail = + [ "An unknown error prevented two-factor authentication." ]; + + try + { + HttpResponseMessage response; + + if (resetSharedKey) + { + response = await httpClient.PostAsJsonAsync("manage/2fa", + new { enable, resetSharedKey }); + } + else if (forgetMachine) + { + response = await httpClient.PostAsJsonAsync("manage/2fa", + new { enable, forgetMachine }); + } + else if (!string.IsNullOrEmpty(twoFactorCode)) + { + response = await httpClient.PostAsJsonAsync("manage/2fa", + new { enable, twoFactorCode }); + } + else + { + response = await httpClient.PostAsJsonAsync("manage/2fa", + new { }); + } + + // successful? + if (response.IsSuccessStatusCode) + { + return await response.Content + .ReadFromJsonAsync() ?? + 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(); + 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 TwoFactorResult + { + ErrorList = problemDetails == null ? defaultDetail : [.. errors] + }; + } + catch { } + + // unknown error + return new TwoFactorResult + { + ErrorList = [ "There was an error processing the request." ] + }; +} + +/// +/// User login with two-factor authentication. +/// +/// The user's email address. +/// The user's password. +/// The user's password. +/// The result of the login request serialized to a . +public async Task LoginTwoFactorRecoveryCodeAsync(string email, + string password, string twoFactorRecoveryCode) +{ + try + { + // login with cookies + var result = await httpClient.PostAsJsonAsync( + "login?useCookies=true", new + { + email, + password, + twoFactorRecoveryCode + }); + + // success? + if (result.IsSuccessStatusCode) + { + // need to refresh auth state + NotifyAuthenticationStateChanged(GetAuthenticationStateAsync()); + + // success! + return new FormResult { Succeeded = true }; + } + } + catch { } + + // unknown error + return new FormResult + { + Succeeded = false, + ErrorList = [ "Invalid email, password, or two-factor code." ] + }; +} + +private class HttpResponseContent +{ + public string? Type { get; set; } + public string? Title { get; set; } + public int Status { get; set; } + public string? Detail { get; set; } +} +``` + +## Replace `Login` component + +Replace the `Login` component with the following code. + +`Components/Identity/Login.razor`: + +```razor +@page "/login" +@using System.ComponentModel.DataAnnotations +@using BlazorWasmAuth.Identity +@using BlazorWasmAuth.Identity.Models +@inject IAccountManagement Acct +@inject ILogger Logger +@inject NavigationManager Navigation + +Login + +

Login

+ + + +
+ You're logged in as @context.User.Identity?.Name. +
+
+ + @foreach (var error in formResult.ErrorList) + { +
@error
+ } +
+
+
+ + +

Use a local account to log in.

+
+
+
+ + + +
+
+ + + +
+
+
+
+ + + +
+
+
+ +
+ +
+
+
+
+
+
+ +@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.TwoFactorCode)) + { + if (Input.TwoFactorCode.Length == 6) + { + formResult = await Acct.LoginTwoFactorCodeAsync( + Input.Email, Input.Password, Input.TwoFactorCode); + } + else + { + formResult = await Acct.LoginTwoFactorRecoveryCodeAsync( + Input.Email, Input.Password, Input.TwoFactorCode); + } + } + } + else + { + formResult = await Acct.LoginAsync(Input.Email, Input.Password); + requiresTwoFactor = formResult.ErrorList.Contains("RequiresTwoFactor"); + Input.TwoFactorCode = string.Empty; + 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; + + [Required] + [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")] + public string TwoFactorCode { get; set; } = "123456"; + } +} +``` + +## Show recovery codes component + +Add the following `ShowRecoveryCode` component to the app. + +`Components/Identity/ShowRecoveryCodes.razor`: + +```razor +

Recovery codes

+ + +
+
+ @foreach (var recoveryCode in RecoveryCodes) + { +
+ @recoveryCode +
+ } +
+
+ +@code { + [Parameter] + public string[] RecoveryCodes { get; set; } = []; +} +``` + +## Manage 2FA page + +Add the following `Manage2fa` component. + +`Components/Identity/Manage2fa.razor`: + +```razor +@page "/manage-2fa" +@using System.ComponentModel.DataAnnotations +@using System.Globalization +@using System.Text +@using System.Text.Encodings.Web +@using BlazorWasmAuth.Identity +@using BlazorWasmAuth.Identity.Models +@attribute [Authorize] +@implements IAsyncDisposable +@inject IAccountManagement Acct +@inject IAuthorizationService AuthorizationService +@inject IConfiguration Config +@inject IJSRuntime JS +@inject ILogger Logger + +Manage 2FA + +

Manage Two-factor Authentication

+
+
+
+ @if (twoFactorResult is not null) + { + if (twoFactorResult.ErrorList.Length > 0) + { + @foreach (var error in twoFactorResult.ErrorList) + { +
@error
+ } + } + else + { + @if (twoFactorResult.IsTwoFactorEnabled) + { + + + + + @if (recoveryCodes is null) + { + + } + else + { + + } + } + else + { +

Configure authenticator app

+
+

To use an authenticator app:

+
    +
  1. +

    + Download a two-factor authenticator app, such + as either of the following: +

    +

    +
  2. +
  3. +

    + Scan the QR Code or enter this key + @twoFactorResult.SharedKey into your + two factor authenticator app. Spaces and casing + don't matter. +

    +
    +
  4. +
  5. +

    + Once you have scanned the QR code or input the + key above, your two factor authentication app + will provide you with a unique code. Enter the + code in the confirmation box below. +

    +
    +
    + + +
    + + + +
    + +
    +
    +
    +
  6. +
+
+ } + } + } +
+
+ +@code { + private IEnumerable? recoveryCodes; + private IJSObjectReference? module; + private TwoFactorResult twoFactorResult = new(); + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = new(); + + [CascadingParameter] + private Task? authenticationState { get; set; } + + protected override async Task OnInitializedAsync() + { + twoFactorResult = await Acct.TwoFactorRequest(); + } + + protected override async Task OnAfterRenderAsync(bool firstRender) + { + if (firstRender) + { + module = await JS.InvokeAsync("import", + "./Components/Identity/Manage2fa.razor.js"); + + //twoFactorResult = await Acct.TwoFactorRequest(); + + if (authenticationState is not null) + { + 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, + twoFactorResult.SharedKey); + + await module.InvokeVoidAsync("setQrCode", uri); + } + } + } + + private async Task Disable2FA() + { + twoFactorResult = await Acct.TwoFactorRequest(resetSharedKey: true); + } + + private async Task NewCodes() + { + twoFactorResult = await Acct.TwoFactorRequest(resetRecoveryCodes: true); + recoveryCodes = twoFactorResult.RecoveryCodes; + } + + private async Task OnValidSubmitAsync() + { + twoFactorResult = await Acct.TwoFactorRequest(enable: true, + twoFactorCode: Input.Code); + recoveryCodes = twoFactorResult.RecoveryCodes; + } + + 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; + } + + async ValueTask IAsyncDisposable.DisposeAsync() + { + if (module is not null) + { + await module.DisposeAsync(); + } + } +} +``` + +Add the following [collocated JavaScript file](xref:blazor/js-interop/javascript-location#load-a-script-from-an-external-javascript-file-js-collocated-with-a-component). + +`Components/Identity/Manage2fa.razor.js`: + +```javascript +export function setQrCode(uri) { + new QRCode(document.getElementById('qrCode'), uri); +} +``` + +## Link to the the Manage 2FA page + +In the `` content of the `` in `Components/Layout/NavMenu.razor`, add a link to the **Manage 2FA** page: + +```razor + + + + ... + + + + ... + + + +``` + +## Additional resources + +* [Mandrill.net (GitHub repository)](https://github.com/feinoujc/Mandrill.net) +* [Mailchimp developer: Transactional API](https://mailchimp.com/developer/transactional/docs/fundamentals/) diff --git a/aspnetcore/toc.yml b/aspnetcore/toc.yml index f86c4972e728..f00a0fdba16f 100644 --- a/aspnetcore/toc.yml +++ b/aspnetcore/toc.yml @@ -612,6 +612,8 @@ items: uid: blazor/security/webassembly/standalone-with-identity/index - name: Account confirmation and password recovery uid: blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery + - name: QR codes with TOTP + uid: blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps - name: Standalone with Authentication library uid: blazor/security/webassembly/standalone-with-authentication-library - name: Standalone with Microsoft Accounts @@ -644,7 +646,7 @@ items: uid: blazor/security/interactive-server-side-rendering - name: Account confirmation and password recovery uid: blazor/security/account-confirmation-and-password-recovery - - name: QR code generation + - name: QR codes with TOTP uid: blazor/security/qrcodes-for-authenticator-apps - name: Content Security Policy uid: blazor/security/content-security-policy From 1a76a339e2675cad0522fa9f1cc69728fdcbb128 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Wed, 20 Nov 2024 20:00:03 -0500 Subject: [PATCH 02/25] Updates --- ...ount-confirmation-and-password-recovery.md | 70 +++++++++++-------- .../standalone-with-identity/index.md | 3 +- .../qrcodes-for-authenticator-apps.md | 9 ++- 3 files changed, 47 insertions(+), 35 deletions(-) diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md index 2a67461ca129..4313bc3a379f 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md @@ -158,15 +158,11 @@ Locate the line that calls -- You successfully registered. Now you can login. -- -+
-+ You successfully registered. You must now confirm your account by clicking -+ the link in the email that was sent to you. After confirming your account, -+ you can login to the app. -+ Resend confirmation email -+
+- You successfully registered. Now you can login. ++ You successfully registered. You must now confirm your account by clicking ++ the link in the email that was sent to you. After confirming your account, ++ you can login to the app. ++ Resend confirmation email ``` ## Update seed data code to confirm seeded accounts @@ -345,15 +341,21 @@ In the client project, add the following `ForgotPassword` component.
@if (!passwordResetCodeSent) { - +
- + @@ -390,15 +392,20 @@ In the client project, add the following `ForgotPassword` component. A password reset code has been sent to your email address. Obtain the code from the email for this form.
- +
- + @@ -406,9 +413,13 @@ In the client project, add the following `ForgotPassword` component. class="text-danger" />
-
-
@code { - private bool passwordResetCodeSent; - private bool passwordResetSuccess, errors; + private bool passwordResetCodeSent, passwordResetSuccess, errors; private string[] errorList = []; [SupplyParameterFromForm(FormName = "forgot-password")] @@ -492,7 +506,7 @@ In the client project, add the following `ForgotPassword` component. [DataType(DataType.Password)] [Display(Name = "Confirm password")] [Compare("NewPassword", ErrorMessage = "The new password and confirmation " + - "password do not match.")] + "password don't match.")] public string ConfirmPassword { get; set; } = string.Empty; } } diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/index.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/index.md index d65f44c4a47d..8304e3594d89 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/index.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/index.md @@ -85,12 +85,11 @@ At this point, you must provide custom code to parse the . +* [Two-factor authentication (2FA) with a TOTP authenticator app](xref:blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps) For information on additional Identity scenarios provided by the API, see : * Secure selected endpoints -* Two-factor authentication (2FA) and recovery codes * User info management ## Use secure authentication flows to maintain sensitive data and credentials diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md index cbdaf7f15d23..7d4d7d4ce755 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md @@ -740,11 +740,10 @@ Add the following `Manage2fa` component.

- +
Date: Thu, 21 Nov 2024 10:10:23 -0500 Subject: [PATCH 03/25] Updates --- .../qrcodes-for-authenticator-apps.md | 19 +- ...ount-confirmation-and-password-recovery.md | 81 +--- .../qrcodes-for-authenticator-apps.md | 437 +++++++----------- 3 files changed, 204 insertions(+), 333 deletions(-) diff --git a/aspnetcore/blazor/security/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/qrcodes-for-authenticator-apps.md index a9dc6fb603d1..ace84d25cbd9 100644 --- a/aspnetcore/blazor/security/qrcodes-for-authenticator-apps.md +++ b/aspnetcore/blazor/security/qrcodes-for-authenticator-apps.md @@ -33,14 +33,16 @@ For more information, see . For ## Adding QR codes to the 2FA configuration page -These instructions use [Shim Sangmin](https://hogangnono.com)'s [qrcode.js: Cross-browser QRCode generator for JavaScript](https://davidshimjs.github.io/qrcodejs/) ([`davidshimjs/qrcodejs` GitHub repository](https://github.com/davidshimjs/qrcodejs)). +The QR code used by 2FA authenticator apps to configure the app to creates TOTP codes must be generated by a QR code library. -Download the [`qrcode.min.js`](https://davidshimjs.github.io/qrcodejs/) library to the `wwwroot` folder of the solution's server project. The library has no dependencies. +The guidance in this article uses [qr-creator](https://github.com/nimiq/qr-creator), but you can use any general QR code generation library. + +Download the [`qr-creator.min.js`](https://cdn.jsdelivr.net/npm/qr-creator/dist/qr-creator.min.js) library to the solution's server project. The library has no dependencies. If you intend to use the library to generate other QR codes elsewhere in the app for your own purposes, there's also a module version of the library available from the maintainer. In the `App` component (`Components/App.razor`), place a library script reference after [Blazor's ` +```html + ``` The `EnableAuthenticator` component, which is part of the QR code system in the app and displays the QR code to users, adopts static server-side rendering (static SSR) with enhanced navigation. Therefore, normal scripts can't execute when the component loads or updates under enhanced navigation. Extra steps are required to trigger the QR code to load in the UI when the page is loaded. To accomplish loading the QR code, the approach explained in is adopted. @@ -72,7 +74,14 @@ Add the following [collocated JS file](xref:blazor/js-interop/javascript-locatio ```javascript export function onLoad() { const uri = document.getElementById('qrCodeData').getAttribute('data-url'); - new QRCode(document.getElementById('qrCode'), uri); + QrCreator.render({ + text: uri, + radius: 0, + ecLevel: 'H', + fill: '#000000', + background: null, + size: 190 + }, document.querySelector('#qrCode')); } ``` diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md index 4313bc3a379f..e31006c1b94e 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md @@ -4,7 +4,7 @@ author: guardrex description: Learn how to configure an ASP.NET Core Blazor WebAssembly app with ASP.NET Core Identity with email confirmation and password recovery. ms.author: riande monikerRange: '>= aspnetcore-8.0' -ms.date: 10/31/2024 +ms.date: 11/21/2024 uid: blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery --- # Account confirmation and password recovery in ASP.NET Core Blazor WebAssembly with ASP.NET Core Identity @@ -16,7 +16,7 @@ This article explains how to configure an ASP.NET Core Blazor WebAssembly app wi > [!NOTE] > This article only applies standalone Blazor WebAssembly apps with ASP.NET Core Identity. To implement email confirmation and password recovery for Blazor Web Apps, see . -## Namespace +## Namespaces and article code examples The namespaces used by the examples in this article are: @@ -27,6 +27,8 @@ These namespaces correspond to the projects in the `BlazorWebAssemblyStandaloneW If you aren't using the `BlazorWebAssemblyStandaloneWithIdentity` sample solution, change the namespaces in the code examples to use the namespaces of your projects. +In this article's code examples, the code lines are artificially broken across two or more lines to eliminate or reduce horizontal scrolling of the article's code blocks. The code executes regardless of these artificial line breaks. You're welcome to condense the code in your own apps by removing the artificial line breaks after you paste the code into a project. + ## Select and configure an email provider for the server project In this article, [Mailchimp's Transactional API](https://mailchimp.com/developer/transactional/api/) is used via [Mandrill.net](https://www.nuget.org/packages/Mandrill.net) to send email. We recommend using an email service to send email rather than SMTP. SMTP is difficult to configure and secure properly. Whichever email service you use, access their guidance for .NET apps, create an account, configure an API key for their service, and install any NuGet packages required. @@ -220,24 +222,16 @@ public Task ResetPasswordAsync(string email, string resetCode, In the client project, add implementations for the preceding methods in the `CookieAuthenticationStateProvider` class (`Identity/CookieAuthenticationStateProvider.cs`): ```csharp -/// -/// Begin the password recovery process by issuing a POST request to the -/// '/forgotPassword' endpoint. -/// -/// The user's email address. -/// A indicating success or failure. public async Task ForgotPasswordAsync(string email) { try { - // make the request var result = await httpClient.PostAsJsonAsync( "forgotPassword", new { email }); - // successful? if (result.IsSuccessStatusCode) { return true; @@ -245,19 +239,9 @@ public async Task ForgotPasswordAsync(string email) } catch { } - // unknown error return false; } -/// -/// Reset the user's password by issuing a POST request to the -/// '/resetPassword' endpoint. -/// -/// The user's email address. -/// The user's reset code. -/// The user's new password. -/// The result serialized to a . -/// public async Task ResetPasswordAsync(string email, string resetCode, string newPassword) { @@ -265,7 +249,6 @@ public async Task ResetPasswordAsync(string email, string resetCode, try { - // make the request var result = await httpClient.PostAsJsonAsync( "resetPassword", new { @@ -274,13 +257,11 @@ public async Task ResetPasswordAsync(string email, string resetCode, newPassword }); - // successful? if (result.IsSuccessStatusCode) { return new FormResult { Succeeded = true }; } - // body should contain details about why it failed var details = await result.Content.ReadAsStringAsync(); var problemDetails = JsonDocument.Parse(details); var errors = new List(); @@ -301,7 +282,6 @@ public async Task ResetPasswordAsync(string email, string resetCode, } } - // return the error list return new FormResult { Succeeded = false, @@ -310,7 +290,6 @@ public async Task ResetPasswordAsync(string email, string resetCode, } catch { } - // unknown error return new FormResult { Succeeded = false, @@ -321,9 +300,6 @@ public async Task ResetPasswordAsync(string email, string resetCode, In the client project, add the following `ForgotPassword` component. -> [!NOTE] -> Code lines in the following example are broken across two or more lines to eliminate or reduce horizontal scrolling in this article, but you can place the following code as shown into a test app. The code executes regardless of the artificial line breaks. - `Components/Identity/ForgotPassword.razor`: ```razor @@ -341,20 +317,15 @@ In the client project, add the following `ForgotPassword` component.
@if (!passwordResetCodeSent) { - +
-
- +
- + @@ -413,13 +379,9 @@ In the client project, add the following `ForgotPassword` component. class="text-danger" />
-
-
@@ -778,6 +670,7 @@ Add the following `Manage2fa` component. private IEnumerable? recoveryCodes; private IJSObjectReference? module; private TwoFactorResult twoFactorResult = new(); + private ElementReference qrCodeElement; [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); @@ -796,35 +689,36 @@ Add the following `Manage2fa` component. { module = await JS.InvokeAsync("import", "./Components/Identity/Manage2fa.razor.js"); + } - //twoFactorResult = await Acct.TwoFactorRequest(); - - if (authenticationState is not null) - { - var authState = await authenticationState; - var email = authState?.User?.Identity?.Name!; + if (authenticationState is not null && + !string.IsNullOrEmpty(twoFactorResult?.SharedKey) && + module is not null) + { + 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, - twoFactorResult.SharedKey); + var uri = string.Format( + CultureInfo.InvariantCulture, + "otpauth://totp/{0}:{1}?secret={2}&issuer={0}&digits=6", + UrlEncoder.Default.Encode(Config["TotpOrganizationName"]!), + email, + twoFactorResult?.SharedKey); - await module.InvokeVoidAsync("setQrCode", uri); - } + await module.InvokeVoidAsync("setQrCode", qrCodeElement, uri); } } private async Task Disable2FA() { - twoFactorResult = await Acct.TwoFactorRequest(resetSharedKey: true); + twoFactorResult = + await Acct.TwoFactorRequest(enable: false, resetSharedKey: true); } - private async Task NewCodes() + private async Task GenerateNewCodes() { - twoFactorResult = await Acct.TwoFactorRequest(resetRecoveryCodes: true); - recoveryCodes = twoFactorResult.RecoveryCodes; + twoFactorResult = + await Acct.TwoFactorRequest(enable: false, resetRecoveryCodes: true); } private async Task OnValidSubmitAsync() @@ -859,8 +753,17 @@ Add the following [collocated JavaScript file](xref:blazor/js-interop/javascript `Components/Identity/Manage2fa.razor.js`: ```javascript -export function setQrCode(uri) { - new QRCode(document.getElementById('qrCode'), uri); +export function setQrCode(qrCodeElement, uri) { + if (qrCodeElement !== null) { + QrCreator.render({ + text: uri, + radius: 0, + ecLevel: 'H', + fill: '#000000', + background: null, + size: 190 + }, qrCodeElement); + } } ``` From 732f927b221bba77f6991a227fdd74fa379be128 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Thu, 21 Nov 2024 11:12:30 -0500 Subject: [PATCH 04/25] Updates --- .../standalone-with-identity/qrcodes-for-authenticator-apps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md index a6bae9c41c70..8b3df3878579 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md @@ -111,7 +111,7 @@ public Task LoginTwoFactorCodeAsync( public Task TwoFactorRequest( bool enable = false, - string twoFactorCode = string.Empty, + string twoFactorCode = "", bool resetSharedKey = false, bool resetRecoveryCodes = false, bool forgetMachine = false); From 8f12c385eb5e3cf02d4b58d1799fb899ae92597a Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Fri, 22 Nov 2024 08:47:27 -0500 Subject: [PATCH 05/25] Updates --- .../qrcodes-for-authenticator-apps.md | 12 +- .../qrcodes-for-authenticator-apps.md | 232 ++++++++++++------ 2 files changed, 162 insertions(+), 82 deletions(-) diff --git a/aspnetcore/blazor/security/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/qrcodes-for-authenticator-apps.md index ace84d25cbd9..85533c533e75 100644 --- a/aspnetcore/blazor/security/qrcodes-for-authenticator-apps.md +++ b/aspnetcore/blazor/security/qrcodes-for-authenticator-apps.md @@ -11,12 +11,12 @@ uid: blazor/security/qrcodes-for-authenticator-apps [!INCLUDE[](~/includes/not-latest-version-without-not-supported-content.md)] -This article explains how to configure an ASP.NET Core Blazor Web App with QR code generation for TOTP authenticator apps. +This article explains how to configure an ASP.NET Core Blazor Web App with QR code generation for Time-based One-time Password Algorithm (TOTP) authenticator apps. -For an introduction to two-factor authentication (2FA) with authenticator apps using a Time-based One-time Password Algorithm (TOTP), see . +For an introduction to two-factor authentication (2FA) using a TOTP authenticator app, see . > [!WARNING] -> An ASP.NET Core TOTP code should be kept secret because it can be used to authenticate successfully multiple times before it expires. +> TOTP codes should be kept secret because it can be used to authenticate successfully multiple times before it expires. ## Scaffold the Enable Authenticator component into the app @@ -33,11 +33,11 @@ For more information, see . For ## Adding QR codes to the 2FA configuration page -The QR code used by 2FA authenticator apps to configure the app to creates TOTP codes must be generated by a QR code library. +QR codes for use by TOTP authenticator apps must be generated by a QR code library. -The guidance in this article uses [qr-creator](https://github.com/nimiq/qr-creator), but you can use any general QR code generation library. +The guidance in this article uses [`nimiq/qr-creator`](https://github.com/nimiq/qr-creator), but you can use any general QR code generation library. -Download the [`qr-creator.min.js`](https://cdn.jsdelivr.net/npm/qr-creator/dist/qr-creator.min.js) library to the solution's server project. The library has no dependencies. If you intend to use the library to generate other QR codes elsewhere in the app for your own purposes, there's also a module version of the library available from the maintainer. +Download the [`qr-creator.min.js`](https://cdn.jsdelivr.net/npm/qr-creator/dist/qr-creator.min.js) library to the solution's server project. The library has no dependencies. If you intend to use the library to generate other QR codes elsewhere in the app for your own purposes, there's also a module version of the library available from the maintainer (see the [`nimiq/qr-creator`](https://github.com/nimiq/qr-creator) GitHub repository for details). In the `App` component (`Components/App.razor`), place a library script reference after [Blazor's ` @@ -53,7 +53,7 @@ In the client project's `wwwroot/index.html` file, add the following ` -``` - -The `EnableAuthenticator` component, which is part of the QR code system in the app and displays the QR code to users, adopts static server-side rendering (static SSR) with enhanced navigation. Therefore, normal scripts can't execute when the component loads or updates under enhanced navigation. Extra steps are required to trigger the QR code to load in the UI when the page is loaded. To accomplish loading the QR code, the approach explained in is adopted. - -Add the following [JavaScript initializer](xref:blazor/fundamentals/startup#javascript-initializers) to the server project's `wwwroot` folder. The `{NAME}` placeholder must be the name of the app's assembly in order for Blazor to locate and load the file automatically. If the server app's assembly name is `BlazorSample`, the file is named `BlazorSample.lib.module.js`. - -`wwwroot/{NAME}.lib.module.js`: - -[!INCLUDE[](~/blazor/includes/js-interop/blazor-page-script.md)] - -Add the following shared `PageScript` component to the server app. - -`Components/PageScript.razor`: +Open the `EnableAuthenticator` component in the `Components/Account/Pages/Manage` folder. At the top of the file under the `@page` directive, add an `@using` directive for the QrCodeGenerator namespace: ```razor - - -@code { - [Parameter] - [EditorRequired] - public string Src { get; set; } = default!; -} +@using Net.Codecrete.QrCodeGenerator ``` -Add the following [collocated JS file](xref:blazor/js-interop/javascript-location#load-a-script-from-an-external-javascript-file-js-collocated-with-a-component) for the `EnableAuthenticator` component, which is located at `Components/Account/Pages/Manage/EnableAuthenticator.razor`. The `onLoad` function creates the QR code with Sangmin's `qrcode.js` library using the QR code URI produced by the `GenerateQrCodeUri` method in the component's `@code` block. - -`Components/Account/Pages/Manage/EnableAuthenticator.razor.js`: - -```javascript -export function onLoad() { - const uri = document.getElementById('qrCodeData').getAttribute('data-url'); - QrCreator.render({ - text: uri, - radius: 0, - ecLevel: 'H', - fill: '#000000', - background: null, - size: 190 - }, document.querySelector('#qrCode')); -} -``` - -Under the `` component in the `EnableAuthenticator` component, add the `PageScript` component with the path to the collocated JS file: - -```razor - -``` - -> [!NOTE] -> An alternative to using the approach with the `PageScript` component is to use an event listener (`blazor.addEventListener("enhancedload", {CALLBACK})`) registered in an [`afterWebStarted` JS initializer](xref:blazor/fundamentals/startup#javascript-initializers) to listen for page updates caused by enhanced navigation. The callback (`{CALLBACK}` placeholder) performs the QR code initialization logic. -> -> Using the callback approach with `enhancedload`, the code executes for every enhanced navigation, even when the QR code `
` isn't rendered. Therefore, additional code must be added to check for the presence of the `
` before executing the code that adds a QR code. - - -Delete the `
` element that contains the QR code instructions: +Delete the `
` element that contains the QR code instructions and the two `
` elements where the QR code should appear and where the QR code data is stored in the page: ```diff -
- Learn how to enable - QR code generation. -
+-
+-
``` -Locate the two `
` elements where the QR code should appear and where the QR code data is stored in the page. +Replace the deleted elements with the following markup: -Make the following changes: +```razor +
+ + + + +
+``` -* For the empty `
`, give the element an `id` of `qrCode`. -* For the `
` with the `data-url` attribute, give the element an `id` of `qrCodeData`. +Just inside the opening `@code` block, change the variable declaration for `authenticatorUri` to `svgGraphicsPath`: ```diff --
--
-+
-+
+- private string? authenticatorUri; ++ private string? svgGraphicsPath; ``` -Change the site name in the `GenerateQrCodeUri` method of the `EnableAuthenticator` component. The default value is `Microsoft.AspNetCore.Identity.UI`. Change the value to a meaningful site name that users can identify easily in their authenticator app. Developers usually set a site name that matches the company's name. We recommend limiting the site name length to 30 characters or less to allow the site name to display on narrow mobile device screens. +Change the site name in the `GenerateQrCodeUri` method. The default value is `Microsoft.AspNetCore.Identity.UI`. Change the value to a meaningful site name that users can identify easily in their authenticator app. Developers usually set a site name that matches the company's name. We recommend limiting the site name length to 30 characters or less to allow the site name to display on narrow mobile device screens. In the following example, the default value `Microsoft.AspNetCore.Identity.UI` is changed to the company name `Weyland-Yutani Corporation` (©1986 20th Century Studios [*Aliens*](https://www.20thcenturystudios.com/movies/aliens)). @@ -120,6 +77,20 @@ In the `GenerateQrCodeUri` method: + UrlEncoder.Encode("Weyland-Yutani Corporation"), ``` +At the bottom of the `LoadSharedKeyAndQrCodeUriAsync` method, add the [`var` keyword](/dotnet/csharp/programming-guide/classes-and-structs/implicitly-typed-local-variables) to the line that sets `authenticatorUri`, making it an implicitly-typed local variable: + +```diff +- authenticatorUri = GenerateQrCodeUri(email!, unformattedKey!); ++ var authenticatorUri = GenerateQrCodeUri(email!, unformattedKey!); +``` + +Add the following lines of code at the bottom of the `LoadSharedKeyAndQrCodeUriAsync` method: + +```csharp +var qr = QrCode.EncodeText(authenticatorUri, QrCode.Ecc.Medium); +svgGraphicsPath = qr.ToGraphicsPath(); +``` + Run the app and ensure that the QR code is scannable and that the code validates. > [!WARNING] diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md index a8a48ded26b8..a267db64bd0a 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md @@ -41,15 +41,11 @@ Although apps that implement 2FA usually adopt account confirmation and password A QR code generated by the app to set up 2FA with an TOTP authenticator app must be generated by a QR code library. -The guidance in this article uses [`nimiq/qr-creator`](https://github.com/nimiq/qr-creator), but you can use any QR code generation library. +The guidance in this article uses [`manuelbl/QrCodeGenerator`](https://github.com/manuelbl/QrCodeGenerator), but you can use any QR code generation library. -Download the [`qr-creator.min.js`](https://cdn.jsdelivr.net/npm/qr-creator/dist/qr-creator.min.js) library to the `wwwroot` folder of the client project. The library has no dependencies. If you intend to use the library to generate other QR codes elsewhere in the app for your own purposes, there's also a module version of the library available from the maintainer (see the [`nimiq/qr-creator`](https://github.com/nimiq/qr-creator) GitHub repository for details). +Add a package reference to the client project for the [`Net.Codecrete.QrCodeGenerator`](https://www.nuget.org/packages/Net.Codecrete.QrCodeGenerator) NuGet package. -In the client project's `wwwroot/index.html` file, add the following ` -``` +[!INCLUDE[](~/includes/package-reference.md)] ## Set the TOTP organization name @@ -606,6 +602,7 @@ If 2FA is enabled, buttons appear to disable 2FA and regenerate recovery codes. @using System.Globalization @using System.Text @using System.Text.Encodings.Web +@using Net.Codecrete.QrCodeGenerator @using BlazorWasmAuth.Identity @using BlazorWasmAuth.Identity.Models @attribute [Authorize] @@ -613,7 +610,6 @@ If 2FA is enabled, buttons appear to disable 2FA and regenerate recovery codes. @inject IAccountManagement Acct @inject IAuthorizationService AuthorizationService @inject IConfiguration Config -@inject IJSRuntime JS @inject ILogger Logger Manage 2FA @@ -700,7 +696,14 @@ If 2FA is enabled, buttons appear to disable 2FA and regenerate recovery codes. two-factor authenticator app. Spaces and casing don't matter.

-
+
+ + + + +
  • @@ -748,10 +751,9 @@ If 2FA is enabled, buttons appear to disable 2FA and regenerate recovery codes.

  • @code { - private IJSObjectReference? module; private TwoFactorResponse twoFactorResponse = new(); - private ElementReference qrCodeElement; private bool loading = true; + private string? svgGraphicsPath; [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); @@ -762,39 +764,35 @@ If 2FA is enabled, buttons appear to disable 2FA and regenerate recovery codes. protected override async Task OnInitializedAsync() { twoFactorResponse = await Acct.TwoFactorRequestAsync(new()); + svgGraphicsPath = await GetQrCode(twoFactorResponse.SharedKey); loading = false; } - protected override async Task OnAfterRenderAsync(bool firstRender) + private async Task GetQrCode(string sharedKey) { - if (firstRender) - { - module = await JS.InvokeAsync("import", - "./Components/Identity/Manage2fa.razor.js"); - } - - if (authenticationState is not null && - !string.IsNullOrEmpty(twoFactorResponse?.SharedKey) && - module is not null) + 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); - await module.InvokeVoidAsync("setQrCode", qrCodeElement, uri); + return qr.ToGraphicsPath(); } + + return string.Empty; } private async Task Disable2FA() { twoFactorResponse = await Acct.TwoFactorRequestAsync(new() { ResetSharedKey = true }); + svgGraphicsPath = await GetQrCode(twoFactorResponse.SharedKey); } private async Task GenerateNewCodes() @@ -834,27 +832,6 @@ If 2FA is enabled, buttons appear to disable 2FA and regenerate recovery codes. } ``` -Add the following [collocated JavaScript file](xref:blazor/js-interop/javascript-location#load-a-script-from-an-external-javascript-file-js-collocated-with-a-component) to the project. The `setQrCode` function uses [`nimiq/qr-creator`](https://github.com/nimiq/qr-creator) to create a QR code from the URI string if the `qrCode` element is rendered by the `Manage2fa` component. To customize features of the generated QR code, see the `nimiq/qr-creator` documentation at their GitHub repository. - -`Components/Identity/Manage2fa.razor.js`: - -```javascript -export function setQrCode(qrCodeElement, uri) { - if (qrCodeElement !== null && - qrCodeElement.innerHTML !== undefined && - !qrCodeElement.innerHTML.trim()) { - QrCreator.render({ - text: uri, - radius: 0, - ecLevel: 'H', - fill: '#000000', - background: null, - size: 190 - }, qrCodeElement); - } -} -``` - ## Link to the the Manage 2FA page Add a link to the navigation menu for users to reach the `Manage2fa` component page. From c1e2bd8c2c20291e3a3651a1adb44d6a67e3b548 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Wed, 4 Dec 2024 10:28:21 -0500 Subject: [PATCH 16/25] Updates --- .../qrcodes-for-authenticator-apps.md | 9 --------- 1 file changed, 9 deletions(-) diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md index a267db64bd0a..1ce2e999b86f 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md @@ -606,7 +606,6 @@ If 2FA is enabled, buttons appear to disable 2FA and regenerate recovery codes. @using BlazorWasmAuth.Identity @using BlazorWasmAuth.Identity.Models @attribute [Authorize] -@implements IAsyncDisposable @inject IAccountManagement Acct @inject IAuthorizationService AuthorizationService @inject IConfiguration Config @@ -821,14 +820,6 @@ If 2FA is enabled, buttons appear to disable 2FA and regenerate recovery codes. [Display(Name = "Verification Code")] public string Code { get; set; } = string.Empty; } - - async ValueTask IAsyncDisposable.DisposeAsync() - { - if (module is not null) - { - await module.DisposeAsync(); - } - } } ``` From d716300f021605322e0e0adc2a9f3851035d1091 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:02:01 -0500 Subject: [PATCH 17/25] Updates --- aspnetcore/security/authentication/identity-enable-qrcodes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aspnetcore/security/authentication/identity-enable-qrcodes.md b/aspnetcore/security/authentication/identity-enable-qrcodes.md index 754222afd22c..fb9bd7b19282 100644 --- a/aspnetcore/security/authentication/identity-enable-qrcodes.md +++ b/aspnetcore/security/authentication/identity-enable-qrcodes.md @@ -17,7 +17,7 @@ ASP.NET Core ships with support for authenticator applications for individual au :::moniker range=">= aspnetcore-8.0" -The ASP.NET Core web app templates support authenticators but don't provide support for QR code generation. QR code generators ease the setup of 2FA. This document provides guidance for Razor Pages and MVC apps on how to add [QR code](https://wikipedia.org/wiki/QR_code) generation to the 2FA configuration page. For guidance that applies to Blazor Web Apps, see . +The ASP.NET Core web app templates support authenticators but don't provide support for QR code generation. QR code generators ease the setup of 2FA. This document provides guidance for Razor Pages and MVC apps on how to add [QR code](https://wikipedia.org/wiki/QR_code) generation to the 2FA configuration page. For guidance that applies to Blazor Web Apps, see . For guidance that applies to Blazor WebAssembly apps, see . :::moniker-end From 6060ce86b6c0b499d455139c416ee8cb51d60dd5 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Wed, 4 Dec 2024 11:08:51 -0500 Subject: [PATCH 18/25] Updates --- aspnetcore/security/authentication/identity-enable-qrcodes.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/aspnetcore/security/authentication/identity-enable-qrcodes.md b/aspnetcore/security/authentication/identity-enable-qrcodes.md index fb9bd7b19282..1fa8a0abbb96 100644 --- a/aspnetcore/security/authentication/identity-enable-qrcodes.md +++ b/aspnetcore/security/authentication/identity-enable-qrcodes.md @@ -17,7 +17,7 @@ ASP.NET Core ships with support for authenticator applications for individual au :::moniker range=">= aspnetcore-8.0" -The ASP.NET Core web app templates support authenticators but don't provide support for QR code generation. QR code generators ease the setup of 2FA. This document provides guidance for Razor Pages and MVC apps on how to add [QR code](https://wikipedia.org/wiki/QR_code) generation to the 2FA configuration page. For guidance that applies to Blazor Web Apps, see . For guidance that applies to Blazor WebAssembly apps, see . +The ASP.NET Core web app templates support authenticators but don't provide support for QR code generation. QR code generators ease the setup of 2FA. This document provides guidance for Razor Pages and MVC apps on how to add [QR code](https://wikipedia.org/wiki/QR_code) generation to the 2FA configuration page. For guidance that applies to Blazor Web Apps, see . For guidance that applies to Blazor WebAssembly apps, see . :::moniker-end From c128164f8cdc366c10962802b24a64581b54f215 Mon Sep 17 00:00:00 2001 From: Luke Latham <1622880+guardrex@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:23:12 -0500 Subject: [PATCH 19/25] Apply suggestions from code review Co-authored-by: Stephen Halter --- .../account-confirmation-and-password-recovery.md | 2 +- .../qrcodes-for-authenticator-apps.md | 12 ++++++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md index 02c9e7e6eb53..89cfface86ce 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md @@ -362,7 +362,7 @@ In the client project, add the following `ForgotPassword` component. Obtain the code from the email for this form.
    + OnValidSubmit="OnValidSubmitStep2Async" method="post"> diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md index 1ce2e999b86f..b9225f0a74a2 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md @@ -480,6 +480,8 @@ Replace the `Login` component. The following version of the `Login` component: { if (!string.IsNullOrEmpty(Input.TwoFactorCode)) { + // The [RegularExpression] data annotation ensures that the input will be either a six-digit + // authenticator code (######) or eleven-character alphanumeric recovery code (#####-#####) if (Input.TwoFactorCode.Length == 6) { formResult = await Acct.LoginTwoFactorCodeAsync( @@ -505,7 +507,10 @@ Replace the `Login` component. The following version of the `Login` component: formResult = await Acct.LoginAsync(Input.Email, Input.Password); requiresTwoFactor = formResult.ErrorList.Contains("RequiresTwoFactor"); Input.TwoFactorCode = string.Empty; - formResult.ErrorList = []; + if (requiresTwoFactor) + { + formResult.ErrorList = []; + } } if (formResult.Succeeded && !string.IsNullOrEmpty(ReturnUrl)) @@ -526,13 +531,12 @@ Replace the `Login` component. The following version of the `Login` component: [Display(Name = "Password")] public string Password { get; set; } = string.Empty; - [Required] [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")] - public string TwoFactorCode { get; set; } = "123456"; + [Display(Name = "Two-factor Code or Recovery Code")] + public string TwoFactorOrRecoveryCode { get; set; } = string.Empty; } } ``` From 5025c32910eed9bdd772502beb077ce805e3acae Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:29:23 -0500 Subject: [PATCH 20/25] Updates --- .../qrcodes-for-authenticator-apps.md | 45 ++++++++++++------- 1 file changed, 30 insertions(+), 15 deletions(-) diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md index b9225f0a74a2..e923515e512c 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md @@ -431,15 +431,15 @@ Replace the `Login` component. The following version of the `Login` component:
    - -
    @@ -478,19 +478,22 @@ Replace the `Login` component. The following version of the `Login` component: { if (requiresTwoFactor) { - if (!string.IsNullOrEmpty(Input.TwoFactorCode)) + if (!string.IsNullOrEmpty(Input.TwoFactorCodeOrRecoveryCode)) { - // The [RegularExpression] data annotation ensures that the input will be either a six-digit - // authenticator code (######) or eleven-character alphanumeric recovery code (#####-#####) - if (Input.TwoFactorCode.Length == 6) + // 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.TwoFactorCode); + Input.Email, Input.Password, + Input.TwoFactorCodeOrRecoveryCode); } else { formResult = await Acct.LoginTwoFactorRecoveryCodeAsync( - Input.Email, Input.Password, Input.TwoFactorCode); + Input.Email, Input.Password, + Input.TwoFactorCodeOrRecoveryCode); if (formResult.Succeeded) { @@ -501,12 +504,22 @@ Replace the `Login` component. The following version of the `Login` component: } } } + 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.TwoFactorCode = string.Empty; + Input.TwoFactorCodeOrRecoveryCode = string.Empty; + if (requiresTwoFactor) { formResult.ErrorList = []; @@ -536,7 +549,7 @@ Replace the `Login` component. The following version of the `Login` component: "eleven-character alphanumeric recovery code (#####-#####, dash " + "required)")] [Display(Name = "Two-factor Code or Recovery Code")] - public string TwoFactorOrRecoveryCode { get; set; } = string.Empty; + public string TwoFactorCodeOrRecoveryCode { get; set; } = string.Empty; } } ``` @@ -544,13 +557,15 @@ Replace the `Login` component. The following version of the `Login` component: Using the preceding component, the user is remembered after a successful login with a valid TOTP code from an authenticator app. If you want to always require a TOTP code for login and not remember the machine, call the `TwoFactorRequestAsync` method with `TwoFactorRequest.ForgetMachine` set to `true` immediately after a successful two-factor login: ```diff -if (Input.TwoFactorCode.Length == 6) +if (Input.TwoFactorCodeOrRecoveryCode.Length == 6) { - formResult = await Acct.LoginTwoFactorCodeAsync(Input.Email, Input.Password, Input.TwoFactorCode); + formResult = await Acct.LoginTwoFactorCodeAsync(Input.Email, Input.Password, + Input.TwoFactorCodeOrRecoveryCode); + if (formResult.Succeeded) + { -+ var forgetMachine = await Acct.TwoFactorRequestAsync(new() { ForgetMachine = true }); ++ var forgetMachine = ++ await Acct.TwoFactorRequestAsync(new() { ForgetMachine = true }); + } } ``` From e57702cb44b9bdaf57ff360b5573f58d0b9f11e1 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Wed, 4 Dec 2024 16:40:08 -0500 Subject: [PATCH 21/25] Updates --- .../standalone-with-identity/qrcodes-for-authenticator-apps.md | 1 + 1 file changed, 1 insertion(+) diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md index e923515e512c..d44acde4af9c 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md @@ -808,6 +808,7 @@ If 2FA is enabled, buttons appear to disable 2FA and regenerate recovery codes. private async Task Disable2FA() { + await Acct.TwoFactorRequestAsync(new() { ForgetMachine = true }); twoFactorResponse = await Acct.TwoFactorRequestAsync(new() { ResetSharedKey = true }); svgGraphicsPath = await GetQrCode(twoFactorResponse.SharedKey); From 17be3fb0599726268f31bf6ddaecfdd987504f0a Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Thu, 5 Dec 2024 15:49:22 -0500 Subject: [PATCH 22/25] Updates --- .../qrcodes-for-authenticator-apps.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md index d44acde4af9c..4ab062eb84f9 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md @@ -829,6 +829,18 @@ If 2FA is enabled, buttons appear to disable 2FA and regenerate recovery codes. 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 From 0be9c72b671a91257174055c067ba5965d5294a6 Mon Sep 17 00:00:00 2001 From: guardrex <1622880+guardrex@users.noreply.github.com> Date: Thu, 5 Dec 2024 16:13:54 -0500 Subject: [PATCH 23/25] Updates --- .../qrcodes-for-authenticator-apps.md | 29 ++++++++++--------- 1 file changed, 16 insertions(+), 13 deletions(-) diff --git a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md index 4ab062eb84f9..07d390437e72 100644 --- a/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md @@ -213,7 +213,10 @@ public async Task LoginAsync(string email, string password) } } } - catch { } + catch (Exception ex) + { + logger.LogError(ex, "App error"); + } return new FormResult { @@ -248,7 +251,10 @@ public async Task LoginTwoFactorCodeAsync( return new FormResult { Succeeded = true }; } } - catch { } + catch (Exception ex) + { + logger.LogError(ex, "App error"); + } return new FormResult { @@ -283,7 +289,10 @@ public async Task LoginTwoFactorRecoveryCodeAsync(string email, return new FormResult { Succeeded = true }; } } - catch { } + catch (Exception ex) + { + logger.LogError(ex, "App error"); + } return new FormResult { @@ -380,12 +389,6 @@ Replace the `Login` component. The following version of the `Login` component:
    You're logged in as @context.User.Identity?.Name.
    - @if (!string.IsNullOrEmpty(recoveryCodesRemainingMessage)) - { -
    - @recoveryCodesRemainingMessage -
    - } @foreach (var error in formResult.ErrorList) @@ -466,7 +469,6 @@ Replace the `Login` component. The following version of the `Login` component: @code { private FormResult formResult = new(); private bool requiresTwoFactor; - private string? recoveryCodesRemainingMessage; [SupplyParameterFromForm] private InputModel Input { get; set; } = new(); @@ -498,9 +500,6 @@ Replace the `Login` component. The following version of the `Login` component: if (formResult.Succeeded) { var twoFactorResponse = await Acct.TwoFactorRequestAsync(new()); - recoveryCodesRemainingMessage = - $"You have {twoFactorResponse.RecoveryCodesLeft} recovery " + - "codes remaining."; } } } @@ -662,6 +661,10 @@ If 2FA is enabled, buttons appear to disable 2FA and regenerate recovery codes. @if (twoFactorResponse.RecoveryCodes is null) { +
    + Recovery Codes Remaining: + @twoFactorResponse.RecoveryCodesLeft +