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

Simplify WebAuthn process for users of Boilerplate (#10262) #10263

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 @@ -5,7 +5,9 @@
<BitText Typography="BitTypography.H6" Align="BitTextAlign.Center">
@Localizer[nameof(AppStrings.PasswordlessTitle)]
</BitText>

<br />

@if (isConfigured)
{
<BitButton AutoLoading OnClick="WrapHandled(DisablePasswordless)" Variant="BitVariant.Outline" Color="BitColor.Warning">
Expand All @@ -18,6 +20,13 @@
@Localizer[nameof(AppStrings.EnablePasswordless)]
</BitButton>
}

@* <br />

<BitButton AutoLoading OnClick="WrapHandled(DeleteAll)" Variant="BitVariant.Outline" Color="BitColor.Warning">
Delete all credentials
</BitButton> *@

<br />
</BitStack>
</section>
Original file line number Diff line number Diff line change
Expand Up @@ -21,14 +21,23 @@ protected override async Task OnParamsSetAsync()

if (User?.UserName is null) return;

isConfigured = await JSRuntime.IsWebAuthnConfigured(User.UserName);
isConfigured = await JSRuntime.IsWebAuthnConfigured(User.Id);
}


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;
Expand All @@ -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;

Expand All @@ -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)
{
Expand All @@ -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;
//}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,28 +10,33 @@ public static ValueTask<bool> IsWebAuthnAvailable(this IJSRuntime jsRuntime)
return jsRuntime.InvokeAsync<bool>("WebAuthn.isAvailable");
}

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

public static ValueTask<bool> IsWebAuthnConfigured(this IJSRuntime jsRuntime, string? username = null)
public static ValueTask<Guid[]?> GetWebAuthnConfiguredUserIds(this IJSRuntime jsRuntime)
{
return jsRuntime.InvokeAsync<bool>("WebAuthn.isConfigured", username);
return jsRuntime.InvokeAsync<Guid[]?>("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<AuthenticatorAttestationRawResponse> CreateWebAuthnCredential(this IJSRuntime jsRuntime, CredentialCreateOptions options)
{
return jsRuntime.InvokeAsync<AuthenticatorAttestationRawResponse>("WebAuthn.createCredential", options);
}

public static ValueTask<AuthenticatorAssertionRawResponse> VerifyWebAuthnCredential(this IJSRuntime jsRuntime, AssertionOptions options)
public static ValueTask<AuthenticatorAssertionRawResponse> GetWebAuthnCredential(this IJSRuntime jsRuntime, AssertionOptions options)
{
return jsRuntime.InvokeAsync<AuthenticatorAssertionRawResponse>("WebAuthn.verifyCredential", options);
return jsRuntime.InvokeAsync<AuthenticatorAssertionRawResponse>("WebAuthn.getCredential", options);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) : []));
}


Expand Down Expand Up @@ -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) {
Expand All @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -16,10 +16,19 @@ public partial class IdentityController


[HttpGet]
public async Task<AssertionOptions> GetWebAuthnAssertionOptions(CancellationToken cancellationToken)
public async Task<AssertionOptions> GetWebAuthnAssertionOptions(WebAuthnAssertionOptionsRequestDto request, CancellationToken cancellationToken)
{
var existingKeys = new List<PublicKeyCredentialDescriptor>();

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,
Expand All @@ -28,9 +37,9 @@ public async Task<AssertionOptions> 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)]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,7 @@ public async Task<CredentialCreateOptions> 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()),
Expand All @@ -33,15 +31,12 @@ public async Task<CredentialCreateOptions> 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,
Expand All @@ -55,7 +50,7 @@ public async Task<CredentialCreateOptions> GetWebAuthnCredentialOptions(Cancella
ExcludeCredentials = [], //[.. existingKeys],
AuthenticatorSelection = authenticatorSelection,
AttestationPreference = AttestationConveyancePreference.None,
Extensions = extensions
//Extensions = extensions
});

var key = GetWebAuthnCacheKey(userId);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>([webAppUrl.AbsoluteUri]),
ServerIcon = new Uri(webAppUrl, "images/icons/bit-logo.png").ToString()
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ public interface IIdentityController : IAppController
Task<string> GetSocialSignInUri(string provider, string? returnUrl = null, int? localHttpPort = null, CancellationToken cancellationToken = default);

[HttpGet]
Task<AssertionOptions> GetWebAuthnAssertionOptions(CancellationToken cancellationToken);
Task<AssertionOptions> GetWebAuthnAssertionOptions(WebAuthnAssertionOptionsRequestDto request, CancellationToken cancellationToken);

[HttpPost]
Task<VerifyAssertionResult> VerifyWebAuthAssertion(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
namespace Boilerplate.Shared.Dtos.Identity;

public partial class WebAuthnAssertionOptionsRequestDto
{
public Guid[]? UserIds { get; set; }
}
Loading