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; } +}