Skip to content

Commit b082393

Browse files
authored
feat(templates): add WebAuthn to Boilerplate #9922 (#10225)
1 parent 0f6ceba commit b082393

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

44 files changed

+981
-49
lines changed

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Boilerplate.Client.Core.csproj

+1
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
<!--/+:msbuild-conditional:noEmit -->
2424
<PackageReference Condition=" '$(appInsights)' == 'true' OR '$(appInsights)' == '' " Include="BlazorApplicationInsights" />
2525
<PackageReference Condition=" '$(offlineDb)' == 'true' OR '$(offlineDb)' == ''" Include="Bit.Besql" />
26+
<PackageReference Include="Fido2.Models" />
2627
<PackageReference Condition=" '$(signalR)' == 'true' OR '$(signalR)' == ''" Include="Microsoft.AspNetCore.SignalR.Client" />
2728
<PackageReference Condition=" '$(offlineDb)' == 'true' OR '$(offlineDb)' == ''" Include="Microsoft.EntityFrameworkCore.Sqlite" />
2829
<PackageReference Condition=" '$(sentry)' == 'true' OR '$(sentry)' == '' " Include="Sentry.Extensions.Logging" />

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor

+2
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
<BitNavPanel @bind-IsOpen="isNavPanelOpen"
1616
@bind-IsToggled="isNavPanelToggled"
1717
IconNavUrl="/"
18+
Class="nav-panel"
1819
Items="@navPanelAuthenticatedItems"
1920
Accent="BitColor.SecondaryBackground"
2021
IconUrl="_content/Boilerplate.Client.Core/images/bit-logo.svg" />
@@ -24,6 +25,7 @@
2425
<BitNavPanel @bind-IsOpen="isNavPanelOpen"
2526
@bind-IsToggled="isNavPanelToggled"
2627
IconNavUrl="/"
28+
Class="nav-panel"
2729
Items="@navPanelUnAuthenticatedItems"
2830
Accent="BitColor.SecondaryBackground"
2931
IconUrl="_content/Boilerplate.Client.Core/images/bit-logo.svg">

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.scss

+8
Original file line numberDiff line numberDiff line change
@@ -104,4 +104,12 @@ main {
104104
line-height: 16px;
105105
color: $bit-color-error;
106106
}
107+
108+
.nav-panel {
109+
width: 280px;
110+
111+
@include lt-md {
112+
width: 210px;
113+
}
114+
}
107115
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
@inherits AppComponentBase
2+
3+
<section>
4+
<BitStack FillContent HorizontalAlign="BitAlignment.Center" Class="max-width">
5+
<BitText Typography="BitTypography.H6" Align="BitTextAlign.Center">
6+
@Localizer[nameof(AppStrings.PasswordlessTitle)]
7+
</BitText>
8+
<br />
9+
@if (isConfigured)
10+
{
11+
<BitButton OnClick="WrapHandled(DisablePasswordless)" Variant="BitVariant.Outline" Color="BitColor.Warning">
12+
@Localizer[nameof(AppStrings.DisablePasswordless)]
13+
</BitButton>
14+
}
15+
else
16+
{
17+
<BitButton OnClick="WrapHandled(EnablePasswordless)">
18+
@Localizer[nameof(AppStrings.EnablePasswordless)]
19+
</BitButton>
20+
}
21+
<br />
22+
</BitStack>
23+
</section>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
using Fido2NetLib;
2+
using Boilerplate.Shared.Dtos.Identity;
3+
using Boilerplate.Shared.Controllers.Identity;
4+
5+
namespace Boilerplate.Client.Core.Components.Pages.Authorized.Settings;
6+
7+
public partial class PasswordlessSection
8+
{
9+
private bool isConfigured;
10+
11+
12+
[AutoInject] IUserController userController = default!;
13+
[AutoInject] IIdentityController identityController = default!;
14+
15+
16+
[Parameter] public UserDto? User { get; set; }
17+
18+
protected override async Task OnParamsSetAsync()
19+
{
20+
await base.OnParamsSetAsync();
21+
22+
if (User?.UserName is null) return;
23+
24+
isConfigured = await JSRuntime.IsWebAuthnConfigured(User.UserName);
25+
}
26+
27+
28+
private async Task EnablePasswordless()
29+
{
30+
if (User?.UserName is null) return;
31+
32+
var options = await userController.GetWebAuthnCredentialOptions(CurrentCancellationToken);
33+
34+
AuthenticatorAttestationRawResponse attestationResponse;
35+
try
36+
{
37+
attestationResponse = await JSRuntime.CreateWebAuthnCredential(options);
38+
}
39+
catch (Exception ex)
40+
{
41+
// we can safely handle the exception thrown here since it mostly because of a timeout or user cancelling the native ui.
42+
ExceptionHandler.Handle(ex, ExceptionDisplayKind.None);
43+
return;
44+
}
45+
46+
await userController.CreateWebAuthnCredential(attestationResponse, CurrentCancellationToken);
47+
48+
await JSRuntime.StoreWebAuthnConfigured(User.UserName);
49+
50+
isConfigured = true;
51+
52+
SnackBarService.Success(Localizer[nameof(AppStrings.EnablePasswordlessSucsessMessage)]);
53+
}
54+
55+
private async Task DisablePasswordless()
56+
{
57+
if (User?.UserName is null) return;
58+
59+
var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken);
60+
61+
AuthenticatorAssertionRawResponse assertion;
62+
try
63+
{
64+
assertion = await JSRuntime.VerifyWebAuthnCredential(options);
65+
}
66+
catch (Exception ex)
67+
{
68+
// we can safely handle the exception thrown here since it mostly because of a timeout or user cancelling the native ui.
69+
ExceptionHandler.Handle(ex, ExceptionDisplayKind.None);
70+
return;
71+
}
72+
73+
var verifyResult = await identityController.VerifyWebAuthAssertion(assertion, CurrentCancellationToken);
74+
75+
await userController.DeleteWebAuthnCredential(assertion.Id, CurrentCancellationToken);
76+
77+
await JSRuntime.RemoveWebAuthnConfigured(User.UserName);
78+
79+
isConfigured = false;
80+
81+
SnackBarService.Success(Localizer[nameof(AppStrings.DisablePasswordlessSucsessMessage)]);
82+
}
83+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
section {
2+
width: 100%;
3+
display: flex;
4+
justify-content: center;
5+
}

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor

+9-3
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,19 @@
2020
Title="@Localizer[nameof(AppStrings.AccountTitle)]"
2121
Subtitle="@Localizer[nameof(AppStrings.AccountSubtitle)]">
2222
<BitPivot Alignment="BitAlignment.Center">
23-
<BitPivotItem HeaderText="@Localizer[nameof(AppStrings.Email)]">
23+
<BitPivotItem Key="@nameof(AppStrings.Email)" HeaderText="@Localizer[nameof(AppStrings.Email)]">
2424
<ChangeEmailSection Email="@user?.Email" />
2525
</BitPivotItem>
26-
<BitPivotItem HeaderText="@Localizer[nameof(AppStrings.Phone)]">
26+
<BitPivotItem Key="@nameof(AppStrings.Phone)" HeaderText="@Localizer[nameof(AppStrings.Phone)]">
2727
<ChangePhoneNumberSection PhoneNumber="@user?.PhoneNumber" />
2828
</BitPivotItem>
29-
<BitPivotItem>
29+
@if (showPasswordless)
30+
{
31+
<BitPivotItem Key="@nameof(AppStrings.Passwordless)" HeaderText="@Localizer[nameof(AppStrings.Passwordless)]">
32+
<PasswordlessSection User="user" />
33+
</BitPivotItem>
34+
}
35+
<BitPivotItem Key="@nameof(AppStrings.Delete)">
3036
<Header>
3137
<BitText Color="BitColor.Error">@Localizer[nameof(AppStrings.Delete)]</BitText>
3238
</Header>

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor.cs

+4
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ public partial class SettingsPage
1010
protected override string? Subtitle => string.Empty;
1111

1212

13+
private bool showPasswordless;
14+
15+
1316
[Parameter] public string? Section { get; set; }
1417

1518

@@ -31,6 +34,7 @@ protected override async Task OnInitAsync()
3134
try
3235
{
3336
user = (await PrerenderStateService.GetValue(() => HttpClient.GetFromJsonAsync("api/User/GetCurrentUser", JsonSerializerOptions.GetTypeInfo<UserDto>(), CurrentCancellationToken)))!;
37+
showPasswordless = await JSRuntime.IsWebAuthnAvailable() && AppPlatform.IsBlazorHybrid is false;
3438
}
3539
finally
3640
{

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor

+13-3
Original file line numberDiff line numberDiff line change
@@ -15,16 +15,26 @@
1515
{
1616
@if (isOtpSent is false)
1717
{
18-
<SignInPanel IsWaiting="isWaiting" Model="model" OnSocialSignIn="SocialSignIn" OnSendOtp="SendOtp" OnTabChange="HandleOnSignInPanelTabChange" />
18+
<SignInPanel Model="model"
19+
IsWaiting="isWaiting"
20+
OnSendOtp="HandleOnSendOtp"
21+
OnSocialSignIn="HandleOnSocialSignIn"
22+
OnTabChange="HandleOnSignInPanelTabChange"
23+
OnPasswordlessSignIn="HandleOnPasswordlessSignIn" />
1924
}
2025
else
2126
{
22-
<OtpPanel IsWaiting="isWaiting" Model="model" OnSignIn="DoSignIn" OnResendOtp="ResendOtp" />
27+
<OtpPanel Model="model"
28+
OnSignIn="DoSignIn"
29+
IsWaiting="isWaiting"
30+
OnResendOtp="HandleOnResendOtp" />
2331
}
2432
}
2533
else
2634
{
27-
<TfaPanel IsWaiting="isWaiting" Model="model" OnSendTfaToken="SendTfaToken" />
35+
<TfaPanel Model="model"
36+
IsWaiting="isWaiting"
37+
OnSendTfaToken="HandleOnSendTfaToken" />
2838
}
2939
</BitStack>
3040
</EditForm>

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor.cs

+68-23
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
//+:cnd:noEmit
2+
using Fido2NetLib;
23
using Boilerplate.Shared.Dtos.Identity;
34
using Boilerplate.Shared.Controllers.Identity;
45

@@ -93,7 +94,47 @@ protected override async Task OnInitAsync()
9394
}
9495

9596

96-
private async Task SocialSignIn(string provider)
97+
private async Task DoSignIn()
98+
{
99+
if (isWaiting) return;
100+
if (isOtpSent && string.IsNullOrWhiteSpace(model.Otp)) return;
101+
102+
isWaiting = true;
103+
104+
try
105+
{
106+
if (requiresTwoFactor && string.IsNullOrWhiteSpace(model.TwoFactorCode)) return;
107+
108+
CleanModel();
109+
110+
if (validatorRef?.EditContext.Validate() is false) return;
111+
112+
model.DeviceInfo = telemetryContext.Platform;
113+
114+
requiresTwoFactor = await AuthManager.SignIn(model, CurrentCancellationToken);
115+
116+
if (requiresTwoFactor)
117+
{
118+
PubSubService.Publish(ClientPubSubMessages.UPDATE_IDENTITY_HEADER_BACK_LINK, TfaPayload);
119+
}
120+
else
121+
{
122+
NavigationManager.NavigateTo(ReturnUrlQueryString ?? Urls.HomePage, replace: true);
123+
}
124+
}
125+
catch (KnownException e)
126+
{
127+
// 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)`.
128+
129+
SnackBarService.Error(e.Message);
130+
}
131+
finally
132+
{
133+
isWaiting = false;
134+
}
135+
}
136+
137+
private async Task HandleOnSocialSignIn(string provider)
97138
{
98139
try
99140
{
@@ -109,38 +150,42 @@ private async Task SocialSignIn(string provider)
109150
}
110151
}
111152

112-
private async Task DoSignIn()
153+
private async Task HandleOnPasswordlessSignIn()
113154
{
114155
if (isWaiting) return;
115-
if (isOtpSent && string.IsNullOrWhiteSpace(model.Otp)) return;
116156

117157
isWaiting = true;
118158

119159
try
120160
{
121-
if (requiresTwoFactor && string.IsNullOrWhiteSpace(model.TwoFactorCode)) return;
122-
123-
CleanModel();
161+
var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken);
124162

125-
if (validatorRef?.EditContext.Validate() is false) return;
126-
127-
model.DeviceInfo = telemetryContext.Platform;
163+
AuthenticatorAssertionRawResponse assertion;
164+
try
165+
{
166+
assertion = await JSRuntime.VerifyWebAuthnCredential(options);
167+
}
168+
catch (Exception ex)
169+
{
170+
// we can safely handle the exception thrown here since it mostly because of a timeout or user cancelling the native ui.
171+
ExceptionHandler.Handle(ex, ExceptionDisplayKind.None);
172+
return;
173+
}
128174

129-
requiresTwoFactor = await AuthManager.SignIn(model, CurrentCancellationToken);
175+
var response = await identityController.VerifyWebAuthAndSignIn(assertion, CurrentCancellationToken);
130176

131-
if (requiresTwoFactor is false)
177+
if (response.RequiresTwoFactor)
132178
{
133-
NavigationManager.NavigateTo(ReturnUrlQueryString ?? Urls.HomePage, replace: true);
179+
PubSubService.Publish(ClientPubSubMessages.UPDATE_IDENTITY_HEADER_BACK_LINK, TfaPayload);
134180
}
135181
else
136182
{
137-
PubSubService.Publish(ClientPubSubMessages.UPDATE_IDENTITY_HEADER_BACK_LINK, TfaPayload);
183+
await AuthManager.StoreTokens(response!, model.RememberMe);
184+
NavigationManager.NavigateTo(ReturnUrlQueryString ?? Urls.HomePage, replace: true);
138185
}
139186
}
140187
catch (KnownException e)
141188
{
142-
// 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)`.
143-
144189
SnackBarService.Error(e.Message);
145190
}
146191
finally
@@ -149,6 +194,13 @@ private async Task DoSignIn()
149194
}
150195
}
151196

197+
private void HandleOnSignInPanelTabChange(SignInPanelTab tab)
198+
{
199+
currentSignInPanelTab = tab;
200+
}
201+
202+
private Task HandleOnSendOtp() => SendOtp(false);
203+
private Task HandleOnResendOtp() => SendOtp(true);
152204
private async Task SendOtp(bool resend)
153205
{
154206
try
@@ -185,10 +237,8 @@ private async Task SendOtp(bool resend)
185237
SnackBarService.Error(e.Message);
186238
}
187239
}
188-
private Task ResendOtp() => SendOtp(true);
189-
private Task SendOtp() => SendOtp(false);
190240

191-
private async Task SendTfaToken()
241+
private async Task HandleOnSendTfaToken()
192242
{
193243
try
194244
{
@@ -204,11 +254,6 @@ private async Task SendTfaToken()
204254
}
205255
}
206256

207-
private void HandleOnSignInPanelTabChange(SignInPanelTab tab)
208-
{
209-
currentSignInPanelTab = tab;
210-
}
211-
212257
private void CleanModel()
213258
{
214259
if (currentSignInPanelTab is SignInPanelTab.Email)

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor

+14-1
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,20 @@
6262
</BitStack>
6363
</BitStack>
6464

65-
<BitCheckbox @bind-Value="Model.RememberMe" Label="@Localizer[nameof(AppStrings.RememberMe)]" />
65+
<BitStack Horizontal Alignment="BitAlignment.Center">
66+
<BitCheckbox @bind-Value="Model.RememberMe" Label="@Localizer[nameof(AppStrings.RememberMe)]" />
67+
<BitSpacer />
68+
@if (isWebAuthnAvailable)
69+
{
70+
<BitButton IconOnly
71+
Size="BitSize.Large"
72+
Variant="BitVariant.Text"
73+
Color="BitColor.Tertiary"
74+
OnClick="OnPasswordlessSignIn"
75+
ButtonType="BitButtonType.Button"
76+
IconName="@BitIconName.Fingerprint" />
77+
}
78+
</BitStack>
6679

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

0 commit comments

Comments
 (0)