diff --git a/aspnetcore/blazor/fundamentals/index.md b/aspnetcore/blazor/fundamentals/index.md index 1bf538ba6cb1..7fe074617b1f 100644 --- a/aspnetcore/blazor/fundamentals/index.md +++ b/aspnetcore/blazor/fundamentals/index.md @@ -69,6 +69,7 @@ Blazor documentation adopts several conventions for showing and discussing compo * [Component parameter](xref:blazor/components/index#component-parameters) values lead with a [Razor reserved `@` symbol](xref:mvc/views/razor#razor-syntax), but it isn't required. Literals (for example, boolean values), keywords (for example, `this`), and `null` as component parameter values aren't prefixed with `@`, but this is also merely a documentation convention. Your own code can prefix literals with `@` if you wish. * C# classes use the [`this` keyword](/dotnet/csharp/language-reference/keywords/this) and avoid prefixing fields with an underscore (`_`) that are assigned to in constructors, which differs from the [ASP.NET Core framework engineering guidelines](https://github.com/dotnet/aspnetcore/wiki/Engineering-guidelines). * In examples that use [primary constructors (C# 12 or later)](/dotnet/csharp/whats-new/tutorials/primary-constructors), primary constructor parameters are typically used directly by class members. +In article examples, code lines are split to reduce horizontal scrolling. These breaks don't affect execution but can be removed when pasting into your project. Additional information on Razor component syntax is provided in the *Razor syntax* section of . diff --git a/aspnetcore/blazor/security/qrcodes-for-authenticator-apps.md b/aspnetcore/blazor/security/qrcodes-for-authenticator-apps.md index a9dc6fb603d1..6726d5e3d6cf 100644 --- a/aspnetcore/blazor/security/qrcodes-for-authenticator-apps.md +++ b/aspnetcore/blazor/security/qrcodes-for-authenticator-apps.md @@ -11,107 +11,62 @@ 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 for two-factor authentication (2FA) with QR codes generated by 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 2FA with TOTP authenticator apps, 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. - -## Scaffold the Enable Authenticator component into the app - -Follow the guidance in to scaffold `Pages\Manage\EnableAuthenticator` into the app. - - +The guidance in this article relies upon either creating the app with **Individual Accounts** for the new app's **Authentication type** or [scaffolding Identity into an existing app](xref:security/authentication/scaffold-identity#scaffold-identity-into-a-blazor-project). For guidance on using the .NET CLI instead of Visual Studio for scaffolding Identity into an existing app, see . -> [!NOTE] -> Although only the `EnableAuthenticator` component is selected for scaffolding in this example, scaffolding currently adds all of the Identity components to the app. Additionally, exceptions may be thrown during the process of scaffolding into the app. If exceptions occur when database migrations occur, stop the app and restart the app on each exception. For more information, see [Scaffolding exceptions for Blazor Web App (`dotnet/Scaffolding` #2694)](https://github.com/dotnet/Scaffolding/issues/2694). - -Be patient while migrations are executed. Depending on the speed of the system, it can take up to a minute or two for database migrations to finish. - -For more information, see . For guidance on using the .NET CLI instead of Visual Studio, see . +> [!WARNING] +> TOTP codes should be kept secret because they can be used to authenticate multiple times before they expire. ## 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)). - -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. - -In the `App` component (`Components/App.razor`), place a library script reference after [Blazor's ` -``` - -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`: +The guidance in this article uses [`manuelbl/QrCodeGenerator`](https://github.com/manuelbl/QrCodeGenerator), but you can use any QR code generation library. -[!INCLUDE[](~/blazor/includes/js-interop/blazor-page-script.md)] +Add a package reference for the [`Net.Codecrete.QrCodeGenerator`](https://www.nuget.org/packages/Net.Codecrete.QrCodeGenerator) NuGet package. -Add the following shared `PageScript` component to the server app. +[!INCLUDE[](~/includes/package-reference.md)] -`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!; -} -``` - -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'); - new QRCode(document.getElementById('qrCode'), uri); -} +@using Net.Codecrete.QrCodeGenerator ``` -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. 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. +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)). @@ -122,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/account-confirmation-and-password-recovery.md b/aspnetcore/blazor/security/webassembly/standalone-with-identity/account-confirmation-and-password-recovery.md index 729507bb94ae..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 @@ -4,17 +4,19 @@ 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 +[!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] > 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: @@ -156,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 and can login to the app. ++ 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 @@ -222,24 +220,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; @@ -247,19 +237,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) { @@ -267,7 +247,6 @@ public async Task ResetPasswordAsync(string email, string resetCode, try { - // make the request var result = await httpClient.PostAsJsonAsync( "resetPassword", new { @@ -276,13 +255,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(); @@ -303,7 +280,6 @@ public async Task ResetPasswordAsync(string email, string resetCode, } } - // return the error list return new FormResult { Succeeded = false, @@ -312,7 +288,6 @@ public async Task ResetPasswordAsync(string email, string resetCode, } catch { } - // unknown error return new FormResult { Succeeded = false, @@ -323,9 +298,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 @@ -344,14 +316,15 @@ In the client project, add the following `ForgotPassword` component. @if (!passwordResetCodeSent) { + OnValidSubmit="OnValidSubmitStep1Async" method="post">
- + @@ -415,7 +388,8 @@ In the client project, add the following `ForgotPassword` component. class="text-danger" />
- @@ -435,8 +409,7 @@ In the client project, add the following `ForgotPassword` component.
@code { - private bool passwordResetCodeSent; - private bool passwordResetSuccess, errors; + private bool passwordResetCodeSent, passwordResetSuccess, errors; private string[] errorList = []; [SupplyParameterFromForm(FormName = "forgot-password")] @@ -490,7 +463,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 new file mode 100644 index 000000000000..353b9227a7d6 --- /dev/null +++ b/aspnetcore/blazor/security/webassembly/standalone-with-identity/qrcodes-for-authenticator-apps.md @@ -0,0 +1,898 @@ +--- +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 apps. +ms.author: riande +monikerRange: '>= aspnetcore-8.0' +ms.date: 11/21/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 two-factor authentication (2FA) with QR codes generated by Time-based One-time Password Algorithm (TOTP) authenticator apps. + +For an introduction to 2FA with TOTP authenticator apps, see . + +> [!WARNING] +> TOTP codes should be kept secret because they can be used to authenticate multiple times before they expire. + +## Namespaces and article code examples + +The namespaces used by the examples in this article are: + +* `Backend` for the backend server web API project, described as the "server project" in this article. +* `BlazorWasmAuth` for the front-end client standalone Blazor WebAssembly app, described as the "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, change the namespaces in the code examples to use the namespaces of your projects. + +All of the changes to the solution covered by this article take place in the `BlazorWasmAuth` project of the `BlazorWebAssemblyStandaloneWithIdentity` solution. + +In article examples, code lines are split to reduce horizontal scrolling. These breaks don't affect execution but can be removed when pasting into your project. + +## Optional account confirmation and password recovery + +Although apps that implement 2FA usually adopt account confirmation and password recovery features, 2FA doesn't require it. The guidance in this article can be followed to implement 2FA without following the guidance in . + +## Add a QR code library to the app + +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 [`manuelbl/QrCodeGenerator`](https://github.com/manuelbl/QrCodeGenerator), but you can use any QR code generation library. + +Add a package reference to the client project for the [`Net.Codecrete.QrCodeGenerator`](https://www.nuget.org/packages/Net.Codecrete.QrCodeGenerator) NuGet package. + +[!INCLUDE[](~/includes/package-reference.md)] + +## Set the TOTP organization name + +Set the site name in the app settings file of the client 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. 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`: + +```json +"TotpOrganizationName": "Weyland-Yutani Corporation" +``` + +The app settings file after the TOTP organization name configuration is added: + +```json +{ + "BackendUrl": "https://localhost:7211", + "FrontendUrl": "https://localhost:7171", + "TotpOrganizationName": "Weyland-Yutani Corporation" +} +``` + +## Add model classes + +Add the following `LoginResponse` class to the `Models` folder. This class is populated for requests to the `/login` endpoint of in the server app. + +`Identity/Models/LoginResponse.cs`: + +```csharp +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; } +} +``` + +Add the following `TwoFactorRequest` class to the `Models` folder. This class is populated for requests to the `/manage/2fa` endpoint of in the server app. + +`Identity/Models/TwoFactorRequest.cs`: + +```csharp +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; } +} +``` + +Add the following `TwoFactorResponse` class to the `Models` folder. This class is populated by the response to a 2FA request made to the `/manage/2fa` endpoint of in the server app. + +`Identity/Models/TwoFactorResponse.cs`: + +```csharp +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` interface + +Add the following class signatures to the `IAccountManagement` interface. The class signatures represent methods added to the cookie authentication state provider for the following client requests: + +* Log in with a 2FA TOTP code (`/login` endpoint): `LoginTwoFactorCodeAsync` +* Log in with a 2FA recovery code (`/login` endpoint): `LoginTwoFactorRecoveryCodeAsync` +* Make a 2FA management request (`/manage/2fa` endpoint): `TwoFactorRequestAsync` + +`Identity/IAccountManagement.cs` (paste the following code at the bottom of the file): + +```csharp +public Task LoginTwoFactorCodeAsync( + string email, + string password, + string twoFactorCode); + +public Task LoginTwoFactorRecoveryCodeAsync( + string email, + string password, + string twoFactorRecoveryCode); + +public Task TwoFactorRequestAsync( + TwoFactorRequest twoFactorRequest); +``` + +## Update the cookie authentication state provider + +Update the `CookieAuthenticationStateProvider` with features to add the following features: + +* Authenticate users with either a TOTP authenticator app code or a recovery code. +* Manage 2FA in the app. + +At the top of the `CookieAuthenticationStateProvider.cs` file, add a `using` statement for : + +```csharp +using System.Text.Json.Serialization; +``` + +Inject an `ILogger` to log exceptions in the class: + +```diff +- public class CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory) +- : AuthenticationStateProvider, IAccountManagement ++ public class CookieAuthenticationStateProvider(IHttpClientFactory httpClientFactory, ++ ILogger logger) ++ : AuthenticationStateProvider, IAccountManagement +``` + +In the , add the option set to , which avoids serializing null properties: + +```diff +private readonly JsonSerializerOptions jsonSerializerOptions = + new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, ++ DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, + }; +``` + +The `LoginAsync` method is updated with the following logic: + +* Attempt a normal login at the `/login` endpoint with an email address and password. +* If the server responds with a success status code, the method returns a `FormResult` with the `Succeeded` property set to `true`. +* If the server responds with the *401 - Unauthorized* status code and a detail code of "`RequiresTwoFactor`," a `FormResult` is returned with `Succeeded` set to `false` and the `RequiresTwoFactor` detail in the error list. + +In `Identity/CookieAuthenticationStateProvider.cs`, replace the `LoginAsync` method with the following code: + +```csharp +public async Task 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( + 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." ] + }; +} +``` + +A `LoginTwoFactorCodeAsync` method is added, which sends a request to the `/login` endpoint with a 2FA TOTP code (`twoFactorCode`). The method processes the response in a similar fashion to a normal, non-2FA login request. + +Add the following method and class to `Identity/CookieAuthenticationStateProvider.cs` (paste the following code at the bottom of the class file): + +```csharp +public async Task 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." ] + }; +} +``` + +A `LoginTwoFactorRecoveryCodeAsync` method is added, which sends a request to the `/login` endpoint with a 2FA recovery code (`twoFactorRecoveryCode`). The method processes the response in a similar fashion to a normal, non-2FA login request. + +Add the following method and class to `Identity/CookieAuthenticationStateProvider.cs` (paste the following code at the bottom of the class file): + +```csharp +public async Task 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." ] + }; +} +``` + +A `TwoFactorRequestAsync` method is added, which is used to manage 2FA for the user: + +* Reset the shared 2FA key when `TwoFactorRequest.ResetSharedKey` is `true`. Resetting the shared key implicitly disables 2FA. This forces the user to prove that they can provide a valid TOTP code from their authenticator app to enable 2FA after receiving a new shared key. +* Reset the user's recovery codes when `TwoFactorRequest.ResetRecoveryCodes` is `true`. +* Forget the machine when `TwoFactorRequest.ForgetMachine` is `true`, which means that a new 2FA TOTP code is required on the next login attempt. +* Enable 2FA using a TOTP code from a TOTP authenticator app when `TwoFactorRequest.Enable` is `true` and `TwoFactorRequest.TwoFactorCode` has a valid TOTP value. +* Obtain 2FA status with an empty request when all of `TwoFactorRequest`'s properties are `null`. + +Add the following `TwoFactorRequestAsync` method to `Identity/CookieAuthenticationStateProvider.cs` (paste the following code at the bottom of the class file): + +```csharp +public async Task 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() ?? + 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 TwoFactorResponse + { + ErrorList = problemDetails == null ? defaultDetail : [.. errors] + }; +} +``` + +## Replace `Login` component + +Replace the `Login` component. The following version of the `Login` component: + +* Accepts a user's email address and password for an initial login attempt. +* If login is successful (2FA is disabled), the component notifies the user that they're authenticated. +* If the login attempt results in a response indicating that 2FA is required, a 2FA input element appears to receive either a 2FA TOTP code from an authenticator app or a recovery code. Depending on which code the user enters, login is attempted again by calling either `LoginTwoFactorCodeAsync` for a TOTP code or `LoginTwoFactorRecoveryCodeAsync` for a recovery 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.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; + } +} +``` + +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.TwoFactorCodeOrRecoveryCode.Length == 6) +{ + formResult = await Acct.LoginTwoFactorCodeAsync(Input.Email, Input.Password, + Input.TwoFactorCodeOrRecoveryCode); + ++ if (formResult.Succeeded) ++ { ++ var forgetMachine = ++ await Acct.TwoFactorRequestAsync(new() { ForgetMachine = true }); ++ } +} +``` + +## Add a component to display recovery codes + +Add the following `ShowRecoveryCodes` component to the app to display recovery codes to the user. + +`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 to the app to manage 2FA for users. + +If 2FA isn't enabled, the component loads a form with a QR code to enable 2FA with a TOTP authenticator app. The user adds the app to their authenticator app and then verifies the authenticator app and enables 2FA by providing a TOTP code from the authenticator app. + +If 2FA is enabled, buttons appear to disable 2FA and regenerate recovery codes. + +`Components/Identity/Manage2fa.razor`: + +```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 Logger + +Manage 2FA + +

Manage Two-factor Authentication

+
+
+
+ @if (loading) + { +

Loading ...

+ } + else + { + @if (twoFactorResponse is not null) + { + @foreach (var error in twoFactorResponse.ErrorList) + { +
@error
+ } + @if (twoFactorResponse.IsTwoFactorEnabled) + { + + +
+ +
+ + @if (twoFactorResponse.RecoveryCodes is null) + { +
+ Recovery Codes Remaining: + @twoFactorResponse.RecoveryCodesLeft +
+
+ +
+ } + 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 + @twoFactorResponse.SharedKey into your + two-factor authenticator app. Spaces and casing + don't matter. +

    +
    + + + + +
    +
  4. +
  5. +

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

    +
    +
    + + +
    + + + +
    + +
    +
    +
    +
  6. +
+
+ } + } + } +
+
+ +@code { + private TwoFactorResponse twoFactorResponse = new(); + private bool loading = true; + private string? svgGraphicsPath; + + [SupplyParameterFromForm] + private InputModel Input { get; set; } = new(); + + [CascadingParameter] + private Task? authenticationState { get; set; } + + protected override async Task OnInitializedAsync() + { + twoFactorResponse = await Acct.TwoFactorRequestAsync(new()); + svgGraphicsPath = await GetQrCode(twoFactorResponse.SharedKey); + loading = false; + } + + private async Task 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; + } +} +``` + +## Link to the the Manage 2FA page + +Add a link to the navigation menu for users to reach the `Manage2fa` component page. + +In the `` content of the `` in `Components/Layout/NavMenu.razor`, add the following markup: + +```diff + + + + ... + ++ + + ... + + + +``` + +## Additional resources + +* [`nimiq/qr-creator`](https://github.com/nimiq/qr-creator) +* diff --git a/aspnetcore/security/authentication/identity-enable-qrcodes.md b/aspnetcore/security/authentication/identity-enable-qrcodes.md index 754222afd22c..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 . +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 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