diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Boilerplate.Client.Core.csproj b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Boilerplate.Client.Core.csproj
index 4f79e91dbc..2aa1798190 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Boilerplate.Client.Core.csproj
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Boilerplate.Client.Core.csproj
@@ -23,6 +23,7 @@
+
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor
index fcc34547c7..314fac8e30 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor
@@ -15,6 +15,7 @@
@@ -24,6 +25,7 @@
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.scss b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.scss
index 69b03d3b77..35d52815ed 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.scss
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.scss
@@ -104,4 +104,12 @@ main {
line-height: 16px;
color: $bit-color-error;
}
+
+ .nav-panel {
+ width: 280px;
+
+ @include lt-md {
+ width: 210px;
+ }
+ }
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor
new file mode 100644
index 0000000000..918afd886a
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor
@@ -0,0 +1,23 @@
+@inherits AppComponentBase
+
+
+
+
+ @Localizer[nameof(AppStrings.PasswordlessTitle)]
+
+
+ @if (isConfigured)
+ {
+
+ @Localizer[nameof(AppStrings.DisablePasswordless)]
+
+ }
+ else
+ {
+
+ @Localizer[nameof(AppStrings.EnablePasswordless)]
+
+ }
+
+
+
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs
new file mode 100644
index 0000000000..f23b7b0826
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs
@@ -0,0 +1,83 @@
+using Fido2NetLib;
+using Boilerplate.Shared.Dtos.Identity;
+using Boilerplate.Shared.Controllers.Identity;
+
+namespace Boilerplate.Client.Core.Components.Pages.Authorized.Settings;
+
+public partial class PasswordlessSection
+{
+ private bool isConfigured;
+
+
+ [AutoInject] IUserController userController = default!;
+ [AutoInject] IIdentityController identityController = default!;
+
+
+ [Parameter] public UserDto? User { get; set; }
+
+ protected override async Task OnParamsSetAsync()
+ {
+ await base.OnParamsSetAsync();
+
+ if (User?.UserName is null) return;
+
+ isConfigured = await JSRuntime.IsWebAuthnConfigured(User.UserName);
+ }
+
+
+ private async Task EnablePasswordless()
+ {
+ if (User?.UserName is null) return;
+
+ var options = await userController.GetWebAuthnCredentialOptions(CurrentCancellationToken);
+
+ AuthenticatorAttestationRawResponse attestationResponse;
+ try
+ {
+ attestationResponse = await JSRuntime.CreateWebAuthnCredential(options);
+ }
+ catch (Exception ex)
+ {
+ // we can safely handle the exception thrown here since it mostly because of a timeout or user cancelling the native ui.
+ ExceptionHandler.Handle(ex, ExceptionDisplayKind.None);
+ return;
+ }
+
+ await userController.CreateWebAuthnCredential(attestationResponse, CurrentCancellationToken);
+
+ await JSRuntime.StoreWebAuthnConfigured(User.UserName);
+
+ isConfigured = true;
+
+ SnackBarService.Success(Localizer[nameof(AppStrings.EnablePasswordlessSucsessMessage)]);
+ }
+
+ private async Task DisablePasswordless()
+ {
+ if (User?.UserName is null) return;
+
+ var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken);
+
+ AuthenticatorAssertionRawResponse assertion;
+ try
+ {
+ assertion = await JSRuntime.VerifyWebAuthnCredential(options);
+ }
+ catch (Exception ex)
+ {
+ // we can safely handle the exception thrown here since it mostly because of a timeout or user cancelling the native ui.
+ ExceptionHandler.Handle(ex, ExceptionDisplayKind.None);
+ return;
+ }
+
+ var verifyResult = await identityController.VerifyWebAuthAssertion(assertion, CurrentCancellationToken);
+
+ await userController.DeleteWebAuthnCredential(assertion.Id, CurrentCancellationToken);
+
+ await JSRuntime.RemoveWebAuthnConfigured(User.UserName);
+
+ isConfigured = false;
+
+ SnackBarService.Success(Localizer[nameof(AppStrings.DisablePasswordlessSucsessMessage)]);
+ }
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.scss b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.scss
new file mode 100644
index 0000000000..00b117f7f7
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.scss
@@ -0,0 +1,5 @@
+section {
+ width: 100%;
+ display: flex;
+ justify-content: center;
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor
index 107d336df7..16471b2221 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor
@@ -20,13 +20,19 @@
Title="@Localizer[nameof(AppStrings.AccountTitle)]"
Subtitle="@Localizer[nameof(AppStrings.AccountSubtitle)]">
-
+
-
+
-
+ @if (showPasswordless)
+ {
+
+
+
+ }
+
@Localizer[nameof(AppStrings.Delete)]
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor.cs
index f7f1696d8b..6fb471140a 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor.cs
@@ -10,6 +10,9 @@ public partial class SettingsPage
protected override string? Subtitle => string.Empty;
+ private bool showPasswordless;
+
+
[Parameter] public string? Section { get; set; }
@@ -31,6 +34,7 @@ protected override async Task OnInitAsync()
try
{
user = (await PrerenderStateService.GetValue(() => HttpClient.GetFromJsonAsync("api/User/GetCurrentUser", JsonSerializerOptions.GetTypeInfo(), CurrentCancellationToken)))!;
+ showPasswordless = await JSRuntime.IsWebAuthnAvailable() && AppPlatform.IsBlazorHybrid is false;
}
finally
{
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor
index 8ef87bf65d..bb2a7870ec 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor
@@ -15,16 +15,26 @@
{
@if (isOtpSent is false)
{
-
+
}
else
{
-
+
}
}
else
{
-
+
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor.cs
index 47355167ef..62692817c0 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor.cs
@@ -1,4 +1,5 @@
//+:cnd:noEmit
+using Fido2NetLib;
using Boilerplate.Shared.Dtos.Identity;
using Boilerplate.Shared.Controllers.Identity;
@@ -93,7 +94,47 @@ protected override async Task OnInitAsync()
}
- private async Task SocialSignIn(string provider)
+ private async Task DoSignIn()
+ {
+ if (isWaiting) return;
+ if (isOtpSent && string.IsNullOrWhiteSpace(model.Otp)) return;
+
+ isWaiting = true;
+
+ try
+ {
+ if (requiresTwoFactor && string.IsNullOrWhiteSpace(model.TwoFactorCode)) return;
+
+ CleanModel();
+
+ if (validatorRef?.EditContext.Validate() is false) return;
+
+ model.DeviceInfo = telemetryContext.Platform;
+
+ requiresTwoFactor = await AuthManager.SignIn(model, CurrentCancellationToken);
+
+ if (requiresTwoFactor)
+ {
+ PubSubService.Publish(ClientPubSubMessages.UPDATE_IDENTITY_HEADER_BACK_LINK, TfaPayload);
+ }
+ else
+ {
+ NavigationManager.NavigateTo(ReturnUrlQueryString ?? Urls.HomePage, replace: true);
+ }
+ }
+ catch (KnownException e)
+ {
+ // To disable the sign-in button until a specific time after a user lockout, use the value of `e.TryGetExtensionDataValue("TryAgainIn", out var tryAgainIn)`.
+
+ SnackBarService.Error(e.Message);
+ }
+ finally
+ {
+ isWaiting = false;
+ }
+ }
+
+ private async Task HandleOnSocialSignIn(string provider)
{
try
{
@@ -109,38 +150,42 @@ private async Task SocialSignIn(string provider)
}
}
- private async Task DoSignIn()
+ private async Task HandleOnPasswordlessSignIn()
{
if (isWaiting) return;
- if (isOtpSent && string.IsNullOrWhiteSpace(model.Otp)) return;
isWaiting = true;
try
{
- if (requiresTwoFactor && string.IsNullOrWhiteSpace(model.TwoFactorCode)) return;
-
- CleanModel();
+ var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken);
- if (validatorRef?.EditContext.Validate() is false) return;
-
- model.DeviceInfo = telemetryContext.Platform;
+ AuthenticatorAssertionRawResponse assertion;
+ try
+ {
+ assertion = await JSRuntime.VerifyWebAuthnCredential(options);
+ }
+ catch (Exception ex)
+ {
+ // we can safely handle the exception thrown here since it mostly because of a timeout or user cancelling the native ui.
+ ExceptionHandler.Handle(ex, ExceptionDisplayKind.None);
+ return;
+ }
- requiresTwoFactor = await AuthManager.SignIn(model, CurrentCancellationToken);
+ var response = await identityController.VerifyWebAuthAndSignIn(assertion, CurrentCancellationToken);
- if (requiresTwoFactor is false)
+ if (response.RequiresTwoFactor)
{
- NavigationManager.NavigateTo(ReturnUrlQueryString ?? Urls.HomePage, replace: true);
+ PubSubService.Publish(ClientPubSubMessages.UPDATE_IDENTITY_HEADER_BACK_LINK, TfaPayload);
}
else
{
- PubSubService.Publish(ClientPubSubMessages.UPDATE_IDENTITY_HEADER_BACK_LINK, TfaPayload);
+ await AuthManager.StoreTokens(response!, model.RememberMe);
+ NavigationManager.NavigateTo(ReturnUrlQueryString ?? Urls.HomePage, replace: true);
}
}
catch (KnownException e)
{
- // To disable the sign-in button until a specific time after a user lockout, use the value of `e.TryGetExtensionDataValue("TryAgainIn", out var tryAgainIn)`.
-
SnackBarService.Error(e.Message);
}
finally
@@ -149,6 +194,13 @@ private async Task DoSignIn()
}
}
+ private void HandleOnSignInPanelTabChange(SignInPanelTab tab)
+ {
+ currentSignInPanelTab = tab;
+ }
+
+ private Task HandleOnSendOtp() => SendOtp(false);
+ private Task HandleOnResendOtp() => SendOtp(true);
private async Task SendOtp(bool resend)
{
try
@@ -185,10 +237,8 @@ private async Task SendOtp(bool resend)
SnackBarService.Error(e.Message);
}
}
- private Task ResendOtp() => SendOtp(true);
- private Task SendOtp() => SendOtp(false);
- private async Task SendTfaToken()
+ private async Task HandleOnSendTfaToken()
{
try
{
@@ -204,11 +254,6 @@ private async Task SendTfaToken()
}
}
- private void HandleOnSignInPanelTabChange(SignInPanelTab tab)
- {
- currentSignInPanelTab = tab;
- }
-
private void CleanModel()
{
if (currentSignInPanelTab is SignInPanelTab.Email)
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor
index 66aea3d5f8..dfecd0c20f 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor
@@ -62,7 +62,20 @@
-
+
+
+
+ @if (isWebAuthnAvailable)
+ {
+
+ }
+
@Localizer[nameof(AppStrings.SignIn)]
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs
index 2488018b56..9d46cbf982 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs
@@ -4,12 +4,22 @@ namespace Boilerplate.Client.Core.Components.Pages.Identity.SignIn;
public partial class SignInPanel
{
+ private const string EmailKey = nameof(EmailKey);
+ private const string PhoneKey = nameof(PhoneKey);
+
+
+ private bool isWebAuthnAvailable;
+ private string? selectedKey = EmailKey;
+
+
[Parameter] public bool IsWaiting { get; set; }
[Parameter] public SignInRequestDto Model { get; set; } = default!;
[Parameter] public EventCallback OnSocialSignIn { get; set; }
+ [Parameter] public EventCallback OnPasswordlessSignIn { get; set; }
+
[Parameter] public EventCallback OnSendOtp { get; set; }
[Parameter] public EventCallback OnTabChange { get; set; }
@@ -18,10 +28,19 @@ public partial class SignInPanel
public string? ReturnUrlQueryString { get; set; }
- private const string EmailKey = nameof(EmailKey);
- private const string PhoneKey = nameof(PhoneKey);
+ protected override async Task OnAfterFirstRenderAsync()
+ {
+ isWebAuthnAvailable = await JSRuntime.IsWebAuthnAvailable() && AppPlatform.IsBlazorHybrid is false;
+ StateHasChanged();
+
+ if (await JSRuntime.IsWebAuthnConfigured())
+ {
+ await OnPasswordlessSignIn.InvokeAsync();
+ }
+
+ await base.OnAfterFirstRenderAsync();
+ }
- private string? selectedKey = EmailKey;
private async Task HandleOnPivotChange(BitPivotItem item)
{
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/IJSRuntimeWebAuthnExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/IJSRuntimeWebAuthnExtensions.cs
new file mode 100644
index 0000000000..49be599d9f
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/IJSRuntimeWebAuthnExtensions.cs
@@ -0,0 +1,37 @@
+//+:cnd:noEmit
+using Fido2NetLib;
+
+namespace Microsoft.JSInterop;
+
+public static partial class IJSRuntimeWebAuthnExtensions
+{
+ public static ValueTask IsWebAuthnAvailable(this IJSRuntime jsRuntime)
+ {
+ return jsRuntime.InvokeAsync("WebAuthn.isAvailable");
+ }
+
+ public static ValueTask StoreWebAuthnConfigured(this IJSRuntime jsRuntime, string username)
+ {
+ return jsRuntime.InvokeVoidAsync("WebAuthn.storeConfigured", username);
+ }
+
+ public static ValueTask IsWebAuthnConfigured(this IJSRuntime jsRuntime, string? username = null)
+ {
+ return jsRuntime.InvokeAsync("WebAuthn.isConfigured", username);
+ }
+
+ public static ValueTask RemoveWebAuthnConfigured(this IJSRuntime jsRuntime, string username)
+ {
+ return jsRuntime.InvokeVoidAsync("WebAuthn.removeConfigured", username);
+ }
+
+ public static ValueTask CreateWebAuthnCredential(this IJSRuntime jsRuntime, CredentialCreateOptions options)
+ {
+ return jsRuntime.InvokeAsync("WebAuthn.createCredential", options);
+ }
+
+ public static ValueTask VerifyWebAuthnCredential(this IJSRuntime jsRuntime, AssertionOptions options)
+ {
+ return jsRuntime.InvokeAsync("WebAuthn.verifyCredential", options);
+ }
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebAuthn.ts b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebAuthn.ts
new file mode 100644
index 0000000000..d3bb2b27db
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebAuthn.ts
@@ -0,0 +1,105 @@
+class WebAuthn {
+ private static STORE_KEY = 'configured-webauthn';
+
+ public static isAvailable() {
+ return !!window.PublicKeyCredential;
+ }
+
+ public static storeConfigured(username: string) {
+ const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
+ storedCredentials.push(username);
+ localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(storedCredentials));
+ }
+
+ public static isConfigured(username: string | undefined) {
+ const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
+ return !!username ? storedCredentials.includes(username) : storedCredentials.length > 0;
+ }
+
+ public static removeConfigured(username: string) {
+ const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
+ localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(storedCredentials.filter(c => c !== username)));
+ }
+
+
+ public static async createCredential(options: PublicKeyCredentialCreationOptions) {
+ if (typeof options.challenge === 'string') {
+ options.challenge = WebAuthn.urlToArray(options.challenge);
+ }
+
+ if (typeof options.user.id === 'string') {
+ options.user.id = WebAuthn.urlToArray(options.user.id);
+ }
+
+ if (options.rp.id === null) {
+ options.rp.id = undefined;
+ }
+
+ for (let cred of options.excludeCredentials || []) {
+ if (typeof cred.id !== 'string') continue;
+
+ cred.id = WebAuthn.urlToArray(cred.id);
+ }
+
+ const credential = await navigator.credentials.create({ publicKey: options }) as PublicKeyCredential;
+ const response = credential.response as AuthenticatorAttestationResponse;
+
+ return {
+ id: WebAuthn.base64ToUrl(credential.id),
+ rawId: WebAuthn.arrayToUrl(credential.rawId),
+ type: credential.type,
+ clientExtensionResults: credential.getClientExtensionResults(),
+ response: {
+ attestationObject: WebAuthn.arrayToUrl(response.attestationObject),
+ clientDataJSON: WebAuthn.arrayToUrl(response.clientDataJSON),
+ transports: response.getTransports ? response.getTransports() : []
+ }
+ };
+ }
+
+ public static async verifyCredential(options: PublicKeyCredentialRequestOptions) {
+ if (typeof options.challenge === 'string') {
+ options.challenge = WebAuthn.urlToArray(options.challenge);
+ }
+
+ if (options.allowCredentials) {
+ for (let i = 0; i < options.allowCredentials.length; i++) {
+ const id = options.allowCredentials[i].id;
+ if (typeof id === 'string') {
+ options.allowCredentials[i].id = WebAuthn.urlToArray(id);
+ }
+ }
+ }
+ const credential = await navigator.credentials.get({ publicKey: options }) as PublicKeyCredential;
+ const response = credential.response as AuthenticatorAssertionResponse;
+
+ return {
+ id: credential.id,
+ rawId: WebAuthn.arrayToUrl(credential.rawId),
+ type: credential.type,
+ clientExtensionResults: credential.getClientExtensionResults(),
+ response: {
+ authenticatorData: WebAuthn.arrayToUrl(response.authenticatorData),
+ clientDataJSON: WebAuthn.arrayToUrl(response.clientDataJSON),
+ userHandle: response.userHandle && response.userHandle.byteLength > 0 ? WebAuthn.arrayToUrl(response.userHandle) : undefined,
+ signature: WebAuthn.arrayToUrl(response.signature)
+ }
+ }
+ }
+
+
+
+ private static arrayToUrl(value: ArrayBuffer): string {
+ return btoa(String.fromCharCode(...new Uint8Array(value))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, "");
+ }
+
+ private static urlToArray(value: string): Uint8Array {
+ return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
+ }
+
+ private static base64ToUrl(value: string): string {
+ return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, "");
+ }
+}
+
+(window as any).WebAuthn = WebAuthn;
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/app.ts b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/app.ts
index 317f5f45bd..0a5c0134e6 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/app.ts
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/app.ts
@@ -35,7 +35,7 @@ class App {
return;
}
- var script = document.createElement('script');
+ const script = document.createElement('script');
script.src = "https://cdn.jsdelivr.net/npm/eruda";
document.body.append(script);
script.onload = function () {
@@ -76,6 +76,8 @@ class App {
//#endif
}
+(window as any).App = App;
+
window.addEventListener('message', handleMessage);
window.addEventListener('load', handleLoad);
window.addEventListener('resize', setCssWindowSizes);
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/wwwroot/images/bit-logo.png b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/wwwroot/images/bit-logo.png
new file mode 100644
index 0000000000..1629cbe86b
Binary files /dev/null and b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/wwwroot/images/bit-logo.png differ
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props
index bbe36321a6..b60c1045d1 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props
@@ -9,6 +9,8 @@
+
+
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props
index d0cf5f259c..a5ff3d6d3f 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props
@@ -9,6 +9,8 @@
+
+
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj
index d3829c84d8..3808572e0d 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj
@@ -15,6 +15,7 @@
+
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs
new file mode 100644
index 0000000000..ba63186b45
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs
@@ -0,0 +1,104 @@
+//+:cnd:noEmit
+using System.Text;
+using Microsoft.Extensions.Caching.Distributed;
+using Fido2NetLib;
+using Fido2NetLib.Objects;
+using Boilerplate.Shared.Dtos.Identity;
+using Boilerplate.Server.Api.Models.Identity;
+
+namespace Boilerplate.Server.Api.Controllers.Identity;
+
+public partial class IdentityController
+{
+ [AutoInject] private IFido2 fido2 = default!;
+ [AutoInject] private IDistributedCache cache = default!;
+ [AutoInject] protected JsonSerializerOptions jsonSerializerOptions = default!;
+
+
+ [HttpGet]
+ public async Task GetWebAuthnAssertionOptions(CancellationToken cancellationToken)
+ {
+ var existingKeys = new List();
+
+ var extensions = new AuthenticationExtensionsClientInputs
+ {
+ UserVerificationMethod = true,
+ Extensions = true
+ };
+
+ var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams
+ {
+ Extensions = extensions,
+ AllowedCredentials = existingKeys,
+ UserVerification = UserVerificationRequirement.Discouraged,
+ });
+
+ var key = new string([.. options.Challenge.Select(b => (char)b)]);
+ await cache.SetAsync(key, Encoding.UTF8.GetBytes(options.ToJson()), cancellationToken);
+
+ return options;
+ }
+
+ [HttpPost]
+ public async Task VerifyWebAuthAssertion(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken)
+ {
+ var (verifyResult, _) = await Verify(clientResponse, cancellationToken);
+
+ return verifyResult;
+ }
+
+ [HttpPost, Produces()]
+ public async Task VerifyWebAuthAndSignIn(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken)
+ {
+ var (verifyResult, credential) = await Verify(clientResponse, cancellationToken);
+
+ var user = await userManager.FindByIdAsync(credential.UserId.ToString())
+ ?? throw new ResourceNotFoundException();
+
+ var (otp, _) = await GenerateAutomaticSignInLink(user, null, "WebAuthn");
+
+ credential.SignCount = verifyResult.SignCount;
+
+ DbContext.WebAuthnCredential.Update(credential);
+
+ await DbContext.SaveChangesAsync(cancellationToken);
+
+ await SignIn(new() { Otp = otp }, user, cancellationToken);
+ }
+
+
+ private async Task<(VerifyAssertionResult, WebAuthnCredential)> Verify(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken)
+ {
+ var response = JsonSerializer.Deserialize(clientResponse.Response.ClientDataJson, jsonSerializerOptions.GetTypeInfo())
+ ?? throw new InvalidOperationException("Invalid client data.");
+
+ var key = new string([.. response.Challenge.Select(b => (char)b)]);
+ var cachedBytes = await cache.GetAsync(key, cancellationToken)
+ ?? throw new ResourceNotFoundException();
+
+ var jsonOptions = Encoding.UTF8.GetString(cachedBytes);
+ var options = AssertionOptions.FromJson(jsonOptions);
+
+ await cache.RemoveAsync(key, cancellationToken);
+
+ var credential = (await DbContext.WebAuthnCredential.FirstOrDefaultAsync(c => c.Id == clientResponse.Id, cancellationToken))
+ ?? throw new ResourceNotFoundException();
+
+ var verifyResult = await fido2.MakeAssertionAsync(new MakeAssertionParams
+ {
+ AssertionResponse = clientResponse,
+ OriginalOptions = options,
+ StoredPublicKey = credential.PublicKey!,
+ StoredSignatureCounter = credential.SignCount,
+ IsUserHandleOwnerOfCredentialIdCallback = IsUserHandleOwnerOfCredentialId
+ }, cancellationToken);
+
+ return (verifyResult, credential);
+ }
+
+ private async Task IsUserHandleOwnerOfCredentialId(IsUserHandleOwnerOfCredentialIdParams args, CancellationToken cancellationToken)
+ {
+ var storedCreds = await DbContext.WebAuthnCredential.Where(c => c.UserHandle == args.UserHandle).ToListAsync(cancellationToken);
+ return storedCreds.Exists(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, c.Id, c.Transports).Id.SequenceEqual(args.CredentialId));
+ }
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs
index 573dabddf0..353d70da57 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs
@@ -91,9 +91,16 @@ public async Task SignUp(SignUpRequestDto request, CancellationToken cancellatio
public async Task SignIn(SignInRequestDto request, CancellationToken cancellationToken)
{
request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber);
- signInManager.AuthenticationScheme = IdentityConstants.BearerScheme;
- var user = await userManager.FindUserAsync(request) ?? throw new UnauthorizedException(Localizer[nameof(AppStrings.InvalidUserCredentials)]).WithData("Identifier", request);
+ var user = await userManager.FindUserAsync(request)
+ ?? throw new UnauthorizedException(Localizer[nameof(AppStrings.InvalidUserCredentials)]).WithData("Identifier", request);
+
+ await SignIn(request, user, cancellationToken);
+ }
+
+ private async Task SignIn(SignInRequestDto request, User user, CancellationToken cancellationToken)
+ {
+ signInManager.AuthenticationScheme = IdentityConstants.BearerScheme;
var userSession = await CreateUserSession(user.Id, request.DeviceInfo, cancellationToken);
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.WebAuthn.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.WebAuthn.cs
new file mode 100644
index 0000000000..51230b855a
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.WebAuthn.cs
@@ -0,0 +1,124 @@
+//+:cnd:noEmit
+using System.Text;
+using Microsoft.Extensions.Caching.Distributed;
+using Fido2NetLib;
+using Fido2NetLib.Objects;
+using Boilerplate.Shared.Dtos.Identity;
+using Boilerplate.Server.Api.Models.Identity;
+
+namespace Boilerplate.Server.Api.Controllers.Identity;
+
+public partial class UserController
+{
+ [AutoInject] private IFido2 fido2 = default!;
+ [AutoInject] private IDistributedCache cache = default!;
+
+
+ [HttpGet]
+ public async Task GetWebAuthnCredentialOptions(CancellationToken cancellationToken)
+ {
+ var userId = User.GetUserId();
+ var user = await userManager.FindByIdAsync(userId.ToString())
+ ?? throw new ResourceNotFoundException();
+
+ var existingCredentials = DbContext.WebAuthnCredential.Where(c => c.UserId == userId);
+ var existingKeys = existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey,
+ c.Id,
+ c.Transports));
+ var fidoUser = new Fido2User
+ {
+ Id = Encoding.UTF8.GetBytes(userId.ToString()),
+ Name = user.DisplayUserName,
+ DisplayName = user.DisplayName,
+ };
+
+ var options = fido2.RequestNewCredential(new RequestNewCredentialParams
+ {
+ User = fidoUser,
+ ExcludeCredentials = [.. existingKeys],
+ AuthenticatorSelection = AuthenticatorSelection.Default,
+ AttestationPreference = AttestationConveyancePreference.None,
+ Extensions = new AuthenticationExtensionsClientInputs
+ {
+ CredProps = true,
+ Extensions = true,
+ UserVerificationMethod = true,
+ }
+ });
+
+ var key = GetWebAuthnCacheKey(userId);
+ await cache.SetAsync(key, Encoding.UTF8.GetBytes(options.ToJson()), cancellationToken);
+
+ return options;
+ }
+
+ [HttpPut]
+ public async Task CreateWebAuthnCredential(AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken)
+ {
+ var userId = User.GetUserId();
+ var user = await userManager.FindByIdAsync(userId.ToString())
+ ?? throw new ResourceNotFoundException();
+
+ var key = GetWebAuthnCacheKey(userId);
+ var cachedBytes = await cache.GetAsync(key, cancellationToken)
+ ?? throw new ResourceNotFoundException();
+
+ var jsonOptions = Encoding.UTF8.GetString(cachedBytes);
+ var options = CredentialCreateOptions.FromJson(jsonOptions);
+
+ var makeCredentialParams = new MakeNewCredentialParams
+ {
+ AttestationResponse = attestationResponse,
+ OriginalOptions = options,
+ IsCredentialIdUniqueToUserCallback = IsCredentialIdUniqueToUser
+ };
+
+ var credential = await fido2.MakeNewCredentialAsync(makeCredentialParams, cancellationToken);
+
+ var newCredential = new WebAuthnCredential
+ {
+ UserId = userId,
+ Id = credential.Id,
+ PublicKey = credential.PublicKey,
+ UserHandle = credential.User.Id,
+ SignCount = credential.SignCount,
+ RegDate = DateTimeOffset.UtcNow,
+ AaGuid = credential.AaGuid,
+ Transports = credential.Transports,
+ AttestationFormat = credential.AttestationFormat,
+ IsBackupEligible = credential.IsBackupEligible,
+ IsBackedUp = credential.IsBackedUp,
+ AttestationObject = credential.AttestationObject,
+ AttestationClientDataJson = credential.AttestationClientDataJson,
+ };
+
+ await DbContext.WebAuthnCredential.AddAsync(newCredential, cancellationToken);
+
+ await cache.RemoveAsync(key, cancellationToken);
+
+ await DbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ [HttpDelete]
+ public async Task DeleteWebAuthnCredential(byte[] credentialId, CancellationToken cancellationToken)
+ {
+ var userId = User.GetUserId();
+ var user = await userManager.FindByIdAsync(userId.ToString())
+ ?? throw new ResourceNotFoundException();
+
+ var entityToDelete = await DbContext.WebAuthnCredential.FindAsync([credentialId], cancellationToken)
+ ?? throw new ResourceNotFoundException();
+
+ DbContext.WebAuthnCredential.Remove(entityToDelete);
+
+ await DbContext.SaveChangesAsync(cancellationToken);
+ }
+
+ private static string GetWebAuthnCacheKey(Guid userId) => $"WebAuthn_Options_{userId}";
+
+ private async Task IsCredentialIdUniqueToUser(IsCredentialIdUniqueToUserParams args, CancellationToken cancellationToken)
+ {
+ var count = await DbContext.WebAuthnCredential.CountAsync(c => c.Id == args.CredentialId, cancellationToken);
+ return count <= 0;
+ }
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/AppDbContext.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/AppDbContext.cs
index aa746d94f2..9822b89e63 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/AppDbContext.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/AppDbContext.cs
@@ -37,6 +37,8 @@ public partial class AppDbContext(DbContextOptions options)
public DbSet PushNotificationSubscriptions { get; set; } = default!;
//#endif
+ public DbSet WebAuthnCredential { get; set; } = default!;
+
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Configurations/Identity/WebAuthnCredentialConfiguration.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Configurations/Identity/WebAuthnCredentialConfiguration.cs
new file mode 100644
index 0000000000..c0469f13ea
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Configurations/Identity/WebAuthnCredentialConfiguration.cs
@@ -0,0 +1,14 @@
+using Boilerplate.Server.Api.Models.Identity;
+
+namespace Boilerplate.Server.Api.Data.Configurations.Identity;
+
+public class WebAuthnCredentialConfiguration : IEntityTypeConfiguration
+{
+ public void Configure(EntityTypeBuilder builder)
+ {
+ builder.HasOne(t => t.User)
+ .WithMany(u => u.WebAuthnCredentials)
+ .HasForeignKey(t => t.UserId)
+ .OnDelete(DeleteBehavior.Cascade);
+ }
+}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250303200825_InitialMigration.Designer.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.Designer.cs
similarity index 98%
rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250303200825_InitialMigration.Designer.cs
rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.Designer.cs
index 76d018c1e7..51f46b9bcd 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250303200825_InitialMigration.Designer.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.Designer.cs
@@ -11,7 +11,7 @@
namespace Boilerplate.Server.Api.Data.Migrations;
[DbContext(typeof(AppDbContext))]
-[Migration("20250303200825_InitialMigration")]
+[Migration("20250311112214_InitialMigration")]
partial class InitialMigration
{
///
@@ -274,6 +274,54 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.ToTable("UserSessions");
});
+ modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.WebAuthnCredential", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("BLOB");
+
+ b.Property("AaGuid")
+ .HasColumnType("TEXT");
+
+ b.Property("AttestationClientDataJson")
+ .HasColumnType("BLOB");
+
+ b.Property("AttestationFormat")
+ .HasColumnType("TEXT");
+
+ b.Property("AttestationObject")
+ .HasColumnType("BLOB");
+
+ b.Property("IsBackedUp")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsBackupEligible")
+ .HasColumnType("INTEGER");
+
+ b.Property("PublicKey")
+ .HasColumnType("BLOB");
+
+ b.Property("RegDate")
+ .HasColumnType("INTEGER");
+
+ b.Property("SignCount")
+ .HasColumnType("INTEGER");
+
+ b.PrimitiveCollection("Transports")
+ .HasColumnType("TEXT");
+
+ b.Property("UserHandle")
+ .HasColumnType("BLOB");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("WebAuthnCredential");
+ });
+
modelBuilder.Entity("Boilerplate.Server.Api.Models.Products.Product", b =>
{
b.Property("Id")
@@ -2039,6 +2087,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.Navigation("User");
});
+ modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.WebAuthnCredential", b =>
+ {
+ b.HasOne("Boilerplate.Server.Api.Models.Identity.User", "User")
+ .WithMany("WebAuthnCredentials")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
modelBuilder.Entity("Boilerplate.Server.Api.Models.Products.Product", b =>
{
b.HasOne("Boilerplate.Server.Api.Models.Categories.Category", "Category")
@@ -2132,6 +2191,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.Navigation("Sessions");
b.Navigation("TodoItems");
+
+ b.Navigation("WebAuthnCredentials");
});
modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.UserSession", b =>
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250303200825_InitialMigration.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.cs
similarity index 96%
rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250303200825_InitialMigration.cs
rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.cs
index 94f794a3f2..a714ff1cc4 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250303200825_InitialMigration.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.cs
@@ -264,6 +264,35 @@ protected override void Up(MigrationBuilder migrationBuilder)
onDelete: ReferentialAction.Cascade);
});
+ migrationBuilder.CreateTable(
+ name: "WebAuthnCredential",
+ columns: table => new
+ {
+ Id = table.Column(type: "BLOB", nullable: false),
+ UserId = table.Column(type: "TEXT", nullable: false),
+ PublicKey = table.Column(type: "BLOB", nullable: true),
+ SignCount = table.Column(type: "INTEGER", nullable: false),
+ Transports = table.Column(type: "TEXT", nullable: true),
+ IsBackupEligible = table.Column(type: "INTEGER", nullable: false),
+ IsBackedUp = table.Column(type: "INTEGER", nullable: false),
+ AttestationObject = table.Column(type: "BLOB", nullable: true),
+ AttestationClientDataJson = table.Column(type: "BLOB", nullable: true),
+ UserHandle = table.Column(type: "BLOB", nullable: true),
+ AttestationFormat = table.Column(type: "TEXT", nullable: true),
+ RegDate = table.Column(type: "INTEGER", nullable: false),
+ AaGuid = table.Column(type: "TEXT", nullable: false)
+ },
+ constraints: table =>
+ {
+ table.PrimaryKey("PK_WebAuthnCredential", x => x.Id);
+ table.ForeignKey(
+ name: "FK_WebAuthnCredential_Users_UserId",
+ column: x => x.UserId,
+ principalTable: "Users",
+ principalColumn: "Id",
+ onDelete: ReferentialAction.Cascade);
+ });
+
migrationBuilder.CreateTable(
name: "PushNotificationSubscriptions",
columns: table => new
@@ -542,6 +571,11 @@ protected override void Up(MigrationBuilder migrationBuilder)
name: "IX_UserSessions_UserId",
table: "UserSessions",
column: "UserId");
+
+ migrationBuilder.CreateIndex(
+ name: "IX_WebAuthnCredential_UserId",
+ table: "WebAuthnCredential",
+ column: "UserId");
}
///
@@ -574,6 +608,9 @@ protected override void Down(MigrationBuilder migrationBuilder)
migrationBuilder.DropTable(
name: "UserTokens");
+ migrationBuilder.DropTable(
+ name: "WebAuthnCredential");
+
migrationBuilder.DropTable(
name: "Categories");
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/AppDbContextModelSnapshot.cs
index c0f6033569..63e8f74eea 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/AppDbContextModelSnapshot.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/AppDbContextModelSnapshot.cs
@@ -271,6 +271,54 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("UserSessions");
});
+ modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.WebAuthnCredential", b =>
+ {
+ b.Property("Id")
+ .HasColumnType("BLOB");
+
+ b.Property("AaGuid")
+ .HasColumnType("TEXT");
+
+ b.Property("AttestationClientDataJson")
+ .HasColumnType("BLOB");
+
+ b.Property("AttestationFormat")
+ .HasColumnType("TEXT");
+
+ b.Property("AttestationObject")
+ .HasColumnType("BLOB");
+
+ b.Property("IsBackedUp")
+ .HasColumnType("INTEGER");
+
+ b.Property("IsBackupEligible")
+ .HasColumnType("INTEGER");
+
+ b.Property("PublicKey")
+ .HasColumnType("BLOB");
+
+ b.Property("RegDate")
+ .HasColumnType("INTEGER");
+
+ b.Property("SignCount")
+ .HasColumnType("INTEGER");
+
+ b.PrimitiveCollection("Transports")
+ .HasColumnType("TEXT");
+
+ b.Property("UserHandle")
+ .HasColumnType("BLOB");
+
+ b.Property("UserId")
+ .HasColumnType("TEXT");
+
+ b.HasKey("Id");
+
+ b.HasIndex("UserId");
+
+ b.ToTable("WebAuthnCredential");
+ });
+
modelBuilder.Entity("Boilerplate.Server.Api.Models.Products.Product", b =>
{
b.Property("Id")
@@ -2036,6 +2084,17 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("User");
});
+ modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.WebAuthnCredential", b =>
+ {
+ b.HasOne("Boilerplate.Server.Api.Models.Identity.User", "User")
+ .WithMany("WebAuthnCredentials")
+ .HasForeignKey("UserId")
+ .OnDelete(DeleteBehavior.Cascade)
+ .IsRequired();
+
+ b.Navigation("User");
+ });
+
modelBuilder.Entity("Boilerplate.Server.Api.Models.Products.Product", b =>
{
b.HasOne("Boilerplate.Server.Api.Models.Categories.Category", "Category")
@@ -2129,6 +2188,8 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("Sessions");
b.Navigation("TodoItems");
+
+ b.Navigation("WebAuthnCredentials");
});
modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.UserSession", b =>
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Extensions/SignInManagerExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Extensions/SignInManagerExtensions.cs
index bdfcc0a7d2..174ebc4e0d 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Extensions/SignInManagerExtensions.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Extensions/SignInManagerExtensions.cs
@@ -16,8 +16,9 @@ public static partial class SignInManagerExtensions
/// 4. After a successful phone number confirmation after sign-up, to automatically sign in the confirmed user for a smoother user experience.
/// 5. When the browser is redirected to a magic link created after a social sign-in, to automatically authenticate the user.
/// 6. When the user opts to sign in using a 6-digit code delivered via native push notification, web push or SignalR message (if configured).
+ /// 7. When the system opts to sign in the user using a 6-digit code generated after a successful WebAuthn process.
///
- /// It's important to clarify the authentication method (e.g., Social, SMS, Email, or Push)
+ /// It's important to clarify the authentication method (e.g., Social, Email, SMS, Push, Social, or WebAuth)
/// to avoid sending a second step to the same communication channel: For successful two-step authentication, the user must use a different method for the second step.
///
@@ -38,12 +39,15 @@ public static partial class SignInManagerExtensions
bool tokenIsValid = false;
string? authenticationMethod = null;
- string[] authenticationMethods = ["Email",
+ string[] authenticationMethods = [
+ "Email",
"Sms",
//#if (notification == true || signalR == true)
"Push", // => Native push notification, web push or SignalR message.
//#endif
- "Social"];
+ "Social",
+ "WebAuthn"
+ ];
foreach (var authMethod in authenticationMethods)
{
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/User.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/User.cs
index 191557d1cc..f75d78cb6a 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/User.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/User.cs
@@ -10,7 +10,8 @@ public partial class User : IdentityUser
[PersonalData]
public string? FullName { get; set; }
- public string? DisplayName => FullName ?? Email ?? PhoneNumber ?? UserName;
+ public string? DisplayName => FullName ?? DisplayUserName;
+ public string? DisplayUserName => FullName ?? Email ?? PhoneNumber ?? UserName;
[PersonalData]
public Gender? Gender { get; set; }
@@ -44,4 +45,6 @@ public partial class User : IdentityUser
//#if (sample == true)
public List TodoItems { get; set; } = [];
//#endif
+
+ public List WebAuthnCredentials { get; set; } = [];
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/WebAuthnCredential.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/WebAuthnCredential.cs
new file mode 100644
index 0000000000..a44563d2cc
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/WebAuthnCredential.cs
@@ -0,0 +1,35 @@
+using Fido2NetLib.Objects;
+
+namespace Boilerplate.Server.Api.Models.Identity;
+
+public class WebAuthnCredential
+{
+ public required byte[] Id { get; set; }
+ public Guid UserId { get; set; }
+
+ [ForeignKey(nameof(UserId))]
+ public User? User { get; set; }
+
+ public byte[]? PublicKey { get; set; }
+
+ public uint SignCount { get; set; }
+
+ public AuthenticatorTransport[]? Transports { get; set; }
+
+ public bool IsBackupEligible { get; set; }
+
+ public bool IsBackedUp { get; set; }
+
+ public byte[]? AttestationObject { get; set; }
+
+ public byte[]? AttestationClientDataJson { get; set; }
+
+ public byte[]? UserHandle { get; set; }
+
+ public string? AttestationFormat { get; set; }
+
+ public DateTimeOffset RegDate { get; set; }
+
+ public Guid AaGuid { get; set; }
+}
+
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs
index 72f623f994..b6cdbdf3f8 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs
@@ -298,6 +298,24 @@ void AddDbContext(DbContextOptionsBuilder options)
c.Timeout = TimeSpan.FromSeconds(10);
c.BaseAddress = new Uri("https://api.cloudflare.com/client/v4/zones/");
});
+
+ services.AddFido2(options =>
+ {
+ var trustedOrigins = appSettings.AllowedOrigins
+ .Union([
+ //#if (api == "Integrated")
+ new Uri("http://localhost:5030/")
+ //#else
+ , new Uri("http://localhost:5031/")
+ //#endif
+ ]);
+
+ options.TimestampDriftTolerance = 1000;
+ options.ServerName = "Boilerplate WebAuthn";
+ options.ServerDomain = trustedOrigins.First().Host;
+ options.Origins = new HashSet(trustedOrigins.Select(uri => uri.AbsoluteUri));
+ options.ServerIcon = "data:image/png;base64,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";
+ });
}
private static void AddIdentity(WebApplicationBuilder builder)
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/GoogleRecaptchaService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/GoogleRecaptchaService.cs
index 87098fc6ed..7cc93f9085 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/GoogleRecaptchaService.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/GoogleRecaptchaService.cs
@@ -6,6 +6,8 @@ public partial class GoogleRecaptchaService
[AutoInject] protected HttpClient httpClient = default!;
+ [AutoInject] protected JsonSerializerOptions jsonSerializerOptions = default!;
+
public virtual async ValueTask Verify(string? googleRecaptchaResponse, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(googleRecaptchaResponse)) return false;
@@ -15,7 +17,7 @@ public virtual async ValueTask Verify(string? googleRecaptchaResponse, Can
response.EnsureSuccessStatusCode();
- var result = await response.Content.ReadFromJsonAsync(ServerJsonContext.Default.GoogleRecaptchaVerificationResponse, cancellationToken);
+ var result = await response.Content.ReadFromJsonAsync(jsonSerializerOptions.GetTypeInfo(), cancellationToken);
return result?.Success is true;
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/NugetStatisticsService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/NugetStatisticsService.cs
index fe032f9aae..a34c1cfe0c 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/NugetStatisticsService.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/NugetStatisticsService.cs
@@ -5,12 +5,13 @@ namespace Boilerplate.Server.Api.Services;
public partial class NugetStatisticsService
{
[AutoInject] protected HttpClient httpClient = default!;
+ [AutoInject] protected JsonSerializerOptions jsonSerializerOptions = default!;
public virtual async ValueTask GetPackageStats(string packageId, CancellationToken cancellationToken)
{
var url = $"/query?q=packageid:{packageId}";
- var response = await httpClient.GetFromJsonAsync(url, ServerJsonContext.Default.Options.GetTypeInfo(), cancellationToken)
+ var response = await httpClient.GetFromJsonAsync(url, jsonSerializerOptions.GetTypeInfo(), cancellationToken)
?? throw new ResourceNotFoundException();
return response;
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/ServerJsonContext.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/ServerJsonContext.cs
index 2edc8f93ad..826d70cc16 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/ServerJsonContext.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/ServerJsonContext.cs
@@ -1,4 +1,5 @@
//+:cnd:noEmit
+using Fido2NetLib;
using Boilerplate.Shared.Dtos.Statistics;
namespace Boilerplate.Server.Api.Services;
@@ -12,6 +13,7 @@ namespace Boilerplate.Server.Api.Services;
[JsonSerializable(typeof(GoogleRecaptchaVerificationResponse))]
//#endif
[JsonSerializable(typeof(ProblemDetails))]
+[JsonSerializable(typeof(AuthenticatorResponse))]
public partial class ServerJsonContext : JsonSerializerContext
{
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/Services/WebServerExceptionHandler.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/Services/WebServerExceptionHandler.cs
index 6ae9d33dc8..67a8fe1aac 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/Services/WebServerExceptionHandler.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/Services/WebServerExceptionHandler.cs
@@ -10,6 +10,11 @@ public partial class WebServerExceptionHandler : ClientExceptionHandlerBase
protected override void Handle(Exception exception, ExceptionDisplayKind displayKind, Dictionary parameters)
{
+ exception = UnWrapException(exception);
+
+ if (IgnoreException(exception))
+ return;
+
if (httpContextAccessor.HttpContext is not null && httpContextAccessor.HttpContext.Response.HasStarted is false)
{
// This method is invoked for exceptions occurring in Blazor Server and during pre-rendering.
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Boilerplate.Shared.csproj b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Boilerplate.Shared.csproj
index cad31eb752..d411716bf5 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Boilerplate.Shared.csproj
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Boilerplate.Shared.csproj
@@ -6,6 +6,7 @@
+
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs
index 653e263da7..81ac52e4c2 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs
@@ -1,4 +1,6 @@
//+:cnd:noEmit
+using Fido2NetLib;
+using Fido2NetLib.Objects;
using Boilerplate.Shared.Dtos.Identity;
namespace Boilerplate.Shared.Controllers.Identity;
@@ -45,4 +47,13 @@ public interface IIdentityController : IAppController
[HttpGet("{?provider,returnUrl,localHttpPort}")]
Task GetSocialSignInUri(string provider, string? returnUrl = null, int? localHttpPort = null, CancellationToken cancellationToken = default);
+
+ [HttpGet]
+ Task GetWebAuthnAssertionOptions(CancellationToken cancellationToken);
+
+ [HttpPost]
+ Task VerifyWebAuthAssertion(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken);
+
+ [HttpPost]
+ Task VerifyWebAuthAndSignIn(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken) => default!;
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IUserController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IUserController.cs
index 9796336df9..720a75d8b6 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IUserController.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IUserController.cs
@@ -1,4 +1,5 @@
-using Boilerplate.Shared.Dtos.Identity;
+using Fido2NetLib;
+using Boilerplate.Shared.Dtos.Identity;
namespace Boilerplate.Shared.Controllers.Identity;
@@ -47,4 +48,13 @@ public interface IUserController : IAppController
[HttpPost]
Task SendElevatedAccessToken(CancellationToken cancellationToken);
+
+ [HttpGet]
+ Task GetWebAuthnCredentialOptions(CancellationToken cancellationToken);
+
+ [HttpPut]
+ Task CreateWebAuthnCredential(AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken);
+
+ [HttpDelete]
+ Task DeleteWebAuthnCredential(byte[] credentialId, CancellationToken cancellationToken);
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs
index cd671f406d..43783c773a 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs
@@ -1,4 +1,6 @@
//+:cnd:noEmit
+using Fido2NetLib;
+using Fido2NetLib.Objects;
//#if (sample == true)
using Boilerplate.Shared.Dtos.Todo;
//#endif
@@ -48,6 +50,11 @@ namespace Boilerplate.Shared.Dtos;
[JsonSerializable(typeof(OverallAnalyticsStatsDataResponseDto))]
[JsonSerializable(typeof(List))]
//#endif
+[JsonSerializable(typeof(AssertionOptions))]
+[JsonSerializable(typeof(AuthenticatorAssertionRawResponse))]
+[JsonSerializable(typeof(AuthenticatorAttestationRawResponse))]
+[JsonSerializable(typeof(CredentialCreateOptions))]
+[JsonSerializable(typeof(VerifyAssertionResult))]
public partial class AppJsonContext : JsonSerializerContext
{
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/UserDto.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/UserDto.cs
index 64f7c06cd2..145f5bced2 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/UserDto.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/UserDto.cs
@@ -35,7 +35,8 @@ public partial class UserDto : IValidatableObject
public string? ConcurrencyStamp { get; set; }
- public string? DisplayName => FullName ?? Email ?? PhoneNumber ?? UserName;
+ public string? DisplayName => FullName ?? DisplayUserName;
+ public string? DisplayUserName => FullName ?? Email ?? PhoneNumber ?? UserName;
public string? GetProfileImageUrl(Uri absoluteServerAddress)
{
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx
index c9bff9c46a..b6917afe49 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx
@@ -526,7 +526,7 @@
حساب کاربری
- ایمیل یا شماره تلفن حساب کاربری خود را تغییر دهید
+ ایمیل، شماره تلفن یا تنظیمات بی رمز حساب کاربری خود را تغییر دهید
حذف حساب
@@ -1210,4 +1210,22 @@
محصولات در همین دسته
+
+ بی رمز
+
+
+ ورود بی رمز
+
+
+ فعالسازی ورود بی رمز
+
+
+ ورود بی رمز با موفقیت فعال شد
+
+
+ غیرفعالسازی ورود بی رمز
+
+
+ ورود بی رمز با موفقیت غیرفعال شد
+
\ No newline at end of file
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx
index a8017fb3ae..b5820313ce 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx
@@ -526,7 +526,7 @@
Account
- Het e-mailadres of telefoonnummer van je account wijzigen
+ Wijzig uw account-e-mailadres, telefoonnummer of wachtwoordloze instellingen
Account verwijderen
@@ -1210,4 +1210,22 @@
Producten in dezelfde categorie
+
+ Wachtwoordloos
+
+
+ Wachtwoordloos inloggen
+
+
+ Wachtwoordloos inloggen inschakelen
+
+
+ Wachtwoordloos inloggen succesvol ingeschakeld
+
+
+ Wachtwoordloos inloggen uitschakelen
+
+
+ Wachtwoordloos inloggen succesvol uitgeschakeld
+
\ No newline at end of file
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx
index f70fca4b32..85a9c5bb43 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx
@@ -526,7 +526,7 @@
Account
- Change your account email or phone number
+ Change your account email, phone number, or passwordless settings
Delete Account
@@ -1210,4 +1210,22 @@
Products in the same category
+
+ Passwordless
+
+
+ Passwordless login
+
+
+ Enable passwordless login
+
+
+ Passwordless login enabled successfully
+
+
+ Disable passwordless login
+
+
+ Passwordless login disabled successfully
+
\ No newline at end of file
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/FakeGoogleRecaptchaService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/FakeGoogleRecaptchaService.cs
index cf2209d96e..bd54f6a57d 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/FakeGoogleRecaptchaService.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/FakeGoogleRecaptchaService.cs
@@ -4,7 +4,7 @@ namespace Boilerplate.Tests.Services;
public partial class FakeGoogleRecaptchaService : GoogleRecaptchaService
{
- public FakeGoogleRecaptchaService() : base(null, null) { }
+ public FakeGoogleRecaptchaService() : base(null, null, null) { }
public override ValueTask Verify(string? googleRecaptchaResponse, CancellationToken cancellationToken)
{