diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Boilerplate.Client.Core.csproj b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Boilerplate.Client.Core.csproj index 4f79e91dbc..2aa1798190 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Boilerplate.Client.Core.csproj +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Boilerplate.Client.Core.csproj @@ -23,6 +23,7 @@ + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor index fcc34547c7..314fac8e30 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor @@ -15,6 +15,7 @@ @@ -24,6 +25,7 @@ diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.scss b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.scss index 69b03d3b77..35d52815ed 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.scss +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Layout/MainLayout.razor.scss @@ -104,4 +104,12 @@ main { line-height: 16px; color: $bit-color-error; } + + .nav-panel { + width: 280px; + + @include lt-md { + width: 210px; + } + } } 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 new file mode 100644 index 0000000000..918afd886a --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor @@ -0,0 +1,23 @@ +@inherits AppComponentBase + +
+ + + @Localizer[nameof(AppStrings.PasswordlessTitle)] + +
+ @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/PasswordlessSection.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs new file mode 100644 index 0000000000..f23b7b0826 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs @@ -0,0 +1,83 @@ +using Fido2NetLib; +using Boilerplate.Shared.Dtos.Identity; +using Boilerplate.Shared.Controllers.Identity; + +namespace Boilerplate.Client.Core.Components.Pages.Authorized.Settings; + +public partial class PasswordlessSection +{ + private bool isConfigured; + + + [AutoInject] IUserController userController = default!; + [AutoInject] IIdentityController identityController = default!; + + + [Parameter] public UserDto? User { get; set; } + + protected override async Task OnParamsSetAsync() + { + await base.OnParamsSetAsync(); + + if (User?.UserName is null) return; + + isConfigured = await JSRuntime.IsWebAuthnConfigured(User.UserName); + } + + + private async Task EnablePasswordless() + { + if (User?.UserName is null) return; + + var options = await userController.GetWebAuthnCredentialOptions(CurrentCancellationToken); + + AuthenticatorAttestationRawResponse attestationResponse; + try + { + attestationResponse = await JSRuntime.CreateWebAuthnCredential(options); + } + catch (Exception ex) + { + // we can safely handle the exception thrown here since it mostly because of a timeout or user cancelling the native ui. + ExceptionHandler.Handle(ex, ExceptionDisplayKind.None); + return; + } + + await userController.CreateWebAuthnCredential(attestationResponse, CurrentCancellationToken); + + await JSRuntime.StoreWebAuthnConfigured(User.UserName); + + isConfigured = true; + + SnackBarService.Success(Localizer[nameof(AppStrings.EnablePasswordlessSucsessMessage)]); + } + + private async Task DisablePasswordless() + { + if (User?.UserName is null) return; + + var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken); + + AuthenticatorAssertionRawResponse assertion; + try + { + assertion = await JSRuntime.VerifyWebAuthnCredential(options); + } + catch (Exception ex) + { + // we can safely handle the exception thrown here since it mostly because of a timeout or user cancelling the native ui. + ExceptionHandler.Handle(ex, ExceptionDisplayKind.None); + return; + } + + var verifyResult = await identityController.VerifyWebAuthAssertion(assertion, CurrentCancellationToken); + + await userController.DeleteWebAuthnCredential(assertion.Id, CurrentCancellationToken); + + await JSRuntime.RemoveWebAuthnConfigured(User.UserName); + + isConfigured = false; + + SnackBarService.Success(Localizer[nameof(AppStrings.DisablePasswordlessSucsessMessage)]); + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.scss b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.scss new file mode 100644 index 0000000000..00b117f7f7 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.scss @@ -0,0 +1,5 @@ +section { + width: 100%; + display: flex; + justify-content: center; +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor index 107d336df7..16471b2221 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor @@ -20,13 +20,19 @@ Title="@Localizer[nameof(AppStrings.AccountTitle)]" Subtitle="@Localizer[nameof(AppStrings.AccountSubtitle)]"> - + - + - + @if (showPasswordless) + { + + + + } +
@Localizer[nameof(AppStrings.Delete)]
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 f7f1696d8b..6fb471140a 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 @@ -10,6 +10,9 @@ public partial class SettingsPage protected override string? Subtitle => string.Empty; + private bool showPasswordless; + + [Parameter] public string? Section { get; set; } @@ -31,6 +34,7 @@ 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; } finally { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor index 8ef87bf65d..bb2a7870ec 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPage.razor @@ -15,16 +15,26 @@ { @if (isOtpSent is false) { - + } else { - + } } else { - + } 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 47355167ef..62692817c0 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 @@ -1,4 +1,5 @@ //+:cnd:noEmit +using Fido2NetLib; using Boilerplate.Shared.Dtos.Identity; using Boilerplate.Shared.Controllers.Identity; @@ -93,7 +94,47 @@ protected override async Task OnInitAsync() } - private async Task SocialSignIn(string provider) + private async Task DoSignIn() + { + if (isWaiting) return; + if (isOtpSent && string.IsNullOrWhiteSpace(model.Otp)) return; + + isWaiting = true; + + try + { + if (requiresTwoFactor && string.IsNullOrWhiteSpace(model.TwoFactorCode)) return; + + CleanModel(); + + if (validatorRef?.EditContext.Validate() is false) return; + + model.DeviceInfo = telemetryContext.Platform; + + requiresTwoFactor = await AuthManager.SignIn(model, CurrentCancellationToken); + + if (requiresTwoFactor) + { + PubSubService.Publish(ClientPubSubMessages.UPDATE_IDENTITY_HEADER_BACK_LINK, TfaPayload); + } + else + { + NavigationManager.NavigateTo(ReturnUrlQueryString ?? Urls.HomePage, replace: true); + } + } + catch (KnownException e) + { + // To disable the sign-in button until a specific time after a user lockout, use the value of `e.TryGetExtensionDataValue("TryAgainIn", out var tryAgainIn)`. + + SnackBarService.Error(e.Message); + } + finally + { + isWaiting = false; + } + } + + private async Task HandleOnSocialSignIn(string provider) { try { @@ -109,38 +150,42 @@ private async Task SocialSignIn(string provider) } } - private async Task DoSignIn() + private async Task HandleOnPasswordlessSignIn() { if (isWaiting) return; - if (isOtpSent && string.IsNullOrWhiteSpace(model.Otp)) return; isWaiting = true; try { - if (requiresTwoFactor && string.IsNullOrWhiteSpace(model.TwoFactorCode)) return; - - CleanModel(); + var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken); - if (validatorRef?.EditContext.Validate() is false) return; - - model.DeviceInfo = telemetryContext.Platform; + AuthenticatorAssertionRawResponse assertion; + try + { + assertion = await JSRuntime.VerifyWebAuthnCredential(options); + } + catch (Exception ex) + { + // we can safely handle the exception thrown here since it mostly because of a timeout or user cancelling the native ui. + ExceptionHandler.Handle(ex, ExceptionDisplayKind.None); + return; + } - requiresTwoFactor = await AuthManager.SignIn(model, CurrentCancellationToken); + var response = await identityController.VerifyWebAuthAndSignIn(assertion, CurrentCancellationToken); - if (requiresTwoFactor is false) + if (response.RequiresTwoFactor) { - NavigationManager.NavigateTo(ReturnUrlQueryString ?? Urls.HomePage, replace: true); + PubSubService.Publish(ClientPubSubMessages.UPDATE_IDENTITY_HEADER_BACK_LINK, TfaPayload); } else { - PubSubService.Publish(ClientPubSubMessages.UPDATE_IDENTITY_HEADER_BACK_LINK, TfaPayload); + await AuthManager.StoreTokens(response!, model.RememberMe); + NavigationManager.NavigateTo(ReturnUrlQueryString ?? Urls.HomePage, replace: true); } } catch (KnownException e) { - // To disable the sign-in button until a specific time after a user lockout, use the value of `e.TryGetExtensionDataValue("TryAgainIn", out var tryAgainIn)`. - SnackBarService.Error(e.Message); } finally @@ -149,6 +194,13 @@ private async Task DoSignIn() } } + private void HandleOnSignInPanelTabChange(SignInPanelTab tab) + { + currentSignInPanelTab = tab; + } + + private Task HandleOnSendOtp() => SendOtp(false); + private Task HandleOnResendOtp() => SendOtp(true); private async Task SendOtp(bool resend) { try @@ -185,10 +237,8 @@ private async Task SendOtp(bool resend) SnackBarService.Error(e.Message); } } - private Task ResendOtp() => SendOtp(true); - private Task SendOtp() => SendOtp(false); - private async Task SendTfaToken() + private async Task HandleOnSendTfaToken() { try { @@ -204,11 +254,6 @@ private async Task SendTfaToken() } } - private void HandleOnSignInPanelTabChange(SignInPanelTab tab) - { - currentSignInPanelTab = tab; - } - private void CleanModel() { if (currentSignInPanelTab is SignInPanelTab.Email) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor index 66aea3d5f8..dfecd0c20f 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor @@ -62,7 +62,20 @@ - + + + + @if (isWebAuthnAvailable) + { + + } + @Localizer[nameof(AppStrings.SignIn)] diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs index 2488018b56..9d46cbf982 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs @@ -4,12 +4,22 @@ namespace Boilerplate.Client.Core.Components.Pages.Identity.SignIn; public partial class SignInPanel { + private const string EmailKey = nameof(EmailKey); + private const string PhoneKey = nameof(PhoneKey); + + + private bool isWebAuthnAvailable; + private string? selectedKey = EmailKey; + + [Parameter] public bool IsWaiting { get; set; } [Parameter] public SignInRequestDto Model { get; set; } = default!; [Parameter] public EventCallback OnSocialSignIn { get; set; } + [Parameter] public EventCallback OnPasswordlessSignIn { get; set; } + [Parameter] public EventCallback OnSendOtp { get; set; } [Parameter] public EventCallback OnTabChange { get; set; } @@ -18,10 +28,19 @@ public partial class SignInPanel public string? ReturnUrlQueryString { get; set; } - private const string EmailKey = nameof(EmailKey); - private const string PhoneKey = nameof(PhoneKey); + protected override async Task OnAfterFirstRenderAsync() + { + isWebAuthnAvailable = await JSRuntime.IsWebAuthnAvailable() && AppPlatform.IsBlazorHybrid is false; + StateHasChanged(); + + if (await JSRuntime.IsWebAuthnConfigured()) + { + await OnPasswordlessSignIn.InvokeAsync(); + } + + await base.OnAfterFirstRenderAsync(); + } - private string? selectedKey = EmailKey; private async Task HandleOnPivotChange(BitPivotItem item) { 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 new file mode 100644 index 0000000000..49be599d9f --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/IJSRuntimeWebAuthnExtensions.cs @@ -0,0 +1,37 @@ +//+:cnd:noEmit +using Fido2NetLib; + +namespace Microsoft.JSInterop; + +public static partial class IJSRuntimeWebAuthnExtensions +{ + public static ValueTask IsWebAuthnAvailable(this IJSRuntime jsRuntime) + { + return jsRuntime.InvokeAsync("WebAuthn.isAvailable"); + } + + public static ValueTask StoreWebAuthnConfigured(this IJSRuntime jsRuntime, string username) + { + return jsRuntime.InvokeVoidAsync("WebAuthn.storeConfigured", username); + } + + public static ValueTask IsWebAuthnConfigured(this IJSRuntime jsRuntime, string? username = null) + { + return jsRuntime.InvokeAsync("WebAuthn.isConfigured", username); + } + + public static ValueTask RemoveWebAuthnConfigured(this IJSRuntime jsRuntime, string username) + { + return jsRuntime.InvokeVoidAsync("WebAuthn.removeConfigured", username); + } + + public static ValueTask CreateWebAuthnCredential(this IJSRuntime jsRuntime, CredentialCreateOptions options) + { + return jsRuntime.InvokeAsync("WebAuthn.createCredential", options); + } + + public static ValueTask VerifyWebAuthnCredential(this IJSRuntime jsRuntime, AssertionOptions options) + { + return jsRuntime.InvokeAsync("WebAuthn.verifyCredential", 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 new file mode 100644 index 0000000000..d3bb2b27db --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebAuthn.ts @@ -0,0 +1,105 @@ +class WebAuthn { + private static STORE_KEY = 'configured-webauthn'; + + public static isAvailable() { + return !!window.PublicKeyCredential; + } + + public static storeConfigured(username: string) { + const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[]; + storedCredentials.push(username); + localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(storedCredentials)); + } + + public static isConfigured(username: string | undefined) { + const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[]; + return !!username ? storedCredentials.includes(username) : storedCredentials.length > 0; + } + + 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))); + } + + + public static async createCredential(options: PublicKeyCredentialCreationOptions) { + if (typeof options.challenge === 'string') { + options.challenge = WebAuthn.urlToArray(options.challenge); + } + + if (typeof options.user.id === 'string') { + options.user.id = WebAuthn.urlToArray(options.user.id); + } + + 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), + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults(), + response: { + attestationObject: WebAuthn.arrayToUrl(response.attestationObject), + clientDataJSON: WebAuthn.arrayToUrl(response.clientDataJSON), + transports: response.getTransports ? response.getTransports() : [] + } + }; + } + + public static async verifyCredential(options: PublicKeyCredentialRequestOptions) { + if (typeof options.challenge === 'string') { + options.challenge = WebAuthn.urlToArray(options.challenge); + } + + 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 { + id: credential.id, + rawId: WebAuthn.arrayToUrl(credential.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) + } + } + } + + + + private static arrayToUrl(value: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(value))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); + } + + private static urlToArray(value: string): Uint8Array { + return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)); + } + + private static base64ToUrl(value: string): string { + return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); + } +} + +(window as any).WebAuthn = WebAuthn; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/app.ts b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/app.ts index 317f5f45bd..0a5c0134e6 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/app.ts +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/app.ts @@ -35,7 +35,7 @@ class App { return; } - var script = document.createElement('script'); + const script = document.createElement('script'); script.src = "https://cdn.jsdelivr.net/npm/eruda"; document.body.append(script); script.onload = function () { @@ -76,6 +76,8 @@ class App { //#endif } +(window as any).App = App; + window.addEventListener('message', handleMessage); window.addEventListener('load', handleLoad); window.addEventListener('resize', setCssWindowSizes); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/wwwroot/images/bit-logo.png b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/wwwroot/images/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.Core/wwwroot/images/bit-logo.png differ diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props index bbe36321a6..b60c1045d1 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props @@ -9,6 +9,8 @@ + + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props index d0cf5f259c..a5ff3d6d3f 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props @@ -9,6 +9,8 @@ + + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj index d3829c84d8..3808572e0d 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Boilerplate.Server.Api.csproj @@ -15,6 +15,7 @@ + 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 new file mode 100644 index 0000000000..ba63186b45 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs @@ -0,0 +1,104 @@ +//+:cnd:noEmit +using System.Text; +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; + +public partial class IdentityController +{ + [AutoInject] private IFido2 fido2 = default!; + [AutoInject] private IDistributedCache cache = default!; + [AutoInject] protected JsonSerializerOptions jsonSerializerOptions = default!; + + + [HttpGet] + public async Task GetWebAuthnAssertionOptions(CancellationToken cancellationToken) + { + var existingKeys = new List(); + + var extensions = new AuthenticationExtensionsClientInputs + { + UserVerificationMethod = true, + Extensions = true + }; + + var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams + { + Extensions = extensions, + AllowedCredentials = existingKeys, + UserVerification = UserVerificationRequirement.Discouraged, + }); + + var key = new string([.. options.Challenge.Select(b => (char)b)]); + await cache.SetAsync(key, Encoding.UTF8.GetBytes(options.ToJson()), cancellationToken); + + return options; + } + + [HttpPost] + public async Task VerifyWebAuthAssertion(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken) + { + var (verifyResult, _) = await Verify(clientResponse, cancellationToken); + + return verifyResult; + } + + [HttpPost, Produces()] + public async Task VerifyWebAuthAndSignIn(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"); + + credential.SignCount = verifyResult.SignCount; + + DbContext.WebAuthnCredential.Update(credential); + + await DbContext.SaveChangesAsync(cancellationToken); + + await SignIn(new() { Otp = otp }, user, cancellationToken); + } + + + private async Task<(VerifyAssertionResult, WebAuthnCredential)> Verify(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken) + { + var response = JsonSerializer.Deserialize(clientResponse.Response.ClientDataJson, jsonSerializerOptions.GetTypeInfo()) + ?? throw new InvalidOperationException("Invalid client data."); + + var key = new string([.. response.Challenge.Select(b => (char)b)]); + var cachedBytes = await cache.GetAsync(key, cancellationToken) + ?? throw new ResourceNotFoundException(); + + var jsonOptions = Encoding.UTF8.GetString(cachedBytes); + var options = AssertionOptions.FromJson(jsonOptions); + + await cache.RemoveAsync(key, cancellationToken); + + var credential = (await DbContext.WebAuthnCredential.FirstOrDefaultAsync(c => c.Id == clientResponse.Id, cancellationToken)) + ?? throw new ResourceNotFoundException(); + + var verifyResult = await fido2.MakeAssertionAsync(new MakeAssertionParams + { + AssertionResponse = clientResponse, + OriginalOptions = options, + StoredPublicKey = credential.PublicKey!, + StoredSignatureCounter = credential.SignCount, + IsUserHandleOwnerOfCredentialIdCallback = IsUserHandleOwnerOfCredentialId + }, cancellationToken); + + return (verifyResult, credential); + } + + private async Task IsUserHandleOwnerOfCredentialId(IsUserHandleOwnerOfCredentialIdParams args, CancellationToken cancellationToken) + { + var storedCreds = await DbContext.WebAuthnCredential.Where(c => c.UserHandle == args.UserHandle).ToListAsync(cancellationToken); + return storedCreds.Exists(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, c.Id, c.Transports).Id.SequenceEqual(args.CredentialId)); + } +} 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 573dabddf0..353d70da57 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 @@ -91,9 +91,16 @@ public async Task SignUp(SignUpRequestDto request, CancellationToken cancellatio public async Task SignIn(SignInRequestDto request, CancellationToken cancellationToken) { request.PhoneNumber = phoneService.NormalizePhoneNumber(request.PhoneNumber); - signInManager.AuthenticationScheme = IdentityConstants.BearerScheme; - var user = await userManager.FindUserAsync(request) ?? throw new UnauthorizedException(Localizer[nameof(AppStrings.InvalidUserCredentials)]).WithData("Identifier", request); + var user = await userManager.FindUserAsync(request) + ?? throw new UnauthorizedException(Localizer[nameof(AppStrings.InvalidUserCredentials)]).WithData("Identifier", request); + + await SignIn(request, user, cancellationToken); + } + + private async Task SignIn(SignInRequestDto request, User user, CancellationToken cancellationToken) + { + signInManager.AuthenticationScheme = IdentityConstants.BearerScheme; var userSession = await CreateUserSession(user.Id, request.DeviceInfo, cancellationToken); 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 new file mode 100644 index 0000000000..51230b855a --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.WebAuthn.cs @@ -0,0 +1,124 @@ +//+:cnd:noEmit +using System.Text; +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; + +public partial class UserController +{ + [AutoInject] private IFido2 fido2 = default!; + [AutoInject] private IDistributedCache cache = default!; + + + [HttpGet] + public async Task GetWebAuthnCredentialOptions(CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + var user = await userManager.FindByIdAsync(userId.ToString()) + ?? 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 fidoUser = new Fido2User + { + Id = Encoding.UTF8.GetBytes(userId.ToString()), + Name = user.DisplayUserName, + DisplayName = user.DisplayName, + }; + + var options = fido2.RequestNewCredential(new RequestNewCredentialParams + { + User = fidoUser, + ExcludeCredentials = [.. existingKeys], + AuthenticatorSelection = AuthenticatorSelection.Default, + AttestationPreference = AttestationConveyancePreference.None, + Extensions = new AuthenticationExtensionsClientInputs + { + CredProps = true, + Extensions = true, + UserVerificationMethod = true, + } + }); + + var key = GetWebAuthnCacheKey(userId); + await cache.SetAsync(key, Encoding.UTF8.GetBytes(options.ToJson()), cancellationToken); + + return options; + } + + [HttpPut] + public async Task CreateWebAuthnCredential(AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + var user = await userManager.FindByIdAsync(userId.ToString()) + ?? throw new ResourceNotFoundException(); + + var key = GetWebAuthnCacheKey(userId); + var cachedBytes = await cache.GetAsync(key, cancellationToken) + ?? throw new ResourceNotFoundException(); + + var jsonOptions = Encoding.UTF8.GetString(cachedBytes); + var options = CredentialCreateOptions.FromJson(jsonOptions); + + var makeCredentialParams = new MakeNewCredentialParams + { + AttestationResponse = attestationResponse, + OriginalOptions = options, + IsCredentialIdUniqueToUserCallback = IsCredentialIdUniqueToUser + }; + + var credential = await fido2.MakeNewCredentialAsync(makeCredentialParams, cancellationToken); + + var newCredential = new WebAuthnCredential + { + UserId = userId, + Id = credential.Id, + PublicKey = credential.PublicKey, + UserHandle = credential.User.Id, + SignCount = credential.SignCount, + RegDate = DateTimeOffset.UtcNow, + AaGuid = credential.AaGuid, + Transports = credential.Transports, + AttestationFormat = credential.AttestationFormat, + IsBackupEligible = credential.IsBackupEligible, + IsBackedUp = credential.IsBackedUp, + AttestationObject = credential.AttestationObject, + AttestationClientDataJson = credential.AttestationClientDataJson, + }; + + await DbContext.WebAuthnCredential.AddAsync(newCredential, cancellationToken); + + await cache.RemoveAsync(key, cancellationToken); + + await DbContext.SaveChangesAsync(cancellationToken); + } + + [HttpDelete] + public async Task DeleteWebAuthnCredential(byte[] credentialId, CancellationToken cancellationToken) + { + var userId = User.GetUserId(); + var user = await userManager.FindByIdAsync(userId.ToString()) + ?? throw new ResourceNotFoundException(); + + var entityToDelete = await DbContext.WebAuthnCredential.FindAsync([credentialId], cancellationToken) + ?? throw new ResourceNotFoundException(); + + DbContext.WebAuthnCredential.Remove(entityToDelete); + + await DbContext.SaveChangesAsync(cancellationToken); + } + + private static string GetWebAuthnCacheKey(Guid userId) => $"WebAuthn_Options_{userId}"; + + private async Task IsCredentialIdUniqueToUser(IsCredentialIdUniqueToUserParams args, CancellationToken cancellationToken) + { + var count = await DbContext.WebAuthnCredential.CountAsync(c => c.Id == args.CredentialId, cancellationToken); + return count <= 0; + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/AppDbContext.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/AppDbContext.cs index aa746d94f2..9822b89e63 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/AppDbContext.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/AppDbContext.cs @@ -37,6 +37,8 @@ public partial class AppDbContext(DbContextOptions options) public DbSet PushNotificationSubscriptions { get; set; } = default!; //#endif + public DbSet WebAuthnCredential { get; set; } = default!; + protected override void OnModelCreating(ModelBuilder modelBuilder) { base.OnModelCreating(modelBuilder); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Configurations/Identity/WebAuthnCredentialConfiguration.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Configurations/Identity/WebAuthnCredentialConfiguration.cs new file mode 100644 index 0000000000..c0469f13ea --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Configurations/Identity/WebAuthnCredentialConfiguration.cs @@ -0,0 +1,14 @@ +using Boilerplate.Server.Api.Models.Identity; + +namespace Boilerplate.Server.Api.Data.Configurations.Identity; + +public class WebAuthnCredentialConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasOne(t => t.User) + .WithMany(u => u.WebAuthnCredentials) + .HasForeignKey(t => t.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250303200825_InitialMigration.Designer.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.Designer.cs similarity index 98% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250303200825_InitialMigration.Designer.cs rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.Designer.cs index 76d018c1e7..51f46b9bcd 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250303200825_InitialMigration.Designer.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.Designer.cs @@ -11,7 +11,7 @@ namespace Boilerplate.Server.Api.Data.Migrations; [DbContext(typeof(AppDbContext))] -[Migration("20250303200825_InitialMigration")] +[Migration("20250311112214_InitialMigration")] partial class InitialMigration { /// @@ -274,6 +274,54 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.ToTable("UserSessions"); }); + modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("BLOB"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("AttestationClientDataJson") + .HasColumnType("BLOB"); + + b.Property("AttestationFormat") + .HasColumnType("TEXT"); + + b.Property("AttestationObject") + .HasColumnType("BLOB"); + + b.Property("IsBackedUp") + .HasColumnType("INTEGER"); + + b.Property("IsBackupEligible") + .HasColumnType("INTEGER"); + + b.Property("PublicKey") + .HasColumnType("BLOB"); + + b.Property("RegDate") + .HasColumnType("INTEGER"); + + b.Property("SignCount") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("Transports") + .HasColumnType("TEXT"); + + b.Property("UserHandle") + .HasColumnType("BLOB"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential"); + }); + modelBuilder.Entity("Boilerplate.Server.Api.Models.Products.Product", b => { b.Property("Id") @@ -2039,6 +2087,17 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.WebAuthnCredential", b => + { + b.HasOne("Boilerplate.Server.Api.Models.Identity.User", "User") + .WithMany("WebAuthnCredentials") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Boilerplate.Server.Api.Models.Products.Product", b => { b.HasOne("Boilerplate.Server.Api.Models.Categories.Category", "Category") @@ -2132,6 +2191,8 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) b.Navigation("Sessions"); b.Navigation("TodoItems"); + + b.Navigation("WebAuthnCredentials"); }); modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.UserSession", b => diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250303200825_InitialMigration.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.cs similarity index 96% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250303200825_InitialMigration.cs rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.cs index 94f794a3f2..a714ff1cc4 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250303200825_InitialMigration.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.cs @@ -264,6 +264,35 @@ protected override void Up(MigrationBuilder migrationBuilder) onDelete: ReferentialAction.Cascade); }); + migrationBuilder.CreateTable( + name: "WebAuthnCredential", + columns: table => new + { + Id = table.Column(type: "BLOB", nullable: false), + UserId = table.Column(type: "TEXT", nullable: false), + PublicKey = table.Column(type: "BLOB", nullable: true), + SignCount = table.Column(type: "INTEGER", nullable: false), + Transports = table.Column(type: "TEXT", nullable: true), + IsBackupEligible = table.Column(type: "INTEGER", nullable: false), + IsBackedUp = table.Column(type: "INTEGER", nullable: false), + AttestationObject = table.Column(type: "BLOB", nullable: true), + AttestationClientDataJson = table.Column(type: "BLOB", nullable: true), + UserHandle = table.Column(type: "BLOB", nullable: true), + AttestationFormat = table.Column(type: "TEXT", nullable: true), + RegDate = table.Column(type: "INTEGER", nullable: false), + AaGuid = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_WebAuthnCredential", x => x.Id); + table.ForeignKey( + name: "FK_WebAuthnCredential_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + migrationBuilder.CreateTable( name: "PushNotificationSubscriptions", columns: table => new @@ -542,6 +571,11 @@ protected override void Up(MigrationBuilder migrationBuilder) name: "IX_UserSessions_UserId", table: "UserSessions", column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_WebAuthnCredential_UserId", + table: "WebAuthnCredential", + column: "UserId"); } /// @@ -574,6 +608,9 @@ protected override void Down(MigrationBuilder migrationBuilder) migrationBuilder.DropTable( name: "UserTokens"); + migrationBuilder.DropTable( + name: "WebAuthnCredential"); + migrationBuilder.DropTable( name: "Categories"); diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/AppDbContextModelSnapshot.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/AppDbContextModelSnapshot.cs index c0f6033569..63e8f74eea 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/AppDbContextModelSnapshot.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/AppDbContextModelSnapshot.cs @@ -271,6 +271,54 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("UserSessions"); }); + modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.WebAuthnCredential", b => + { + b.Property("Id") + .HasColumnType("BLOB"); + + b.Property("AaGuid") + .HasColumnType("TEXT"); + + b.Property("AttestationClientDataJson") + .HasColumnType("BLOB"); + + b.Property("AttestationFormat") + .HasColumnType("TEXT"); + + b.Property("AttestationObject") + .HasColumnType("BLOB"); + + b.Property("IsBackedUp") + .HasColumnType("INTEGER"); + + b.Property("IsBackupEligible") + .HasColumnType("INTEGER"); + + b.Property("PublicKey") + .HasColumnType("BLOB"); + + b.Property("RegDate") + .HasColumnType("INTEGER"); + + b.Property("SignCount") + .HasColumnType("INTEGER"); + + b.PrimitiveCollection("Transports") + .HasColumnType("TEXT"); + + b.Property("UserHandle") + .HasColumnType("BLOB"); + + b.Property("UserId") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("WebAuthnCredential"); + }); + modelBuilder.Entity("Boilerplate.Server.Api.Models.Products.Product", b => { b.Property("Id") @@ -2036,6 +2084,17 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("User"); }); + modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.WebAuthnCredential", b => + { + b.HasOne("Boilerplate.Server.Api.Models.Identity.User", "User") + .WithMany("WebAuthnCredentials") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + modelBuilder.Entity("Boilerplate.Server.Api.Models.Products.Product", b => { b.HasOne("Boilerplate.Server.Api.Models.Categories.Category", "Category") @@ -2129,6 +2188,8 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Navigation("Sessions"); b.Navigation("TodoItems"); + + b.Navigation("WebAuthnCredentials"); }); modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.UserSession", b => diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Extensions/SignInManagerExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Extensions/SignInManagerExtensions.cs index bdfcc0a7d2..174ebc4e0d 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Extensions/SignInManagerExtensions.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Extensions/SignInManagerExtensions.cs @@ -16,8 +16,9 @@ public static partial class SignInManagerExtensions /// 4. After a successful phone number confirmation after sign-up, to automatically sign in the confirmed user for a smoother user experience. /// 5. When the browser is redirected to a magic link created after a social sign-in, to automatically authenticate the user. /// 6. When the user opts to sign in using a 6-digit code delivered via native push notification, web push or SignalR message (if configured). + /// 7. When the system opts to sign in the user using a 6-digit code generated after a successful WebAuthn process. /// - /// It's important to clarify the authentication method (e.g., Social, SMS, Email, or Push) + /// It's important to clarify the authentication method (e.g., Social, Email, SMS, Push, Social, or WebAuth) /// to avoid sending a second step to the same communication channel: For successful two-step authentication, the user must use a different method for the second step. /// @@ -38,12 +39,15 @@ public static partial class SignInManagerExtensions bool tokenIsValid = false; string? authenticationMethod = null; - string[] authenticationMethods = ["Email", + string[] authenticationMethods = [ + "Email", "Sms", //#if (notification == true || signalR == true) "Push", // => Native push notification, web push or SignalR message. //#endif - "Social"]; + "Social", + "WebAuthn" + ]; foreach (var authMethod in authenticationMethods) { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/User.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/User.cs index 191557d1cc..f75d78cb6a 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/User.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/User.cs @@ -10,7 +10,8 @@ public partial class User : IdentityUser [PersonalData] public string? FullName { get; set; } - public string? DisplayName => FullName ?? Email ?? PhoneNumber ?? UserName; + public string? DisplayName => FullName ?? DisplayUserName; + public string? DisplayUserName => FullName ?? Email ?? PhoneNumber ?? UserName; [PersonalData] public Gender? Gender { get; set; } @@ -44,4 +45,6 @@ public partial class User : IdentityUser //#if (sample == true) public List TodoItems { get; set; } = []; //#endif + + public List WebAuthnCredentials { get; set; } = []; } 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 new file mode 100644 index 0000000000..a44563d2cc --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/WebAuthnCredential.cs @@ -0,0 +1,35 @@ +using Fido2NetLib.Objects; + +namespace Boilerplate.Server.Api.Models.Identity; + +public class WebAuthnCredential +{ + public required byte[] Id { get; set; } + public Guid UserId { get; set; } + + [ForeignKey(nameof(UserId))] + public User? User { get; set; } + + public byte[]? PublicKey { get; set; } + + public uint SignCount { get; set; } + + public AuthenticatorTransport[]? Transports { get; set; } + + public bool IsBackupEligible { get; set; } + + public bool IsBackedUp { get; set; } + + public byte[]? AttestationObject { get; set; } + + public byte[]? AttestationClientDataJson { get; set; } + + public byte[]? UserHandle { get; set; } + + public string? AttestationFormat { get; set; } + + public DateTimeOffset RegDate { get; set; } + + public Guid AaGuid { 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 72f623f994..b6cdbdf3f8 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 @@ -298,6 +298,24 @@ void AddDbContext(DbContextOptionsBuilder options) c.Timeout = TimeSpan.FromSeconds(10); c.BaseAddress = new Uri("https://api.cloudflare.com/client/v4/zones/"); }); + + 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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"; + }); } private static void AddIdentity(WebApplicationBuilder builder) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/GoogleRecaptchaService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/GoogleRecaptchaService.cs index 87098fc6ed..7cc93f9085 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/GoogleRecaptchaService.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/GoogleRecaptchaService.cs @@ -6,6 +6,8 @@ public partial class GoogleRecaptchaService [AutoInject] protected HttpClient httpClient = default!; + [AutoInject] protected JsonSerializerOptions jsonSerializerOptions = default!; + public virtual async ValueTask Verify(string? googleRecaptchaResponse, CancellationToken cancellationToken) { if (string.IsNullOrWhiteSpace(googleRecaptchaResponse)) return false; @@ -15,7 +17,7 @@ public virtual async ValueTask Verify(string? googleRecaptchaResponse, Can response.EnsureSuccessStatusCode(); - var result = await response.Content.ReadFromJsonAsync(ServerJsonContext.Default.GoogleRecaptchaVerificationResponse, cancellationToken); + var result = await response.Content.ReadFromJsonAsync(jsonSerializerOptions.GetTypeInfo(), cancellationToken); return result?.Success is true; } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/NugetStatisticsService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/NugetStatisticsService.cs index fe032f9aae..a34c1cfe0c 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/NugetStatisticsService.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/NugetStatisticsService.cs @@ -5,12 +5,13 @@ namespace Boilerplate.Server.Api.Services; public partial class NugetStatisticsService { [AutoInject] protected HttpClient httpClient = default!; + [AutoInject] protected JsonSerializerOptions jsonSerializerOptions = default!; public virtual async ValueTask GetPackageStats(string packageId, CancellationToken cancellationToken) { var url = $"/query?q=packageid:{packageId}"; - var response = await httpClient.GetFromJsonAsync(url, ServerJsonContext.Default.Options.GetTypeInfo(), cancellationToken) + var response = await httpClient.GetFromJsonAsync(url, jsonSerializerOptions.GetTypeInfo(), cancellationToken) ?? throw new ResourceNotFoundException(); return response; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/ServerJsonContext.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/ServerJsonContext.cs index 2edc8f93ad..826d70cc16 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/ServerJsonContext.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Services/ServerJsonContext.cs @@ -1,4 +1,5 @@ //+:cnd:noEmit +using Fido2NetLib; using Boilerplate.Shared.Dtos.Statistics; namespace Boilerplate.Server.Api.Services; @@ -12,6 +13,7 @@ namespace Boilerplate.Server.Api.Services; [JsonSerializable(typeof(GoogleRecaptchaVerificationResponse))] //#endif [JsonSerializable(typeof(ProblemDetails))] +[JsonSerializable(typeof(AuthenticatorResponse))] public partial class ServerJsonContext : JsonSerializerContext { } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/Services/WebServerExceptionHandler.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/Services/WebServerExceptionHandler.cs index 6ae9d33dc8..67a8fe1aac 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/Services/WebServerExceptionHandler.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Web/Services/WebServerExceptionHandler.cs @@ -10,6 +10,11 @@ public partial class WebServerExceptionHandler : ClientExceptionHandlerBase protected override void Handle(Exception exception, ExceptionDisplayKind displayKind, Dictionary parameters) { + exception = UnWrapException(exception); + + if (IgnoreException(exception)) + return; + if (httpContextAccessor.HttpContext is not null && httpContextAccessor.HttpContext.Response.HasStarted is false) { // This method is invoked for exceptions occurring in Blazor Server and during pre-rendering. diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Boilerplate.Shared.csproj b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Boilerplate.Shared.csproj index cad31eb752..d411716bf5 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Boilerplate.Shared.csproj +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Boilerplate.Shared.csproj @@ -6,6 +6,7 @@ + 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 653e263da7..81ac52e4c2 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 @@ -1,4 +1,6 @@ //+:cnd:noEmit +using Fido2NetLib; +using Fido2NetLib.Objects; using Boilerplate.Shared.Dtos.Identity; namespace Boilerplate.Shared.Controllers.Identity; @@ -45,4 +47,13 @@ public interface IIdentityController : IAppController [HttpGet("{?provider,returnUrl,localHttpPort}")] Task GetSocialSignInUri(string provider, string? returnUrl = null, int? localHttpPort = null, CancellationToken cancellationToken = default); + + [HttpGet] + Task GetWebAuthnAssertionOptions(CancellationToken cancellationToken); + + [HttpPost] + Task VerifyWebAuthAssertion(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken); + + [HttpPost] + Task VerifyWebAuthAndSignIn(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken) => default!; } 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 9796336df9..720a75d8b6 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 @@ -1,4 +1,5 @@ -using Boilerplate.Shared.Dtos.Identity; +using Fido2NetLib; +using Boilerplate.Shared.Dtos.Identity; namespace Boilerplate.Shared.Controllers.Identity; @@ -47,4 +48,13 @@ public interface IUserController : IAppController [HttpPost] Task SendElevatedAccessToken(CancellationToken cancellationToken); + + [HttpGet] + Task GetWebAuthnCredentialOptions(CancellationToken cancellationToken); + + [HttpPut] + Task CreateWebAuthnCredential(AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken); + + [HttpDelete] + Task DeleteWebAuthnCredential(byte[] credentialId, 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 cd671f406d..43783c773a 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs @@ -1,4 +1,6 @@ //+:cnd:noEmit +using Fido2NetLib; +using Fido2NetLib.Objects; //#if (sample == true) using Boilerplate.Shared.Dtos.Todo; //#endif @@ -48,6 +50,11 @@ namespace Boilerplate.Shared.Dtos; [JsonSerializable(typeof(OverallAnalyticsStatsDataResponseDto))] [JsonSerializable(typeof(List))] //#endif +[JsonSerializable(typeof(AssertionOptions))] +[JsonSerializable(typeof(AuthenticatorAssertionRawResponse))] +[JsonSerializable(typeof(AuthenticatorAttestationRawResponse))] +[JsonSerializable(typeof(CredentialCreateOptions))] +[JsonSerializable(typeof(VerifyAssertionResult))] public partial class AppJsonContext : JsonSerializerContext { } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/UserDto.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/UserDto.cs index 64f7c06cd2..145f5bced2 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/UserDto.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/Identity/UserDto.cs @@ -35,7 +35,8 @@ public partial class UserDto : IValidatableObject public string? ConcurrencyStamp { get; set; } - public string? DisplayName => FullName ?? Email ?? PhoneNumber ?? UserName; + public string? DisplayName => FullName ?? DisplayUserName; + public string? DisplayUserName => FullName ?? Email ?? PhoneNumber ?? UserName; public string? GetProfileImageUrl(Uri absoluteServerAddress) { diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx index c9bff9c46a..b6917afe49 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx @@ -526,7 +526,7 @@ حساب کاربری - ایمیل یا شماره تلفن حساب کاربری خود را تغییر دهید + ایمیل، شماره تلفن یا تنظیمات بی رمز حساب کاربری خود را تغییر دهید حذف حساب @@ -1210,4 +1210,22 @@ محصولات در همین دسته + + بی رمز + + + ورود بی رمز + + + فعالسازی ورود بی رمز + + + ورود بی رمز با موفقیت فعال شد + + + غیرفعالسازی ورود بی رمز + + + ورود بی رمز با موفقیت غیرفعال شد + \ No newline at end of file diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx index a8017fb3ae..b5820313ce 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx @@ -526,7 +526,7 @@ Account - Het e-mailadres of telefoonnummer van je account wijzigen + Wijzig uw account-e-mailadres, telefoonnummer of wachtwoordloze instellingen Account verwijderen @@ -1210,4 +1210,22 @@ Producten in dezelfde categorie + + Wachtwoordloos + + + Wachtwoordloos inloggen + + + Wachtwoordloos inloggen inschakelen + + + Wachtwoordloos inloggen succesvol ingeschakeld + + + Wachtwoordloos inloggen uitschakelen + + + Wachtwoordloos inloggen succesvol uitgeschakeld + \ No newline at end of file diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx index f70fca4b32..85a9c5bb43 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx @@ -526,7 +526,7 @@ Account - Change your account email or phone number + Change your account email, phone number, or passwordless settings Delete Account @@ -1210,4 +1210,22 @@ Products in the same category + + Passwordless + + + Passwordless login + + + Enable passwordless login + + + Passwordless login enabled successfully + + + Disable passwordless login + + + Passwordless login disabled successfully + \ No newline at end of file diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/FakeGoogleRecaptchaService.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/FakeGoogleRecaptchaService.cs index cf2209d96e..bd54f6a57d 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/FakeGoogleRecaptchaService.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Tests/Services/FakeGoogleRecaptchaService.cs @@ -4,7 +4,7 @@ namespace Boilerplate.Tests.Services; public partial class FakeGoogleRecaptchaService : GoogleRecaptchaService { - public FakeGoogleRecaptchaService() : base(null, null) { } + public FakeGoogleRecaptchaService() : base(null, null, null) { } public override ValueTask Verify(string? googleRecaptchaResponse, CancellationToken cancellationToken) {