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 index d312eb4634..0b4c4811be 100644 --- 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 @@ -5,7 +5,9 @@ @Localizer[nameof(AppStrings.PasswordlessTitle)] +
+ @if (isConfigured) { @@ -18,6 +20,13 @@ @Localizer[nameof(AppStrings.EnablePasswordless)] } + + @*
+ + + Delete all credentials + *@ +
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 index f23b7b0826..97a4ef6a5a 100644 --- 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 @@ -21,7 +21,7 @@ protected override async Task OnParamsSetAsync() if (User?.UserName is null) return; - isConfigured = await JSRuntime.IsWebAuthnConfigured(User.UserName); + isConfigured = await JSRuntime.IsWebAuthnConfigured(User.Id); } @@ -29,6 +29,15 @@ private async Task EnablePasswordless() { if (User?.UserName is null) return; + // Only on Android this action will replace the current credential registered on the device, + // since android won't show the user selection window when there are multiple credentials registered. + // 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): + //var userIds = await JSRuntime.GetWebAuthnConfiguredUserIds(); + //if (userIds is not null && userIds.Length > 0) + //{ + // // show a warning or confirm modal + //} + var options = await userController.GetWebAuthnCredentialOptions(CurrentCancellationToken); AuthenticatorAttestationRawResponse attestationResponse; @@ -45,7 +54,7 @@ private async Task EnablePasswordless() await userController.CreateWebAuthnCredential(attestationResponse, CurrentCancellationToken); - await JSRuntime.StoreWebAuthnConfigured(User.UserName); + await JSRuntime.SetWebAuthnConfiguredUserId(User.Id); isConfigured = true; @@ -56,12 +65,12 @@ private async Task DisablePasswordless() { if (User?.UserName is null) return; - var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken); + var options = await identityController.GetWebAuthnAssertionOptions(new() { UserIds = [User.Id] }, CurrentCancellationToken); AuthenticatorAssertionRawResponse assertion; try { - assertion = await JSRuntime.VerifyWebAuthnCredential(options); + assertion = await JSRuntime.GetWebAuthnCredential(options); } catch (Exception ex) { @@ -74,10 +83,20 @@ private async Task DisablePasswordless() await userController.DeleteWebAuthnCredential(assertion.Id, CurrentCancellationToken); - await JSRuntime.RemoveWebAuthnConfigured(User.UserName); + await JSRuntime.RemoveWebAuthnConfiguredUserId(User.Id); isConfigured = false; SnackBarService.Success(Localizer[nameof(AppStrings.DisablePasswordlessSucsessMessage)]); } + + // Only for debugging purposes, uncomment the following lines and the corresponding lines in the razor file. + //private async Task DeleteAll() + //{ + // await userController.DeleteAllWebAuthnCredentials(CurrentCancellationToken); + + // await JSRuntime.RemoveWebAuthnConfigured(); + + // isConfigured = false; + //} } 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 d257bf26c0..aeac9f7bbc 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 @@ -169,11 +169,14 @@ private async Task HandleOnPasswordlessSignIn() try { - var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken); + var userIds = await JSRuntime.GetWebAuthnConfiguredUserIds(); + if (userIds is null) return; + + var options = await identityController.GetWebAuthnAssertionOptions(new() { UserIds = userIds }, CurrentCancellationToken); try { - webAuthnAssertion = await JSRuntime.VerifyWebAuthnCredential(options); + webAuthnAssertion = await JSRuntime.GetWebAuthnCredential(options); } catch (Exception ex) { 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 index 18cda6f79f..f3ac4dde10 100644 --- 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 @@ -10,19 +10,24 @@ public static ValueTask IsWebAuthnAvailable(this IJSRuntime jsRuntime) return jsRuntime.InvokeAsync("WebAuthn.isAvailable"); } - public static ValueTask StoreWebAuthnConfigured(this IJSRuntime jsRuntime, string username) + public static ValueTask IsWebAuthnConfigured(this IJSRuntime jsRuntime, Guid? userId = null) { - return jsRuntime.InvokeVoidAsync("WebAuthn.storeConfigured", username); + return jsRuntime.InvokeAsync("WebAuthn.isConfigured", userId); } - public static ValueTask IsWebAuthnConfigured(this IJSRuntime jsRuntime, string? username = null) + public static ValueTask GetWebAuthnConfiguredUserIds(this IJSRuntime jsRuntime) { - return jsRuntime.InvokeAsync("WebAuthn.isConfigured", username); + return jsRuntime.InvokeAsync("WebAuthn.getConfiguredUserIds"); } - public static ValueTask RemoveWebAuthnConfigured(this IJSRuntime jsRuntime, string? username = null) + public static ValueTask SetWebAuthnConfiguredUserId(this IJSRuntime jsRuntime, Guid userId) { - return jsRuntime.InvokeVoidAsync("WebAuthn.removeConfigured", username); + return jsRuntime.InvokeVoidAsync("WebAuthn.setConfiguredUserId", userId); + } + + public static ValueTask RemoveWebAuthnConfiguredUserId(this IJSRuntime jsRuntime, Guid? userId = null) + { + return jsRuntime.InvokeVoidAsync("WebAuthn.removeConfiguredUserId", userId); } public static ValueTask CreateWebAuthnCredential(this IJSRuntime jsRuntime, CredentialCreateOptions options) @@ -30,8 +35,8 @@ public static ValueTask CreateWebAuthnCrede return jsRuntime.InvokeAsync("WebAuthn.createCredential", options); } - public static ValueTask VerifyWebAuthnCredential(this IJSRuntime jsRuntime, AssertionOptions options) + public static ValueTask GetWebAuthnCredential(this IJSRuntime jsRuntime, AssertionOptions options) { - return jsRuntime.InvokeAsync("WebAuthn.verifyCredential", options); + return jsRuntime.InvokeAsync("WebAuthn.getCredential", 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 index b24b327f50..133c146e8b 100644 --- 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 @@ -5,20 +5,25 @@ class WebAuthn { return !!window.PublicKeyCredential; } - public static isConfigured(username: string | undefined) { + public static isConfigured(userId: string | undefined) { const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[]; - return !!username ? storedCredentials.includes(username) : storedCredentials.length > 0; + return !!userId ? storedCredentials.includes(userId) : storedCredentials.length > 0; } - public static storeConfigured(username: string) { + public static getConfiguredUserIds() { const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[]; - storedCredentials.push(username); + return storedCredentials; + } + + public static setConfiguredUserId(userId: string) { + const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[]; + storedCredentials.push(userId); localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(storedCredentials)); } - public static removeConfigured(username: string) { + public static removeConfiguredUserId(userId: string) { const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[]; - localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(!!username ? storedCredentials.filter(c => c !== username) : [])); + localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(!!userId ? storedCredentials.filter(c => c !== userId) : [])); } @@ -63,7 +68,7 @@ class WebAuthn { return result; } - public static async verifyCredential(options: PublicKeyCredentialRequestOptions) { + public static async getCredential(options: PublicKeyCredentialRequestOptions) { options.challenge = WebAuthn.ToArrayBuffer(options.challenge, 'challenge'); options.allowCredentials?.forEach(function (cred) { @@ -89,11 +94,10 @@ class WebAuthn { response: { authenticatorData: WebAuthn.ToBase64Url(authenticatorData), clientDataJSON: WebAuthn.ToBase64Url(clientDataJSON), - userHandle: userHandle !== null ? WebAuthn.ToBase64Url(userHandle) : null, + userHandle: userHandle ? (WebAuthn.ToBase64Url(userHandle) || null) : null, signature: WebAuthn.ToBase64Url(signature) } } - return result; } 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 index 6f7aadb423..59183a9981 100644 --- 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 @@ -1,10 +1,10 @@ //+:cnd:noEmit using System.Text; -using Microsoft.Extensions.Caching.Distributed; +using Boilerplate.Server.Api.Models.Identity; +using Boilerplate.Shared.Dtos.Identity; using Fido2NetLib; using Fido2NetLib.Objects; -using Boilerplate.Shared.Dtos.Identity; -using Boilerplate.Server.Api.Models.Identity; +using Microsoft.Extensions.Caching.Distributed; namespace Boilerplate.Server.Api.Controllers.Identity; @@ -16,10 +16,19 @@ public partial class IdentityController [HttpGet] - public async Task GetWebAuthnAssertionOptions(CancellationToken cancellationToken) + public async Task GetWebAuthnAssertionOptions(WebAuthnAssertionOptionsRequestDto request, CancellationToken cancellationToken) { var existingKeys = new List(); + if (request.UserIds is not null) + { + var existingCredentials = await DbContext.WebAuthnCredential.Where(c => request.UserIds.Contains(c.UserId)) + .OrderByDescending(c => c.RegDate) + .Select(c => new { c.Id, c.Transports }) + .ToArrayAsync(cancellationToken); + existingKeys.AddRange(existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, c.Id, c.Transports))); + } + var extensions = new AuthenticationExtensionsClientInputs { Extensions = true, @@ -28,9 +37,9 @@ public async Task GetWebAuthnAssertionOptions(CancellationToke var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams { - Extensions = extensions, + //Extensions = extensions, AllowedCredentials = existingKeys, - UserVerification = UserVerificationRequirement.Discouraged, + UserVerification = UserVerificationRequirement.Required, }); var key = new string([.. options.Challenge.Select(b => (char)b)]); 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 index 910c1db097..55575fbf15 100644 --- 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 @@ -21,9 +21,7 @@ public async Task GetWebAuthnCredentialOptions(Cancella ?? 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 existingKeys = existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, c.Id, c.Transports)); var fidoUser = new Fido2User { Id = Encoding.UTF8.GetBytes(userId.ToString()), @@ -33,15 +31,12 @@ public async Task GetWebAuthnCredentialOptions(Cancella var authenticatorSelection = new AuthenticatorSelection { - ResidentKey = ResidentKeyRequirement.Required, - UserVerification = UserVerificationRequirement.Preferred + RequireResidentKey = false, + ResidentKey = ResidentKeyRequirement.Discouraged, + UserVerification = UserVerificationRequirement.Required, + AuthenticatorAttachment = AuthenticatorAttachment.Platform }; - //var authenticatorSelection = new AuthenticatorSelection - //{ - // AuthenticatorAttachment = AuthenticatorAttachment.Platform - //}; - var extensions = new AuthenticationExtensionsClientInputs { CredProps = true, @@ -55,7 +50,7 @@ public async Task GetWebAuthnCredentialOptions(Cancella ExcludeCredentials = [], //[.. existingKeys], AuthenticatorSelection = authenticatorSelection, AttestationPreference = AttestationConveyancePreference.None, - Extensions = extensions + //Extensions = extensions }); var key = GetWebAuthnCacheKey(userId); 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 5432c6e3ad..3bd3018a9f 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 @@ -310,9 +310,9 @@ void AddDbContext(DbContextOptionsBuilder options) var options = new Fido2Configuration { + ServerDomain = webAppUrl.Host, TimestampDriftTolerance = 1000, ServerName = "Boilerplate WebAuthn", - ServerDomain = webAppUrl.Host, Origins = new HashSet([webAppUrl.AbsoluteUri]), ServerIcon = new Uri(webAppUrl, "images/icons/bit-logo.png").ToString() }; 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 aa2b7b44b0..ba36ba8f05 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 @@ -49,7 +49,7 @@ public interface IIdentityController : IAppController Task GetSocialSignInUri(string provider, string? returnUrl = null, int? localHttpPort = null, CancellationToken cancellationToken = default); [HttpGet] - Task GetWebAuthnAssertionOptions(CancellationToken cancellationToken); + Task GetWebAuthnAssertionOptions(WebAuthnAssertionOptionsRequestDto request, CancellationToken cancellationToken); [HttpPost] Task VerifyWebAuthAssertion(AuthenticatorAssertionRawResponse clientResponse, 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 c28ab09a1e..9c57ab6daa 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs @@ -57,6 +57,7 @@ namespace Boilerplate.Shared.Dtos; [JsonSerializable(typeof(CredentialCreateOptions))] [JsonSerializable(typeof(VerifyAssertionResult))] [JsonSerializable(typeof(VerifyWebAuthnAndSignInDto))] +[JsonSerializable(typeof(WebAuthnAssertionOptionsRequestDto))] public partial class AppJsonContext : JsonSerializerContext { } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/WebAuthnAssertionOptionsRequestDto.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/WebAuthnAssertionOptionsRequestDto.cs new file mode 100644 index 0000000000..dfc53066c5 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/WebAuthnAssertionOptionsRequestDto.cs @@ -0,0 +1,6 @@ +namespace Boilerplate.Shared.Dtos.Identity; + +public partial class WebAuthnAssertionOptionsRequestDto +{ + public Guid[]? UserIds { get; set; } +}