diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppDiagnosticModal.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppDiagnosticModal.razor
index fc7619c2be..d8c9db1bb6 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppDiagnosticModal.razor
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppDiagnosticModal.razor
@@ -88,8 +88,8 @@
IconName="@BitIconName.RecycleBin" />
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppDiagnosticModal.razor.Utils.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppDiagnosticModal.razor.Utils.cs
index 3e024b68b9..a218c0e5cf 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppDiagnosticModal.razor.Utils.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/AppDiagnosticModal.razor.Utils.cs
@@ -1,6 +1,7 @@
using System.Text;
using System.Diagnostics;
using System.Runtime.CompilerServices;
+using Boilerplate.Shared.Controllers.Identity;
//#if (signalR == true)
using Microsoft.AspNetCore.SignalR.Client;
//#endif
@@ -9,9 +10,10 @@ namespace Boilerplate.Client.Core.Components.Layout;
public partial class AppDiagnosticModal
{
- [AutoInject] Cookie cookie = default!;
- [AutoInject] AuthManager authManager = default!;
- [AutoInject] IStorageService storageService = default!;
+ [AutoInject] private Cookie cookie = default!;
+ [AutoInject] private AuthManager authManager = default!;
+ [AutoInject] private IStorageService storageService = default!;
+ [AutoInject] private IUserController userController = default!;
private static async Task ThrowTestException()
{
@@ -97,13 +99,13 @@ await Task.Run(() =>
SnackBarService.Show("Memory After GC", GetMemoryUsage());
}
- string GetMemoryUsage()
+ private string GetMemoryUsage()
{
long memory = Environment.WorkingSet;
return $"{memory / (1024.0 * 1024.0):F2} MB";
}
- async Task ClearCache()
+ private async Task ClearData()
{
try
{
@@ -118,6 +120,15 @@ async Task ClearCache()
await cookie.Remove(item.Name!);
}
+ if ((await AuthenticationStateTask).User.IsAuthenticated())
+ {
+ await userController.DeleteAllWebAuthnCredentials(CurrentCancellationToken);
+
+ // since the localStorage is used to store configured webauthn users and it is already cleared above, we can ignore the following line.
+ // we kept it commented for future refrences.
+ //await JSRuntime.RemoveWebAuthnConfigured();
+ }
+
if (AppPlatform.IsBlazorHybrid is false)
{
await JSRuntime.InvokeVoidAsync("BitBswup.forceRefresh"); // Clears cache storages and uninstalls service-worker.
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 918afd886a..d312eb4634 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
@@ -8,13 +8,13 @@
@if (isConfigured)
{
-
+
@Localizer[nameof(AppStrings.DisablePasswordless)]
}
else
{
-
+
@Localizer[nameof(AppStrings.EnablePasswordless)]
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor.cs
index 6fb471140a..74634ba4ba 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor.cs
@@ -34,7 +34,10 @@ protected override async Task OnInitAsync()
try
{
user = (await PrerenderStateService.GetValue(() => HttpClient.GetFromJsonAsync("api/User/GetCurrentUser", JsonSerializerOptions.GetTypeInfo(), CurrentCancellationToken)))!;
- showPasswordless = await JSRuntime.IsWebAuthnAvailable() && AppPlatform.IsBlazorHybrid is false;
+ if (InPrerenderSession is false)
+ {
+ showPasswordless = await JSRuntime.IsWebAuthnAvailable() && AppPlatform.IsBlazorHybrid is false;
+ }
}
finally
{
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 62692817c0..d257bf26c0 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
@@ -38,6 +38,7 @@ public partial class SignInPage
private SignInPanelTab currentSignInPanelTab;
private readonly SignInRequestDto model = new();
private AppDataAnnotationsValidator? validatorRef;
+ private AuthenticatorAssertionRawResponse? webAuthnAssertion;
private Action unsubscribeIdentityHeaderBackLinkClicked = default!;
@@ -83,6 +84,7 @@ protected override async Task OnInitAsync()
if (source == TfaPayload)
{
+ webAuthnAssertion = null;
requiresTwoFactor = false;
model.TwoFactorCode = null;
}
@@ -105,20 +107,29 @@ private async Task DoSignIn()
{
if (requiresTwoFactor && string.IsNullOrWhiteSpace(model.TwoFactorCode)) return;
- CleanModel();
+ if (webAuthnAssertion is null)
+ {
+ CleanModel();
- if (validatorRef?.EditContext.Validate() is false) return;
+ if (validatorRef?.EditContext.Validate() is false) return;
- model.DeviceInfo = telemetryContext.Platform;
+ model.DeviceInfo = telemetryContext.Platform;
- requiresTwoFactor = await AuthManager.SignIn(model, CurrentCancellationToken);
+ requiresTwoFactor = await AuthManager.SignIn(model, CurrentCancellationToken);
- if (requiresTwoFactor)
- {
- PubSubService.Publish(ClientPubSubMessages.UPDATE_IDENTITY_HEADER_BACK_LINK, TfaPayload);
+ if (requiresTwoFactor)
+ {
+ PubSubService.Publish(ClientPubSubMessages.UPDATE_IDENTITY_HEADER_BACK_LINK, TfaPayload);
+ }
+ else
+ {
+ NavigationManager.NavigateTo(ReturnUrlQueryString ?? Urls.HomePage, replace: true);
+ }
}
else
{
+ var response = await identityController.VerifyWebAuthAndSignIn(new() { ClientResponse = webAuthnAssertion, TfaCode = model.TwoFactorCode }, CurrentCancellationToken);
+ await AuthManager.StoreTokens(response!, model.RememberMe);
NavigationManager.NavigateTo(ReturnUrlQueryString ?? Urls.HomePage, replace: true);
}
}
@@ -160,10 +171,9 @@ private async Task HandleOnPasswordlessSignIn()
{
var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken);
- AuthenticatorAssertionRawResponse assertion;
try
{
- assertion = await JSRuntime.VerifyWebAuthnCredential(options);
+ webAuthnAssertion = await JSRuntime.VerifyWebAuthnCredential(options);
}
catch (Exception ex)
{
@@ -172,9 +182,11 @@ private async Task HandleOnPasswordlessSignIn()
return;
}
- var response = await identityController.VerifyWebAuthAndSignIn(assertion, CurrentCancellationToken);
+ var response = await identityController.VerifyWebAuthAndSignIn(new() { ClientResponse = webAuthnAssertion }, CurrentCancellationToken);
+
+ requiresTwoFactor = response.RequiresTwoFactor;
- if (response.RequiresTwoFactor)
+ if (requiresTwoFactor)
{
PubSubService.Publish(ClientPubSubMessages.UPDATE_IDENTITY_HEADER_BACK_LINK, TfaPayload);
}
@@ -186,6 +198,7 @@ private async Task HandleOnPasswordlessSignIn()
}
catch (KnownException e)
{
+ webAuthnAssertion = null;
SnackBarService.Error(e.Message);
}
finally
@@ -242,9 +255,16 @@ private async Task HandleOnSendTfaToken()
{
try
{
- CleanModel();
+ if (webAuthnAssertion is null)
+ {
+ CleanModel();
- await identityController.SendTwoFactorToken(model, CurrentCancellationToken);
+ await identityController.SendTwoFactorToken(model, CurrentCancellationToken);
+ }
+ else
+ {
+ await identityController.VerifyWebAuthAndSendTwoFactorToken(webAuthnAssertion, CurrentCancellationToken);
+ }
SnackBarService.Success(Localizer[nameof(AppStrings.TfaTokenSentMessage)]);
}
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 49be599d9f..18cda6f79f 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
@@ -20,7 +20,7 @@ public static ValueTask IsWebAuthnConfigured(this IJSRuntime jsRuntime, st
return jsRuntime.InvokeAsync("WebAuthn.isConfigured", username);
}
- public static ValueTask RemoveWebAuthnConfigured(this IJSRuntime jsRuntime, string username)
+ public static ValueTask RemoveWebAuthnConfigured(this IJSRuntime jsRuntime, string? username = null)
{
return jsRuntime.InvokeVoidAsync("WebAuthn.removeConfigured", username);
}
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 d3bb2b27db..b24b327f50 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
@@ -1,105 +1,167 @@
class WebAuthn {
- private static STORE_KEY = 'configured-webauthn';
+ private static STORE_KEY = 'bit-webauthn';
public static isAvailable() {
return !!window.PublicKeyCredential;
}
- public static storeConfigured(username: string) {
+ public static isConfigured(username: string | undefined) {
const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
- storedCredentials.push(username);
- localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(storedCredentials));
+ return !!username ? storedCredentials.includes(username) : storedCredentials.length > 0;
}
- public static isConfigured(username: string | undefined) {
+ public static storeConfigured(username: string) {
const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
- return !!username ? storedCredentials.includes(username) : storedCredentials.length > 0;
+ storedCredentials.push(username);
+ localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(storedCredentials));
}
public static removeConfigured(username: string) {
const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[];
- localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(storedCredentials.filter(c => c !== username)));
+ localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(!!username ? storedCredentials.filter(c => c !== username) : []));
}
public static async createCredential(options: PublicKeyCredentialCreationOptions) {
- if (typeof options.challenge === 'string') {
- options.challenge = WebAuthn.urlToArray(options.challenge);
- }
+ options.challenge = WebAuthn.ToArrayBuffer(options.challenge, 'challenge');
+
+ options.user.id = WebAuthn.ToArrayBuffer(options.user.id, 'user.id');
+
+ options.excludeCredentials?.forEach(function (cred) {
+ cred.id = WebAuthn.ToArrayBuffer(cred.id, 'cred.id');
+ });
- if (typeof options.user.id === 'string') {
- options.user.id = WebAuthn.urlToArray(options.user.id);
+ if (options.authenticatorSelection?.authenticatorAttachment === null) {
+ options.authenticatorSelection.authenticatorAttachment = undefined;
}
if (options.rp.id === null) {
options.rp.id = undefined;
}
- for (let cred of options.excludeCredentials || []) {
- if (typeof cred.id !== 'string') continue;
-
- cred.id = WebAuthn.urlToArray(cred.id);
- }
-
const credential = await navigator.credentials.create({ publicKey: options }) as PublicKeyCredential;
+
const response = credential.response as AuthenticatorAttestationResponse;
- return {
- id: WebAuthn.base64ToUrl(credential.id),
- rawId: WebAuthn.arrayToUrl(credential.rawId),
+ // Move data into Arrays incase it is super long
+ const attestationObject = new Uint8Array(response.attestationObject);
+ const clientDataJSON = new Uint8Array(response.clientDataJSON);
+ const rawId = new Uint8Array(credential.rawId);
+
+ const result = {
+ id: credential.id,
+ rawId: WebAuthn.ToBase64Url(rawId),
type: credential.type,
clientExtensionResults: credential.getClientExtensionResults(),
response: {
- attestationObject: WebAuthn.arrayToUrl(response.attestationObject),
- clientDataJSON: WebAuthn.arrayToUrl(response.clientDataJSON),
+ attestationObject: WebAuthn.ToBase64Url(attestationObject),
+ clientDataJSON: WebAuthn.ToBase64Url(clientDataJSON),
transports: response.getTransports ? response.getTransports() : []
}
};
+
+ return result;
}
public static async verifyCredential(options: PublicKeyCredentialRequestOptions) {
- if (typeof options.challenge === 'string') {
- options.challenge = WebAuthn.urlToArray(options.challenge);
- }
+ options.challenge = WebAuthn.ToArrayBuffer(options.challenge, 'challenge');
+
+ options.allowCredentials?.forEach(function (cred) {
+ cred.id = WebAuthn.ToArrayBuffer(cred.id, 'cred.id');
+ });
- if (options.allowCredentials) {
- for (let i = 0; i < options.allowCredentials.length; i++) {
- const id = options.allowCredentials[i].id;
- if (typeof id === 'string') {
- options.allowCredentials[i].id = WebAuthn.urlToArray(id);
- }
- }
- }
const credential = await navigator.credentials.get({ publicKey: options }) as PublicKeyCredential;
+
const response = credential.response as AuthenticatorAssertionResponse;
- return {
+ // Move data into Arrays incase it is super long
+ let authenticatorData = new Uint8Array(response.authenticatorData);
+ let clientDataJSON = new Uint8Array(response.clientDataJSON);
+ let rawId = new Uint8Array(credential.rawId);
+ let signature = new Uint8Array(response.signature);
+ let userHandle = new Uint8Array(response.userHandle || []);
+
+ var result = {
id: credential.id,
- rawId: WebAuthn.arrayToUrl(credential.rawId),
+ rawId: WebAuthn.ToBase64Url(rawId),
type: credential.type,
clientExtensionResults: credential.getClientExtensionResults(),
response: {
- authenticatorData: WebAuthn.arrayToUrl(response.authenticatorData),
- clientDataJSON: WebAuthn.arrayToUrl(response.clientDataJSON),
- userHandle: response.userHandle && response.userHandle.byteLength > 0 ? WebAuthn.arrayToUrl(response.userHandle) : undefined,
- signature: WebAuthn.arrayToUrl(response.signature)
+ authenticatorData: WebAuthn.ToBase64Url(authenticatorData),
+ clientDataJSON: WebAuthn.ToBase64Url(clientDataJSON),
+ userHandle: userHandle !== null ? WebAuthn.ToBase64Url(userHandle) : null,
+ signature: WebAuthn.ToBase64Url(signature)
}
}
+
+ return result;
}
- private static arrayToUrl(value: ArrayBuffer): string {
- return btoa(String.fromCharCode(...new Uint8Array(value))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, "");
- }
+ private static ToBase64Url(value: any) {
+ // Array or ArrayBuffer to Uint8Array
+ if (Array.isArray(value)) {
+ value = Uint8Array.from(value);
+ }
- private static urlToArray(value: string): Uint8Array {
- return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0));
- }
+ if (value instanceof ArrayBuffer) {
+ value = new Uint8Array(value);
+ }
- private static base64ToUrl(value: string): string {
- return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, "");
- }
+ // Uint8Array to base64
+ if (value instanceof Uint8Array) {
+ var str = "";
+ var len = value.byteLength;
+
+ for (var i = 0; i < len; i++) {
+ str += String.fromCharCode(value[i]);
+ }
+ value = window.btoa(str);
+ }
+
+ if (typeof value !== "string") {
+ throw new Error("could not coerce to string");
+ }
+
+ // base64 to base64url
+ // NOTE: "=" at the end of challenge is optional, strip it off here
+ value = value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, "");
+
+ return value;
+ };
+
+ private static ToArrayBuffer(value: any, name: string) {
+ if (typeof value === "string") {
+ // base64url to base64
+ value = value.replace(/-/g, "+").replace(/_/g, "/");
+
+ // base64 to Uint8Array
+ var str = window.atob(value);
+ var bytes = new Uint8Array(str.length);
+ for (var i = 0; i < str.length; i++) {
+ bytes[i] = str.charCodeAt(i);
+ }
+ value = bytes;
+ }
+
+ // Array to Uint8Array
+ if (Array.isArray(value)) {
+ value = new Uint8Array(value);
+ }
+
+ // Uint8Array to ArrayBuffer
+ if (value instanceof Uint8Array) {
+ value = value.buffer;
+ }
+
+ // error if none of the above worked
+ if (!(value instanceof ArrayBuffer)) {
+ throw new TypeError("could not coerce '" + name + "' to ArrayBuffer");
+ }
+
+ return value;
+ };
}
(window as any).WebAuthn = WebAuthn;
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/wwwroot/images/os/unknown.png b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/wwwroot/images/os/unknown.png
new file mode 100644
index 0000000000..a440991e8f
Binary files /dev/null and b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/wwwroot/images/os/unknown.png differ
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/wwwroot/images/icons/bit-logo.png b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/wwwroot/images/icons/bit-logo.png
new file mode 100644
index 0000000000..1629cbe86b
Binary files /dev/null and b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Web/wwwroot/images/icons/bit-logo.png differ
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 ba63186b45..6f7aadb423 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
@@ -22,8 +22,8 @@ public async Task GetWebAuthnAssertionOptions(CancellationToke
var extensions = new AuthenticationExtensionsClientInputs
{
+ Extensions = true,
UserVerificationMethod = true,
- Extensions = true
};
var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams
@@ -34,7 +34,7 @@ public async Task GetWebAuthnAssertionOptions(CancellationToke
});
var key = new string([.. options.Challenge.Select(b => (char)b)]);
- await cache.SetAsync(key, Encoding.UTF8.GetBytes(options.ToJson()), cancellationToken);
+ await cache.SetAsync(key, Encoding.UTF8.GetBytes(options.ToJson()), new() { SlidingExpiration = TimeSpan.FromMinutes(3) }, cancellationToken);
return options;
}
@@ -48,22 +48,36 @@ public async Task VerifyWebAuthAssertion(AuthenticatorAss
}
[HttpPost, Produces()]
- public async Task VerifyWebAuthAndSignIn(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken)
+ public async Task VerifyWebAuthAndSignIn(VerifyWebAuthnAndSignInDto request, CancellationToken cancellationToken)
{
- var (verifyResult, credential) = await Verify(clientResponse, cancellationToken);
+ var (verifyResult, credential) = await Verify(request.ClientResponse, cancellationToken);
var user = await userManager.FindByIdAsync(credential.UserId.ToString())
?? throw new ResourceNotFoundException();
var (otp, _) = await GenerateAutomaticSignInLink(user, null, "WebAuthn");
- credential.SignCount = verifyResult.SignCount;
+ if (user.TwoFactorEnabled is false || request.TfaCode is not null)
+ {
+ credential.SignCount = verifyResult.SignCount;
+ DbContext.WebAuthnCredential.Update(credential);
+ await DbContext.SaveChangesAsync(cancellationToken);
+ }
- DbContext.WebAuthnCredential.Update(credential);
+ await SignIn(new() { Otp = otp, TwoFactorCode = request.TfaCode }, user, cancellationToken);
+ }
- await DbContext.SaveChangesAsync(cancellationToken);
+ [HttpPost]
+ public async Task VerifyWebAuthAndSendTwoFactorToken(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken)
+ {
+ var (verifyResult, credential) = await Verify(clientResponse, cancellationToken);
+
+ var user = await userManager.FindByIdAsync(credential.UserId.ToString())
+ ?? throw new ResourceNotFoundException();
+
+ var (otp, _) = await GenerateAutomaticSignInLink(user, null, "WebAuthn");
- await SignIn(new() { Otp = otp }, user, cancellationToken);
+ await SendTwoFactorToken(new() { Otp = otp }, user, cancellationToken);
}
@@ -79,7 +93,8 @@ public async Task VerifyWebAuthAndSignIn(AuthenticatorAssertionRawResponse clien
var jsonOptions = Encoding.UTF8.GetString(cachedBytes);
var options = AssertionOptions.FromJson(jsonOptions);
- await cache.RemoveAsync(key, cancellationToken);
+ // since the TFA needs this option we won't remove it from cache manually and just wait for it to expire.
+ // await cache.RemoveAsync(key, cancellationToken);
var credential = (await DbContext.WebAuthnCredential.FirstOrDefaultAsync(c => c.Id == clientResponse.Id, cancellationToken))
?? throw new ResourceNotFoundException();
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs
index 353d70da57..daf1a5cd63 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.cs
@@ -92,7 +92,7 @@ public async Task SignIn(SignInRequestDto request, CancellationToken cancellatio
{
request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber);
- var user = await userManager.FindUserAsync(request)
+ var user = await userManager.FindUserAsync(request)
?? throw new UnauthorizedException(Localizer[nameof(AppStrings.InvalidUserCredentials)]).WithData("Identifier", request);
await SignIn(request, user, cancellationToken);
@@ -341,8 +341,15 @@ public async Task SendOtp(IdentityRequestDto request, string? returnUrl = null,
public async Task SendTwoFactorToken(SignInRequestDto request, CancellationToken cancellationToken)
{
request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber);
- var user = await userManager.FindUserAsync(request) ?? throw new ResourceNotFoundException(Localizer[nameof(AppStrings.UserNotFound)]).WithData("Identifier", request);
+ var user = await userManager.FindUserAsync(request)
+ ?? throw new ResourceNotFoundException(Localizer[nameof(AppStrings.UserNotFound)]).WithData("Identifier", request);
+
+ await SendTwoFactorToken(request, user, cancellationToken);
+ }
+
+ private async Task SendTwoFactorToken(SignInRequestDto request, User user, CancellationToken cancellationToken)
+ {
if (user.TwoFactorEnabled is false)
throw new BadRequestException().WithData("UserId", user.Id);
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 51230b855a..910c1db097 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
@@ -3,7 +3,6 @@
using Microsoft.Extensions.Caching.Distributed;
using Fido2NetLib;
using Fido2NetLib.Objects;
-using Boilerplate.Shared.Dtos.Identity;
using Boilerplate.Server.Api.Models.Identity;
namespace Boilerplate.Server.Api.Controllers.Identity;
@@ -32,22 +31,35 @@ public async Task GetWebAuthnCredentialOptions(Cancella
DisplayName = user.DisplayName,
};
+ var authenticatorSelection = new AuthenticatorSelection
+ {
+ ResidentKey = ResidentKeyRequirement.Required,
+ UserVerification = UserVerificationRequirement.Preferred
+ };
+
+ //var authenticatorSelection = new AuthenticatorSelection
+ //{
+ // AuthenticatorAttachment = AuthenticatorAttachment.Platform
+ //};
+
+ var extensions = new AuthenticationExtensionsClientInputs
+ {
+ CredProps = true,
+ Extensions = true,
+ UserVerificationMethod = true,
+ };
+
var options = fido2.RequestNewCredential(new RequestNewCredentialParams
{
User = fidoUser,
- ExcludeCredentials = [.. existingKeys],
- AuthenticatorSelection = AuthenticatorSelection.Default,
+ ExcludeCredentials = [], //[.. existingKeys],
+ AuthenticatorSelection = authenticatorSelection,
AttestationPreference = AttestationConveyancePreference.None,
- Extensions = new AuthenticationExtensionsClientInputs
- {
- CredProps = true,
- Extensions = true,
- UserVerificationMethod = true,
- }
+ Extensions = extensions
});
var key = GetWebAuthnCacheKey(userId);
- await cache.SetAsync(key, Encoding.UTF8.GetBytes(options.ToJson()), cancellationToken);
+ await cache.SetAsync(key, Encoding.UTF8.GetBytes(options.ToJson()), new() { SlidingExpiration = TimeSpan.FromMinutes(3) }, cancellationToken);
return options;
}
@@ -114,6 +126,22 @@ public async Task DeleteWebAuthnCredential(byte[] credentialId, CancellationToke
await DbContext.SaveChangesAsync(cancellationToken);
}
+ [HttpDelete]
+ public async Task DeleteAllWebAuthnCredentials(CancellationToken cancellationToken)
+ {
+ var userId = User.GetUserId();
+ var user = await userManager.FindByIdAsync(userId.ToString())
+ ?? throw new ResourceNotFoundException();
+
+ var entities = await DbContext.WebAuthnCredential.Where(c => c.UserId == userId).ToListAsync(cancellationToken);
+
+ if (entities is null || entities.Count == 0) return;
+
+ DbContext.WebAuthnCredential.RemoveRange(entities);
+
+ await DbContext.SaveChangesAsync(cancellationToken);
+ }
+
private static string GetWebAuthnCacheKey(Guid userId) => $"WebAuthn_Options_{userId}";
private async Task IsCredentialIdUniqueToUser(IsCredentialIdUniqueToUserParams args, CancellationToken cancellationToken)
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/WebAuthnCredential.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/WebAuthnCredential.cs
index a44563d2cc..1846cea1a6 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/WebAuthnCredential.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/WebAuthnCredential.cs
@@ -2,6 +2,11 @@
namespace Boilerplate.Server.Api.Models.Identity;
+///
+/// This model is used by the Fido2 lib to store and retrieve the data of the browser credential api for `Web Authentication`.
+///
+/// More info:
+///
public class WebAuthnCredential
{
public required byte[] Id { get; set; }
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 b6cdbdf3f8..207fa1b314 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
@@ -10,6 +10,7 @@
using Microsoft.AspNetCore.ResponseCompression;
using System.Security.Cryptography.X509Certificates;
using Twilio;
+using Fido2NetLib;
using PhoneNumbers;
using FluentStorage;
using FluentStorage.Blobs;
@@ -22,7 +23,6 @@
using Boilerplate.Server.Api.Controllers;
using Boilerplate.Server.Api.Models.Identity;
using Boilerplate.Server.Api.Services.Identity;
-
namespace Boilerplate.Server.Api;
public static partial class Program
@@ -301,20 +301,24 @@ void AddDbContext(DbContextOptionsBuilder options)
services.AddFido2(options =>
{
- var trustedOrigins = appSettings.AllowedOrigins
- .Union([
- //#if (api == "Integrated")
- new Uri("http://localhost:5030/")
- //#else
- , new Uri("http://localhost:5031/")
- //#endif
- ]);
-
- options.TimestampDriftTolerance = 1000;
- options.ServerName = "Boilerplate WebAuthn";
- options.ServerDomain = trustedOrigins.First().Host;
- options.Origins = new HashSet(trustedOrigins.Select(uri => uri.AbsoluteUri));
- options.ServerIcon = "data:image/png;base64,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";
+
+ });
+
+ services.AddScoped(sp =>
+ {
+ var webAppUrl = sp.GetRequiredService()
+ .HttpContext!.Request.GetWebAppUrl();
+
+ var options = new Fido2Configuration
+ {
+ TimestampDriftTolerance = 1000,
+ ServerName = "Boilerplate WebAuthn",
+ ServerDomain = webAppUrl.Host,
+ Origins = new HashSet([webAppUrl.AbsoluteUri]),
+ ServerIcon = new Uri(webAppUrl, "images/icons/bit-logo.png").ToString()
+ };
+
+ return options;
});
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/ServerApiSettings.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/ServerApiSettings.cs
index 306157b6c0..606a98beef 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/ServerApiSettings.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/ServerApiSettings.cs
@@ -48,9 +48,9 @@ public partial class ServerApiSettings : SharedSettings
public ResponseCachingOptions ResponseCaching { get; set; } = default!;
///
- /// Defines the list of origins permitted for CORS access to the API. These origins are `also` valid for use as return URLs after social sign-ins and for generating URLs in emails.
+ /// Lists the permitted origins for CORS requests, return URLs following social sign-in and email confirmation, etc., along with allowed origins for Web Auth.
///
- public Uri[] AllowedOrigins { get; set; } = [];
+ public Uri[] TrustedOrigins { get; set; } = [];
//#if (module == "Admin" || module == "Sales")
[Required]
@@ -113,8 +113,8 @@ public override IEnumerable Validate(ValidationContext validat
internal bool IsAllowedOrigin(Uri origin)
{
- return AllowedOrigins.Any(allowedOrigin => allowedOrigin == origin)
- || AllowedOriginsRegex().IsMatch(origin.ToString());
+ return TrustedOrigins.Any(trustedOrigin => trustedOrigin == origin)
+ || TrustedOriginsRegex().IsMatch(origin.ToString());
}
//-:cnd:noEmit
@@ -127,7 +127,7 @@ internal bool IsAllowedOrigin(Uri origin)
[GeneratedRegex(@"^(http|https|app):\/\/(localhost|0\.0\.0\.0|0\.0\.0\.1|127\.0\.0\.1)(:\d+)?(\/.*)?$")]
#endif
//+:cnd:noEmit
- private partial Regex AllowedOriginsRegex();
+ private partial Regex TrustedOriginsRegex();
}
public partial class AppIdentityOptions : IdentityOptions
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json
index c0ce66bac6..225695696c 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/appsettings.json
@@ -131,8 +131,8 @@
}
},
"AllowedHosts": "*",
- "AllowedOrigins": [],
- "AllowedOrigins_Comment": "Defines the list of origins permitted for CORS access to the API. These origins are also valid for use as return URLs after social sign-ins and for generating URLs in emails.",
+ "TrustedOrigins": [],
+ "TrustedOrigins_Comment": "Lists the permitted origins for CORS requests, return URLs following social sign-in and email confirmation, etc., along with allowed origins for Web Auth.",
"ForwardedHeaders": {
"ForwardedHeaders": "All",
"ForwardedHeaders_Comment": "These values apply only if your backend is hosted behind a CDN (such as `Cloudflare`).",
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 81ac52e4c2..aa2b7b44b0 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
@@ -55,5 +55,8 @@ public interface IIdentityController : IAppController
Task VerifyWebAuthAssertion(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken);
[HttpPost]
- Task VerifyWebAuthAndSignIn(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken) => default!;
+ Task VerifyWebAuthAndSignIn(VerifyWebAuthnAndSignInDto request, CancellationToken cancellationToken) => default!;
+
+ [HttpPost]
+ Task VerifyWebAuthAndSendTwoFactorToken(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken);
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IUserController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IUserController.cs
index 720a75d8b6..9ecca46c8b 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IUserController.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IUserController.cs
@@ -57,4 +57,7 @@ public interface IUserController : IAppController
[HttpDelete]
Task DeleteWebAuthnCredential(byte[] credentialId, CancellationToken cancellationToken);
+
+ [HttpDelete]
+ Task DeleteAllWebAuthnCredentials(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 43783c773a..c28ab09a1e 100644
--- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs
@@ -14,6 +14,7 @@
//#if (notification == true)
using Boilerplate.Shared.Dtos.PushNotification;
//#endif
+using Boilerplate.Shared.Dtos.Identity;
using Boilerplate.Shared.Dtos.Statistics;
namespace Boilerplate.Shared.Dtos;
@@ -55,6 +56,7 @@ namespace Boilerplate.Shared.Dtos;
[JsonSerializable(typeof(AuthenticatorAttestationRawResponse))]
[JsonSerializable(typeof(CredentialCreateOptions))]
[JsonSerializable(typeof(VerifyAssertionResult))]
+[JsonSerializable(typeof(VerifyWebAuthnAndSignInDto))]
public partial class AppJsonContext : JsonSerializerContext
{
}
diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/VerifyWebAuthnAndSignInDto.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/VerifyWebAuthnAndSignInDto.cs
new file mode 100644
index 0000000000..65508939d0
--- /dev/null
+++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/VerifyWebAuthnAndSignInDto.cs
@@ -0,0 +1,10 @@
+using Fido2NetLib;
+
+namespace Boilerplate.Shared.Dtos.Identity;
+
+public partial class VerifyWebAuthnAndSignInDto
+{
+ public required AuthenticatorAssertionRawResponse ClientResponse { get; set; }
+
+ public string? TfaCode { get; set; }
+}