Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add WebAuthn to Boilerplate (#9922) #10225

Merged
merged 22 commits into from
Mar 11, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
<!--/+:msbuild-conditional:noEmit -->
<PackageReference Condition=" '$(appInsights)' == 'true' OR '$(appInsights)' == '' " Include="BlazorApplicationInsights" />
<PackageReference Condition=" '$(offlineDb)' == 'true' OR '$(offlineDb)' == ''" Include="Bit.Besql" />
<PackageReference Include="Fido2.Models" />
<PackageReference Condition=" '$(signalR)' == 'true' OR '$(signalR)' == ''" Include="Microsoft.AspNetCore.SignalR.Client" />
<PackageReference Condition=" '$(offlineDb)' == 'true' OR '$(offlineDb)' == ''" Include="Microsoft.EntityFrameworkCore.Sqlite" />
<PackageReference Condition=" '$(sentry)' == 'true' OR '$(sentry)' == '' " Include="Sentry.Extensions.Logging" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<BitNavPanel @bind-IsOpen="isNavPanelOpen"
@bind-IsToggled="isNavPanelToggled"
IconNavUrl="/"
Class="nav-panel"
Items="@navPanelAuthenticatedItems"
Accent="BitColor.SecondaryBackground"
IconUrl="_content/Boilerplate.Client.Core/images/bit-logo.svg" />
Expand All @@ -24,6 +25,7 @@
<BitNavPanel @bind-IsOpen="isNavPanelOpen"
@bind-IsToggled="isNavPanelToggled"
IconNavUrl="/"
Class="nav-panel"
Items="@navPanelUnAuthenticatedItems"
Accent="BitColor.SecondaryBackground"
IconUrl="_content/Boilerplate.Client.Core/images/bit-logo.svg">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,12 @@ main {
line-height: 16px;
color: $bit-color-error;
}

.nav-panel {
width: 280px;

@include lt-md {
width: 210px;
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
@inherits AppComponentBase

<section>
<BitStack FillContent HorizontalAlign="BitAlignment.Center" Class="max-width">
<BitText Typography="BitTypography.H6" Align="BitTextAlign.Center">
@Localizer[nameof(AppStrings.PasswordlessTitle)]
</BitText>
<br />
@if (isConfigured)
{
<BitButton OnClick="WrapHandled(DisablePasswordless)" Variant="BitVariant.Outline" Color="BitColor.Warning">
@Localizer[nameof(AppStrings.DisablePasswordless)]
</BitButton>
}
else
{
<BitButton OnClick="WrapHandled(EnablePasswordless)">
@Localizer[nameof(AppStrings.EnablePasswordless)]
</BitButton>
}
<br />
</BitStack>
</section>
Original file line number Diff line number Diff line change
@@ -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)]);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
section {
width: 100%;
display: flex;
justify-content: center;
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,13 +20,19 @@
Title="@Localizer[nameof(AppStrings.AccountTitle)]"
Subtitle="@Localizer[nameof(AppStrings.AccountSubtitle)]">
<BitPivot Alignment="BitAlignment.Center">
<BitPivotItem HeaderText="@Localizer[nameof(AppStrings.Email)]">
<BitPivotItem Key="@nameof(AppStrings.Email)" HeaderText="@Localizer[nameof(AppStrings.Email)]">
<ChangeEmailSection Email="@user?.Email" />
</BitPivotItem>
<BitPivotItem HeaderText="@Localizer[nameof(AppStrings.Phone)]">
<BitPivotItem Key="@nameof(AppStrings.Phone)" HeaderText="@Localizer[nameof(AppStrings.Phone)]">
<ChangePhoneNumberSection PhoneNumber="@user?.PhoneNumber" />
</BitPivotItem>
<BitPivotItem>
@if (showPasswordless)
{
<BitPivotItem Key="@nameof(AppStrings.Passwordless)" HeaderText="@Localizer[nameof(AppStrings.Passwordless)]">
<PasswordlessSection User="user" />
</BitPivotItem>
}
<BitPivotItem Key="@nameof(AppStrings.Delete)">
<Header>
<BitText Color="BitColor.Error">@Localizer[nameof(AppStrings.Delete)]</BitText>
</Header>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ public partial class SettingsPage
protected override string? Subtitle => string.Empty;


private bool showPasswordless;


[Parameter] public string? Section { get; set; }


Expand All @@ -31,6 +34,7 @@ protected override async Task OnInitAsync()
try
{
user = (await PrerenderStateService.GetValue(() => HttpClient.GetFromJsonAsync("api/User/GetCurrentUser", JsonSerializerOptions.GetTypeInfo<UserDto>(), CurrentCancellationToken)))!;
showPasswordless = await JSRuntime.IsWebAuthnAvailable() && AppPlatform.IsBlazorHybrid is false;
}
finally
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,16 +15,26 @@
{
@if (isOtpSent is false)
{
<SignInPanel IsWaiting="isWaiting" Model="model" OnSocialSignIn="SocialSignIn" OnSendOtp="SendOtp" OnTabChange="HandleOnSignInPanelTabChange" />
<SignInPanel Model="model"
IsWaiting="isWaiting"
OnSendOtp="HandleOnSendOtp"
OnSocialSignIn="HandleOnSocialSignIn"
OnTabChange="HandleOnSignInPanelTabChange"
OnPasswordlessSignIn="HandleOnPasswordlessSignIn" />
}
else
{
<OtpPanel IsWaiting="isWaiting" Model="model" OnSignIn="DoSignIn" OnResendOtp="ResendOtp" />
<OtpPanel Model="model"
OnSignIn="DoSignIn"
IsWaiting="isWaiting"
OnResendOtp="HandleOnResendOtp" />
}
}
else
{
<TfaPanel IsWaiting="isWaiting" Model="model" OnSendTfaToken="SendTfaToken" />
<TfaPanel Model="model"
IsWaiting="isWaiting"
OnSendTfaToken="HandleOnSendTfaToken" />
}
</BitStack>
</EditForm>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
//+:cnd:noEmit
using Fido2NetLib;
using Boilerplate.Shared.Dtos.Identity;
using Boilerplate.Shared.Controllers.Identity;

Expand Down Expand Up @@ -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<TimeSpan>("TryAgainIn", out var tryAgainIn)`.

SnackBarService.Error(e.Message);
}
finally
{
isWaiting = false;
}
}

private async Task HandleOnSocialSignIn(string provider)
{
try
{
Expand All @@ -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<TimeSpan>("TryAgainIn", out var tryAgainIn)`.

SnackBarService.Error(e.Message);
}
finally
Expand All @@ -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
Expand Down Expand Up @@ -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
{
Expand All @@ -204,11 +254,6 @@ private async Task SendTfaToken()
}
}

private void HandleOnSignInPanelTabChange(SignInPanelTab tab)
{
currentSignInPanelTab = tab;
}

private void CleanModel()
{
if (currentSignInPanelTab is SignInPanelTab.Email)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,20 @@
</BitStack>
</BitStack>

<BitCheckbox @bind-Value="Model.RememberMe" Label="@Localizer[nameof(AppStrings.RememberMe)]" />
<BitStack Horizontal Alignment="BitAlignment.Center">
<BitCheckbox @bind-Value="Model.RememberMe" Label="@Localizer[nameof(AppStrings.RememberMe)]" />
<BitSpacer />
@if (isWebAuthnAvailable)
{
<BitButton IconOnly
Size="BitSize.Large"
Variant="BitVariant.Text"
Color="BitColor.Tertiary"
OnClick="OnPasswordlessSignIn"
ButtonType="BitButtonType.Button"
IconName="@BitIconName.Fingerprint" />
}
</BitStack>

<BitButton IsLoading="IsWaiting" ButtonType="BitButtonType.Submit">
@Localizer[nameof(AppStrings.SignIn)]
Expand Down
Loading
Loading