Skip to content

Commit 3d9e0c5

Browse files
authored
feat(templates): simplify WebAuthn process for users of Boilerplate #10262 (#10263)
1 parent 809aed0 commit 3d9e0c5

File tree

11 files changed

+94
-43
lines changed

11 files changed

+94
-43
lines changed

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

+9
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
<BitText Typography="BitTypography.H6" Align="BitTextAlign.Center">
66
@Localizer[nameof(AppStrings.PasswordlessTitle)]
77
</BitText>
8+
89
<br />
10+
911
@if (isConfigured)
1012
{
1113
<BitButton AutoLoading OnClick="WrapHandled(DisablePasswordless)" Variant="BitVariant.Outline" Color="BitColor.Warning">
@@ -18,6 +20,13 @@
1820
@Localizer[nameof(AppStrings.EnablePasswordless)]
1921
</BitButton>
2022
}
23+
24+
@* <br />
25+
26+
<BitButton AutoLoading OnClick="WrapHandled(DeleteAll)" Variant="BitVariant.Outline" Color="BitColor.Warning">
27+
Delete all credentials
28+
</BitButton> *@
29+
2130
<br />
2231
</BitStack>
2332
</section>

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

+24-5
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,23 @@ protected override async Task OnParamsSetAsync()
2121

2222
if (User?.UserName is null) return;
2323

24-
isConfigured = await JSRuntime.IsWebAuthnConfigured(User.UserName);
24+
isConfigured = await JSRuntime.IsWebAuthnConfigured(User.Id);
2525
}
2626

2727

2828
private async Task EnablePasswordless()
2929
{
3030
if (User?.UserName is null) return;
3131

32+
// Only on Android this action will replace the current credential registered on the device,
33+
// since android won't show the user selection window when there are multiple credentials registered.
34+
// So it may be a good idea to show a confirm modal if this behavior is not appropriate for your app (as shown in the following commented lines):
35+
//var userIds = await JSRuntime.GetWebAuthnConfiguredUserIds();
36+
//if (userIds is not null && userIds.Length > 0)
37+
//{
38+
// // show a warning or confirm modal
39+
//}
40+
3241
var options = await userController.GetWebAuthnCredentialOptions(CurrentCancellationToken);
3342

3443
AuthenticatorAttestationRawResponse attestationResponse;
@@ -45,7 +54,7 @@ private async Task EnablePasswordless()
4554

4655
await userController.CreateWebAuthnCredential(attestationResponse, CurrentCancellationToken);
4756

48-
await JSRuntime.StoreWebAuthnConfigured(User.UserName);
57+
await JSRuntime.SetWebAuthnConfiguredUserId(User.Id);
4958

5059
isConfigured = true;
5160

@@ -56,12 +65,12 @@ private async Task DisablePasswordless()
5665
{
5766
if (User?.UserName is null) return;
5867

59-
var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken);
68+
var options = await identityController.GetWebAuthnAssertionOptions(new() { UserIds = [User.Id] }, CurrentCancellationToken);
6069

6170
AuthenticatorAssertionRawResponse assertion;
6271
try
6372
{
64-
assertion = await JSRuntime.VerifyWebAuthnCredential(options);
73+
assertion = await JSRuntime.GetWebAuthnCredential(options);
6574
}
6675
catch (Exception ex)
6776
{
@@ -74,10 +83,20 @@ private async Task DisablePasswordless()
7483

7584
await userController.DeleteWebAuthnCredential(assertion.Id, CurrentCancellationToken);
7685

77-
await JSRuntime.RemoveWebAuthnConfigured(User.UserName);
86+
await JSRuntime.RemoveWebAuthnConfiguredUserId(User.Id);
7887

7988
isConfigured = false;
8089

8190
SnackBarService.Success(Localizer[nameof(AppStrings.DisablePasswordlessSucsessMessage)]);
8291
}
92+
93+
// Only for debugging purposes, uncomment the following lines and the corresponding lines in the razor file.
94+
//private async Task DeleteAll()
95+
//{
96+
// await userController.DeleteAllWebAuthnCredentials(CurrentCancellationToken);
97+
98+
// await JSRuntime.RemoveWebAuthnConfigured();
99+
100+
// isConfigured = false;
101+
//}
83102
}

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

+5-2
Original file line numberDiff line numberDiff line change
@@ -169,11 +169,14 @@ private async Task HandleOnPasswordlessSignIn()
169169

170170
try
171171
{
172-
var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken);
172+
var userIds = await JSRuntime.GetWebAuthnConfiguredUserIds();
173+
if (userIds is null) return;
174+
175+
var options = await identityController.GetWebAuthnAssertionOptions(new() { UserIds = userIds }, CurrentCancellationToken);
173176

174177
try
175178
{
176-
webAuthnAssertion = await JSRuntime.VerifyWebAuthnCredential(options);
179+
webAuthnAssertion = await JSRuntime.GetWebAuthnCredential(options);
177180
}
178181
catch (Exception ex)
179182
{

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/IJSRuntimeWebAuthnExtensions.cs

+13-8
Original file line numberDiff line numberDiff line change
@@ -10,28 +10,33 @@ public static ValueTask<bool> IsWebAuthnAvailable(this IJSRuntime jsRuntime)
1010
return jsRuntime.InvokeAsync<bool>("WebAuthn.isAvailable");
1111
}
1212

13-
public static ValueTask StoreWebAuthnConfigured(this IJSRuntime jsRuntime, string username)
13+
public static ValueTask<bool> IsWebAuthnConfigured(this IJSRuntime jsRuntime, Guid? userId = null)
1414
{
15-
return jsRuntime.InvokeVoidAsync("WebAuthn.storeConfigured", username);
15+
return jsRuntime.InvokeAsync<bool>("WebAuthn.isConfigured", userId);
1616
}
1717

18-
public static ValueTask<bool> IsWebAuthnConfigured(this IJSRuntime jsRuntime, string? username = null)
18+
public static ValueTask<Guid[]?> GetWebAuthnConfiguredUserIds(this IJSRuntime jsRuntime)
1919
{
20-
return jsRuntime.InvokeAsync<bool>("WebAuthn.isConfigured", username);
20+
return jsRuntime.InvokeAsync<Guid[]?>("WebAuthn.getConfiguredUserIds");
2121
}
2222

23-
public static ValueTask RemoveWebAuthnConfigured(this IJSRuntime jsRuntime, string? username = null)
23+
public static ValueTask SetWebAuthnConfiguredUserId(this IJSRuntime jsRuntime, Guid userId)
2424
{
25-
return jsRuntime.InvokeVoidAsync("WebAuthn.removeConfigured", username);
25+
return jsRuntime.InvokeVoidAsync("WebAuthn.setConfiguredUserId", userId);
26+
}
27+
28+
public static ValueTask RemoveWebAuthnConfiguredUserId(this IJSRuntime jsRuntime, Guid? userId = null)
29+
{
30+
return jsRuntime.InvokeVoidAsync("WebAuthn.removeConfiguredUserId", userId);
2631
}
2732

2833
public static ValueTask<AuthenticatorAttestationRawResponse> CreateWebAuthnCredential(this IJSRuntime jsRuntime, CredentialCreateOptions options)
2934
{
3035
return jsRuntime.InvokeAsync<AuthenticatorAttestationRawResponse>("WebAuthn.createCredential", options);
3136
}
3237

33-
public static ValueTask<AuthenticatorAssertionRawResponse> VerifyWebAuthnCredential(this IJSRuntime jsRuntime, AssertionOptions options)
38+
public static ValueTask<AuthenticatorAssertionRawResponse> GetWebAuthnCredential(this IJSRuntime jsRuntime, AssertionOptions options)
3439
{
35-
return jsRuntime.InvokeAsync<AuthenticatorAssertionRawResponse>("WebAuthn.verifyCredential", options);
40+
return jsRuntime.InvokeAsync<AuthenticatorAssertionRawResponse>("WebAuthn.getCredential", options);
3641
}
3742
}

src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebAuthn.ts

+13-9
Original file line numberDiff line numberDiff line change
@@ -5,20 +5,25 @@ class WebAuthn {
55
return !!window.PublicKeyCredential;
66
}
77

8-
public static isConfigured(username: string | undefined) {
8+
public static isConfigured(userId: string | undefined) {
99
const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
10-
return !!username ? storedCredentials.includes(username) : storedCredentials.length > 0;
10+
return !!userId ? storedCredentials.includes(userId) : storedCredentials.length > 0;
1111
}
1212

13-
public static storeConfigured(username: string) {
13+
public static getConfiguredUserIds() {
1414
const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
15-
storedCredentials.push(username);
15+
return storedCredentials;
16+
}
17+
18+
public static setConfiguredUserId(userId: string) {
19+
const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
20+
storedCredentials.push(userId);
1621
localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(storedCredentials));
1722
}
1823

19-
public static removeConfigured(username: string) {
24+
public static removeConfiguredUserId(userId: string) {
2025
const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
21-
localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(!!username ? storedCredentials.filter(c => c !== username) : []));
26+
localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(!!userId ? storedCredentials.filter(c => c !== userId) : []));
2227
}
2328

2429

@@ -63,7 +68,7 @@ class WebAuthn {
6368
return result;
6469
}
6570

66-
public static async verifyCredential(options: PublicKeyCredentialRequestOptions) {
71+
public static async getCredential(options: PublicKeyCredentialRequestOptions) {
6772
options.challenge = WebAuthn.ToArrayBuffer(options.challenge, 'challenge');
6873

6974
options.allowCredentials?.forEach(function (cred) {
@@ -89,11 +94,10 @@ class WebAuthn {
8994
response: {
9095
authenticatorData: WebAuthn.ToBase64Url(authenticatorData),
9196
clientDataJSON: WebAuthn.ToBase64Url(clientDataJSON),
92-
userHandle: userHandle !== null ? WebAuthn.ToBase64Url(userHandle) : null,
97+
userHandle: userHandle ? (WebAuthn.ToBase64Url(userHandle) || null) : null,
9398
signature: WebAuthn.ToBase64Url(signature)
9499
}
95100
}
96-
97101
return result;
98102
}
99103

src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs

+15-6
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
//+:cnd:noEmit
22
using System.Text;
3-
using Microsoft.Extensions.Caching.Distributed;
3+
using Boilerplate.Server.Api.Models.Identity;
4+
using Boilerplate.Shared.Dtos.Identity;
45
using Fido2NetLib;
56
using Fido2NetLib.Objects;
6-
using Boilerplate.Shared.Dtos.Identity;
7-
using Boilerplate.Server.Api.Models.Identity;
7+
using Microsoft.Extensions.Caching.Distributed;
88

99
namespace Boilerplate.Server.Api.Controllers.Identity;
1010

@@ -16,10 +16,19 @@ public partial class IdentityController
1616

1717

1818
[HttpGet]
19-
public async Task<AssertionOptions> GetWebAuthnAssertionOptions(CancellationToken cancellationToken)
19+
public async Task<AssertionOptions> GetWebAuthnAssertionOptions(WebAuthnAssertionOptionsRequestDto request, CancellationToken cancellationToken)
2020
{
2121
var existingKeys = new List<PublicKeyCredentialDescriptor>();
2222

23+
if (request.UserIds is not null)
24+
{
25+
var existingCredentials = await DbContext.WebAuthnCredential.Where(c => request.UserIds.Contains(c.UserId))
26+
.OrderByDescending(c => c.RegDate)
27+
.Select(c => new { c.Id, c.Transports })
28+
.ToArrayAsync(cancellationToken);
29+
existingKeys.AddRange(existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, c.Id, c.Transports)));
30+
}
31+
2332
var extensions = new AuthenticationExtensionsClientInputs
2433
{
2534
Extensions = true,
@@ -28,9 +37,9 @@ public async Task<AssertionOptions> GetWebAuthnAssertionOptions(CancellationToke
2837

2938
var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams
3039
{
31-
Extensions = extensions,
40+
//Extensions = extensions,
3241
AllowedCredentials = existingKeys,
33-
UserVerification = UserVerificationRequirement.Discouraged,
42+
UserVerification = UserVerificationRequirement.Required,
3443
});
3544

3645
var key = new string([.. options.Challenge.Select(b => (char)b)]);

src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.WebAuthn.cs

+6-11
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,7 @@ public async Task<CredentialCreateOptions> GetWebAuthnCredentialOptions(Cancella
2121
?? throw new ResourceNotFoundException();
2222

2323
var existingCredentials = DbContext.WebAuthnCredential.Where(c => c.UserId == userId);
24-
var existingKeys = existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey,
25-
c.Id,
26-
c.Transports));
24+
var existingKeys = existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, c.Id, c.Transports));
2725
var fidoUser = new Fido2User
2826
{
2927
Id = Encoding.UTF8.GetBytes(userId.ToString()),
@@ -33,15 +31,12 @@ public async Task<CredentialCreateOptions> GetWebAuthnCredentialOptions(Cancella
3331

3432
var authenticatorSelection = new AuthenticatorSelection
3533
{
36-
ResidentKey = ResidentKeyRequirement.Required,
37-
UserVerification = UserVerificationRequirement.Preferred
34+
RequireResidentKey = false,
35+
ResidentKey = ResidentKeyRequirement.Discouraged,
36+
UserVerification = UserVerificationRequirement.Required,
37+
AuthenticatorAttachment = AuthenticatorAttachment.Platform
3838
};
3939

40-
//var authenticatorSelection = new AuthenticatorSelection
41-
//{
42-
// AuthenticatorAttachment = AuthenticatorAttachment.Platform
43-
//};
44-
4540
var extensions = new AuthenticationExtensionsClientInputs
4641
{
4742
CredProps = true,
@@ -55,7 +50,7 @@ public async Task<CredentialCreateOptions> GetWebAuthnCredentialOptions(Cancella
5550
ExcludeCredentials = [], //[.. existingKeys],
5651
AuthenticatorSelection = authenticatorSelection,
5752
AttestationPreference = AttestationConveyancePreference.None,
58-
Extensions = extensions
53+
//Extensions = extensions
5954
});
6055

6156
var key = GetWebAuthnCacheKey(userId);

src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Program.Services.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -310,9 +310,9 @@ void AddDbContext(DbContextOptionsBuilder options)
310310

311311
var options = new Fido2Configuration
312312
{
313+
ServerDomain = webAppUrl.Host,
313314
TimestampDriftTolerance = 1000,
314315
ServerName = "Boilerplate WebAuthn",
315-
ServerDomain = webAppUrl.Host,
316316
Origins = new HashSet<string>([webAppUrl.AbsoluteUri]),
317317
ServerIcon = new Uri(webAppUrl, "images/icons/bit-logo.png").ToString()
318318
};

src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs

+1-1
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ public interface IIdentityController : IAppController
4949
Task<string> GetSocialSignInUri(string provider, string? returnUrl = null, int? localHttpPort = null, CancellationToken cancellationToken = default);
5050

5151
[HttpGet]
52-
Task<AssertionOptions> GetWebAuthnAssertionOptions(CancellationToken cancellationToken);
52+
Task<AssertionOptions> GetWebAuthnAssertionOptions(WebAuthnAssertionOptionsRequestDto request, CancellationToken cancellationToken);
5353

5454
[HttpPost]
5555
Task<VerifyAssertionResult> VerifyWebAuthAssertion(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken);

src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs

+1
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ namespace Boilerplate.Shared.Dtos;
5757
[JsonSerializable(typeof(CredentialCreateOptions))]
5858
[JsonSerializable(typeof(VerifyAssertionResult))]
5959
[JsonSerializable(typeof(VerifyWebAuthnAndSignInDto))]
60+
[JsonSerializable(typeof(WebAuthnAssertionOptionsRequestDto))]
6061
public partial class AppJsonContext : JsonSerializerContext
6162
{
6263
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
namespace Boilerplate.Shared.Dtos.Identity;
2+
3+
public partial class WebAuthnAssertionOptionsRequestDto
4+
{
5+
public Guid[]? UserIds { get; set; }
6+
}

0 commit comments

Comments
 (0)