From 76fe85b7cbfe418a895d14ea82d2b5b296a658ee Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Fri, 7 Mar 2025 18:02:58 +0330 Subject: [PATCH 01/17] fist commit --- .../Boilerplate.Client.Core.csproj | 1 + .../IJSRuntimeWebAuthnExtensions.cs | 23 ++ .../Scripts/webAuthn.ts | 84 ++++++ .../Boilerplate.Client.Core/tsconfig.json | 3 +- .../src/Directory.Packages.props | 207 +++++++-------- .../Boilerplate.Server.Api.csproj | 2 + .../Identity/WebAuthnController.cs | 243 ++++++++++++++++++ .../Data/AppDbContext.cs | 2 + .../Models/Identity/WebAuthnCredential.cs | 36 +++ .../Identity/IWebAuthnController.cs | 7 + 10 files changed, 505 insertions(+), 103 deletions(-) create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/IJSRuntimeWebAuthnExtensions.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/webAuthn.ts create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/WebAuthnController.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/WebAuthnCredential.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IWebAuthnController.cs 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/Extensions/IJSRuntimeWebAuthnExtensions.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/IJSRuntimeWebAuthnExtensions.cs new file mode 100644 index 0000000000..a3ffd235a0 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Extensions/IJSRuntimeWebAuthnExtensions.cs @@ -0,0 +1,23 @@ +//+: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 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..e81dd59c2e --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/webAuthn.ts @@ -0,0 +1,84 @@ +class WebAuthn { + public static isAvailable() { + return !!window.PublicKeyCredential; + } + + public static async createCredential(options: PublicKeyCredentialCreationOptions) { + if (typeof options.challenge === 'string') { + options.challenge = WebAuthn.fromBase64Url(options.challenge); + } + + if (typeof options.user.id === 'string') { + options.user.id = WebAuthn.fromBase64Url(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.fromBase64Url(cred.id); + } + + var credential = await navigator.credentials.create({ publicKey: options }) as PublicKeyCredential; + const response = credential.response as AuthenticatorAttestationResponse; + + return { + id: WebAuthn.base64StringToUrl(credential.id), + rawId: WebAuthn.toBase64Url(credential.rawId), + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults(), + response: { + attestationObject: WebAuthn.toBase64Url(response.attestationObject), + clientDataJSON: WebAuthn.toBase64Url(response.clientDataJSON), + transports: response.getTransports ? response.getTransports() : [] + } + }; + } + + public static async verifyCredential(options: PublicKeyCredentialRequestOptions) { + if (typeof options.challenge === 'string') { + options.challenge = WebAuthn.fromBase64Url(options.challenge); + } + + if (options.allowCredentials) { + for (var i = 0; i < options.allowCredentials.length; i++) { + const id = options.allowCredentials[i].id; + if (typeof id === 'string') { + options.allowCredentials[i].id = WebAuthn.fromBase64Url(id); + } + } + } + var credential = await navigator.credentials.get({ publicKey: options }) as PublicKeyCredential; + const response = credential.response as AuthenticatorAssertionResponse; + + return { + id: credential.id, + rawId: WebAuthn.toBase64Url(credential.rawId), + type: credential.type, + clientExtensionResults: credential.getClientExtensionResults(), + response: { + authenticatorData: WebAuthn.toBase64Url(response.authenticatorData), + clientDataJSON: WebAuthn.toBase64Url(response.clientDataJSON), + userHandle: response.userHandle && response.userHandle.byteLength > 0 ? WebAuthn.toBase64Url(response.userHandle) : undefined, + signature: WebAuthn.toBase64Url(response.signature) + } + } + } + + + + private static toBase64Url(arrayBuffer: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); + } + + private static fromBase64Url(value: string): Uint8Array { + return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)); + } + + private static base64StringToUrl(base64String: string): string { + return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); + } +} \ No newline at end of file diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/tsconfig.json b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/tsconfig.json index 1e38659c56..862613dd3f 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/tsconfig.json +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/tsconfig.json @@ -4,6 +4,7 @@ "strict": true, "noImplicitAny": true, "lib": [ "DOM", "ESNext" ], - "outFile": "wwwroot/scripts/app.js" + "outFile": "wwwroot/scripts/app.js", + "target": "ES2022" // same as the ASP.NET config here: https://github.com/dotnet/aspnetcore/blob/main/src/Components/Shared.JS/tsconfig.json } } \ No newline at end of file diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props index a2adab3101..c336e6342d 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages.props @@ -1,104 +1,107 @@  - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file 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..7302609dac 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,8 @@ + + diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/WebAuthnController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/WebAuthnController.cs new file mode 100644 index 0000000000..e4f8c784ac --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/WebAuthnController.cs @@ -0,0 +1,243 @@ +//+:cnd:noEmit +using System.IdentityModel.Tokens.Jwt; +using System.Text; +using Boilerplate.Server.Api.Models.Identity; +using Boilerplate.Shared.Controllers.Identity; +using Fido2NetLib; +using Fido2NetLib.Objects; + +namespace Boilerplate.Server.Api.Controllers.Identity; + +[ApiController, Route("api/[controller]/[action]")] +public partial class WebAuthnController : AppControllerBase, IWebAuthnController +{ + [AutoInject] private IFido2 fido2 = default!; + [AutoInject] private UserManager userManager = default!; + + + [HttpGet] + public async Task GetCredentialOptions() + { + try + { + var userId = User.GetUserId(); + var user = await userManager.FindByIdAsync(userId.ToString()) + ?? throw new ResourceNotFoundException("User"); + + var fidoUser = new Fido2User + { + Id = Encoding.UTF8.GetBytes(user.Id.ToString()), + Name = user.Email ?? user.PhoneNumber ?? user.UserName, + DisplayName = user.DisplayName + }; + var existingCredentials = DbContext.WebAuthnCredential.Where(c => c.UserId == user.Id); + var existingKeys = existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, c.Id, c.Transports)); + + var options = fido2.RequestNewCredential(new RequestNewCredentialParams + { + User = fidoUser, + ExcludeCredentials = existingKeys.ToList(), + AuthenticatorSelection = AuthenticatorSelection.Default, + AttestationPreference = AttestationConveyancePreference.None, + Extensions = new AuthenticationExtensionsClientInputs + { + Extensions = true, + UserVerificationMethod = true, + CredProps = true + } + }); + + _pendingCredentials[key] = options; + + // 6. return options to client + return options; + } + catch (Exception) + { + throw; + } + } + + /// + /// Creates a new credential for a user. + /// + /// Username of registering user. If usernameless, use base64 encoded options.User.Name from the credential-options used to create the credential. + /// + /// + /// a string containing either "OK" or an error message. + [HttpPut("{username}/credential")] + public async Task CreateCredentialAsync([FromRoute] string username, [FromBody] AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken) + { + try + { + // 1. Get the options we sent the client + var options = _pendingCredentials[username]; + + // 2. Create callback so that lib can verify credential id is unique to this user + + // 3. Verify and make the credentials + var credential = await fido2.MakeNewCredentialAsync(new MakeNewCredentialParams + { + AttestationResponse = attestationResponse, + OriginalOptions = options, + IsCredentialIdUniqueToUserCallback = CredentialIdUniqueToUserAsync + }, cancellationToken: cancellationToken); + + // 4. Store the credentials in db + _demoStorage.AddCredentialToUser(options.User, new StoredCredential + { + + AttestationFormat = credential.AttestationFormat, + Id = credential.Id, + PublicKey = credential.PublicKey, + UserHandle = credential.User.Id, + SignCount = credential.SignCount, + RegDate = DateTimeOffset.UtcNow, + AaGuid = credential.AaGuid, + Transports = credential.Transports, + IsBackupEligible = credential.IsBackupEligible, + IsBackedUp = credential.IsBackedUp, + AttestationObject = credential.AttestationObject, + AttestationClientDataJson = credential.AttestationClientDataJson, + }); + + // 5. Now we need to remove the options from the pending dictionary + _pendingCredentials.Remove(Request.Host.ToString()); + + // 5. return OK to client + return "OK"; + } + catch (Exception e) + { + return FormatException(e); + } + } + + private static async Task CredentialIdUniqueToUserAsync(IsCredentialIdUniqueToUserParams args, CancellationToken cancellationToken) + { + var users = await _demoStorage.GetUsersByCredentialIdAsync(args.CredentialId, cancellationToken); + return users.Count <= 0; + } + + [HttpGet("{username}/assertion-options")] + [HttpGet("assertion-options")] + public AssertionOptions MakeAssertionOptions([FromRoute] string? username, [FromQuery] UserVerificationRequirement? userVerification) + { + try + { + var existingKeys = new List(); + if (!string.IsNullOrEmpty(username)) + { + // 1. Get user and their credentials from DB + var user = _demoStorage.GetUser(username); + + if (user != null) + existingKeys = _demoStorage.GetCredentialsByUser(user).Select(c => c.Descriptor).ToList(); + } + + var exts = new AuthenticationExtensionsClientInputs + { + UserVerificationMethod = true, + Extensions = true + }; + + // 2. Create options (usernameless users will be prompted by their device to select a credential from their own list) + var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams + { + AllowedCredentials = existingKeys, + UserVerification = userVerification ?? UserVerificationRequirement.Discouraged, + Extensions = exts + }); + + // 4. Temporarily store options, session/in-memory cache/redis/db + _pendingAssertions[new string(options.Challenge.Select(b => (char)b).ToArray())] = options; + + // 5. return options to client + return options; + } + catch (Exception) + { + throw; + } + } + + /// + /// Verifies an assertion response from a client, generating a new JWT for the user. + /// + /// The client's authenticator's response to the challenge. + /// + /// + /// Either a new JWT header or an error message. + /// Example successful response: + /// "Bearer eyyylmaooimtotallyatoken" + /// Example error response: + /// "Error: Invalid assertion" + /// + [HttpPost("assertion")] + public async Task MakeAssertionAsync([FromBody] AuthenticatorAssertionRawResponse clientResponse, + CancellationToken cancellationToken) + { + try + { + // 1. Get the assertion options we sent the client remove them from memory so they can't be used again + var response = JsonSerializer.Deserialize(clientResponse.Response.ClientDataJson); + if (response is null) + { + return "Error: Could not deserialize client data"; + } + + var key = new string(response.Challenge.Select(b => (char)b).ToArray()); + if (!_pendingAssertions.TryGetValue(key, out var options)) + { + return "Error: Challenge not found, please get a new one via GET /{username?}/assertion-options"; + } + _pendingAssertions.Remove(key); + + // 2. Get registered credential from database + var creds = _demoStorage.GetCredentialById(clientResponse.Id) ?? throw new Exception("Unknown credentials"); + + // 3. Make the assertion + var res = await fido2.MakeAssertionAsync(new MakeAssertionParams + { + AssertionResponse = clientResponse, + OriginalOptions = options, + StoredPublicKey = creds.PublicKey, + StoredSignatureCounter = creds.SignCount, + IsUserHandleOwnerOfCredentialIdCallback = UserHandleOwnerOfCredentialIdAsync + }, cancellationToken: cancellationToken); + + // 4. Store the updated counter + _demoStorage.UpdateCounter(res.CredentialId, res.SignCount); + + + // 5. return result to client + var handler = new JwtSecurityTokenHandler(); + var token = handler.CreateEncodedJwt( + HttpContext.Request.Host.Host, + HttpContext.Request.Headers.Referer, + new ClaimsIdentity(new Claim[] { new(ClaimTypes.Actor, Encoding.UTF8.GetString(creds.UserHandle)) }), + DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), + DateTime.Now.AddDays(1), + DateTime.Now, + _signingCredentials, + null); + + if (token is null) + { + return "Error: Token couldn't be created"; + } + + return $"Bearer {token}"; + } + catch (Exception e) + { + return $"Error: {FormatException(e)}"; + } + } + + private static async Task UserHandleOwnerOfCredentialIdAsync(IsUserHandleOwnerOfCredentialIdParams args, CancellationToken cancellationToken) + { + var storedCreds = await _demoStorage.GetCredentialsByUserHandleAsync(args.UserHandle, cancellationToken); + return storedCreds.Exists(c => c.Descriptor.Id.SequenceEqual(args.CredentialId)); + } +} 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/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..2270429ada --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/WebAuthnCredential.cs @@ -0,0 +1,36 @@ +using Fido2NetLib.Objects; + +namespace Boilerplate.Server.Api.Models.Identity; + +public class WebAuthnCredential +{ + public Guid UserId { get; set; } + + [ForeignKey(nameof(UserId))] + public User? User { get; set; } + + public required byte[] Id { 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/Shared/Controllers/Identity/IWebAuthnController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IWebAuthnController.cs new file mode 100644 index 0000000000..f348c8feae --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IWebAuthnController.cs @@ -0,0 +1,7 @@ +//+:cnd:noEmit +namespace Boilerplate.Shared.Controllers.Identity; + +[Route("api/[controller]/[action]/")] +public interface IWebAuthnController : IAppController +{ +} From 5e84234e8ff965621790b95a32c547abdf71d279 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Fri, 7 Mar 2025 20:26:02 +0330 Subject: [PATCH 02/17] 2nd commit --- .../Identity/WebAuthnController.cs | 152 ++++++++---------- .../Program.Services.cs | 8 + 2 files changed, 75 insertions(+), 85 deletions(-) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/WebAuthnController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/WebAuthnController.cs index e4f8c784ac..bfa4706e51 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/WebAuthnController.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/WebAuthnController.cs @@ -1,10 +1,11 @@ //+:cnd:noEmit -using System.IdentityModel.Tokens.Jwt; using System.Text; -using Boilerplate.Server.Api.Models.Identity; -using Boilerplate.Shared.Controllers.Identity; +using System.IdentityModel.Tokens.Jwt; +using Microsoft.Extensions.Caching.Distributed; using Fido2NetLib; using Fido2NetLib.Objects; +using Boilerplate.Server.Api.Models.Identity; +using Boilerplate.Shared.Controllers.Identity; namespace Boilerplate.Server.Api.Controllers.Identity; @@ -12,105 +13,86 @@ namespace Boilerplate.Server.Api.Controllers.Identity; public partial class WebAuthnController : AppControllerBase, IWebAuthnController { [AutoInject] private IFido2 fido2 = default!; + [AutoInject] private IDistributedCache cache = default!; [AutoInject] private UserManager userManager = default!; [HttpGet] - public async Task GetCredentialOptions() + public async Task GetCredentialOptions(CancellationToken cancellationToken) { - try + var userId = User.GetUserId(); + var user = await userManager.FindByIdAsync(userId.ToString()) + ?? throw new ResourceNotFoundException("User"); + + var existingCredentials = DbContext.WebAuthnCredential.Where(c => c.UserId == user.Id); + var existingKeys = existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, + c.Id, + c.Transports)); + var fidoUser = new Fido2User { - var userId = User.GetUserId(); - var user = await userManager.FindByIdAsync(userId.ToString()) - ?? throw new ResourceNotFoundException("User"); - - var fidoUser = new Fido2User - { - Id = Encoding.UTF8.GetBytes(user.Id.ToString()), - Name = user.Email ?? user.PhoneNumber ?? user.UserName, - DisplayName = user.DisplayName - }; - var existingCredentials = DbContext.WebAuthnCredential.Where(c => c.UserId == user.Id); - var existingKeys = existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, c.Id, c.Transports)); + Id = Encoding.UTF8.GetBytes(user.Id.ToString()), + Name = user.Email ?? user.PhoneNumber ?? user.UserName, + DisplayName = user.DisplayName + }; - var options = fido2.RequestNewCredential(new RequestNewCredentialParams + var options = fido2.RequestNewCredential(new RequestNewCredentialParams + { + User = fidoUser, + ExcludeCredentials = [.. existingKeys], + AuthenticatorSelection = AuthenticatorSelection.Default, + AttestationPreference = AttestationConveyancePreference.None, + Extensions = new AuthenticationExtensionsClientInputs { - User = fidoUser, - ExcludeCredentials = existingKeys.ToList(), - AuthenticatorSelection = AuthenticatorSelection.Default, - AttestationPreference = AttestationConveyancePreference.None, - Extensions = new AuthenticationExtensionsClientInputs - { - Extensions = true, - UserVerificationMethod = true, - CredProps = true - } - }); + CredProps = true, + Extensions = true, + UserVerificationMethod = true, + } + }); - _pendingCredentials[key] = options; + var key = $"WebAuthn_Options_{user.Id}"; + await cache.SetAsync(key, Encoding.UTF8.GetBytes(options.ToJson()), cancellationToken); - // 6. return options to client - return options; - } - catch (Exception) - { - throw; - } + return options; } - /// - /// Creates a new credential for a user. - /// - /// Username of registering user. If usernameless, use base64 encoded options.User.Name from the credential-options used to create the credential. - /// - /// - /// a string containing either "OK" or an error message. - [HttpPut("{username}/credential")] - public async Task CreateCredentialAsync([FromRoute] string username, [FromBody] AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken) + [HttpPut()] + public async Task CreateCredentialAsync([FromRoute] string username, + [FromBody] AuthenticatorAttestationRawResponse attestationResponse, + CancellationToken cancellationToken) { - try - { - // 1. Get the options we sent the client - var options = _pendingCredentials[username]; - - // 2. Create callback so that lib can verify credential id is unique to this user - - // 3. Verify and make the credentials - var credential = await fido2.MakeNewCredentialAsync(new MakeNewCredentialParams - { - AttestationResponse = attestationResponse, - OriginalOptions = options, - IsCredentialIdUniqueToUserCallback = CredentialIdUniqueToUserAsync - }, cancellationToken: cancellationToken); + var userId = User.GetUserId(); + var user = await userManager.FindByIdAsync(userId.ToString()) + ?? throw new ResourceNotFoundException("User"); - // 4. Store the credentials in db - _demoStorage.AddCredentialToUser(options.User, new StoredCredential - { - - AttestationFormat = credential.AttestationFormat, - Id = credential.Id, - PublicKey = credential.PublicKey, - UserHandle = credential.User.Id, - SignCount = credential.SignCount, - RegDate = DateTimeOffset.UtcNow, - AaGuid = credential.AaGuid, - Transports = credential.Transports, - IsBackupEligible = credential.IsBackupEligible, - IsBackedUp = credential.IsBackedUp, - AttestationObject = credential.AttestationObject, - AttestationClientDataJson = credential.AttestationClientDataJson, - }); + var key = $"WebAuthn_Options_{user.Id}"; + var cachedBytes = await cache.GetAsync(key, cancellationToken) ?? throw new InvalidOperationException("no create credential options found in the cache."); + var jsonOptions = Encoding.UTF8.GetString(cachedBytes); + var options = CredentialCreateOptions.FromJson(jsonOptions); - // 5. Now we need to remove the options from the pending dictionary - _pendingCredentials.Remove(Request.Host.ToString()); + var credential = await fido2.MakeNewCredentialAsync(new MakeNewCredentialParams + { + AttestationResponse = attestationResponse, + OriginalOptions = options, + IsCredentialIdUniqueToUserCallback = CredentialIdUniqueToUserAsync + }, cancellationToken: cancellationToken); - // 5. return OK to client - return "OK"; - } - catch (Exception e) + await DbContext.WebAuthnCredential.AddAsync(new() { - return FormatException(e); - } + 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 cache.RemoveAsync(key, cancellationToken); } private static async Task CredentialIdUniqueToUserAsync(IsCredentialIdUniqueToUserParams args, CancellationToken cancellationToken) 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..ad4a0eb40a 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,14 @@ void AddDbContext(DbContextOptionsBuilder options) c.Timeout = TimeSpan.FromSeconds(10); c.BaseAddress = new Uri("https://api.cloudflare.com/client/v4/zones/"); }); + + services.AddFido2(options => + { + //options.ServerDomain = origin.Host; + //options.ServerName = "FIDO2 Server"; + //options.Origins = new HashSet { origin.AbsoluteUri }; + //options.TimestampDriftTolerance = 1000; + }); } private static void AddIdentity(WebApplicationBuilder builder) From 406cd39f9d6e981158bee5b3a33e9f825ce26e5a Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Sat, 8 Mar 2025 13:35:53 +0330 Subject: [PATCH 03/17] 3rd commit --- .../Boilerplate.Client.Core/Scripts/app.ts | 2 + .../Scripts/webAuthn.ts | 4 +- .../src/Directory.Packages8.props | 3 + .../Identity/IdentityController.WebAuthn.cs | 86 +++++++ .../Identity/IdentityController.cs | 5 + .../Identity/UserController.WebAuthn.cs | 108 +++++++++ .../Identity/WebAuthnController.cs | 225 ------------------ .../Extensions/SignInManagerExtensions.cs | 10 +- .../src/Shared/Boilerplate.Shared.csproj | 1 + .../Identity/IIdentityController.cs | 7 + .../Controllers/Identity/IUserController.cs | 7 + .../Identity/IWebAuthnController.cs | 7 - 12 files changed, 229 insertions(+), 236 deletions(-) create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.WebAuthn.cs delete mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/WebAuthnController.cs delete mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IWebAuthnController.cs 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..c0e856ae9e 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 @@ -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/Scripts/webAuthn.ts b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/webAuthn.ts index e81dd59c2e..2f6d9823e4 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 @@ -81,4 +81,6 @@ class WebAuthn { private static base64StringToUrl(base64String: string): string { return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); } -} \ No newline at end of file +} + +(window as any).WebAuthn = WebAuthn; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props index b4dbe5cd5f..fd44d08154 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props @@ -9,6 +9,9 @@ + + + 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..c45be1955a --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs @@ -0,0 +1,86 @@ +//+:cnd:noEmit +using System.Text; +using Boilerplate.Shared.Dtos.Identity; +using Fido2NetLib; +using Fido2NetLib.Objects; +using Microsoft.Extensions.Caching.Distributed; + +namespace Boilerplate.Server.Api.Controllers.Identity; + +public partial class IdentityController +{ + [AutoInject] private IFido2 fido2 = default!; + [AutoInject] private IDistributedCache cache = 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, Produces()] + public async Task VerifyWebAuthAssertionAndSignIn(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken) + { + var response = JsonSerializer.Deserialize(clientResponse.Response.ClientDataJson) + ?? 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 InvalidOperationException("no assertion credential options found in the cache."); + + 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("Credential"); + + var user = await userManager.FindByIdAsync(credential.UserId.ToString()) + ?? throw new ResourceNotFoundException("User"); + + var verifyResult = await fido2.MakeAssertionAsync(new MakeAssertionParams + { + AssertionResponse = clientResponse, + OriginalOptions = options, + StoredPublicKey = credential.PublicKey!, + StoredSignatureCounter = credential.SignCount, + IsUserHandleOwnerOfCredentialIdCallback = IsUserHandleOwnerOfCredentialId + }, cancellationToken); + + credential.SignCount = verifyResult.SignCount; + + DbContext.WebAuthnCredential.Update(credential); + + var otp = await userManager.GenerateUserTokenAsync(user, + TokenOptions.DefaultPhoneProvider, + FormattableString.Invariant($"Otp_WebAuth,{user.OtpRequestedOn?.ToUniversalTime()}")); + + await SignIn(new() { Otp = otp }, user, cancellationToken); + } + + private async Task IsUserHandleOwnerOfCredentialId(IsUserHandleOwnerOfCredentialIdParams args, CancellationToken cancellationToken) + { + var storedCreds = await DbContext.WebAuthnCredential.Where(c => c.Id == 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..9dffcfde84 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 @@ -95,6 +95,11 @@ public async Task SignIn(SignInRequestDto request, CancellationToken cancellatio 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) + { var userSession = await CreateUserSession(user.Id, request.DeviceInfo, cancellationToken); if (user.TwoFactorEnabled) 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..4d37bc3923 --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.WebAuthn.cs @@ -0,0 +1,108 @@ +//+:cnd:noEmit +using System.Text; +using Boilerplate.Server.Api.Models.Identity; +using Fido2NetLib; +using Fido2NetLib.Objects; +using Microsoft.Extensions.Caching.Distributed; + +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("User"); + + var existingCredentials = DbContext.WebAuthnCredential.Where(c => c.UserId == user.Id); + var existingKeys = existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, + c.Id, + c.Transports)); + var fidoUser = new Fido2User + { + Id = Encoding.UTF8.GetBytes(user.Id.ToString()), + Name = user.Email ?? user.PhoneNumber ?? user.UserName, + 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 = GetWebAuthnKey(user.Id); + 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("User"); + + var key = GetWebAuthnKey(user.Id); + var cachedBytes = await cache.GetAsync(key, cancellationToken) + ?? throw new InvalidOperationException("no create credential options found in the cache."); + + 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 + { + 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); + } + + + private static string GetWebAuthnKey(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/Controllers/Identity/WebAuthnController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/WebAuthnController.cs deleted file mode 100644 index bfa4706e51..0000000000 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/WebAuthnController.cs +++ /dev/null @@ -1,225 +0,0 @@ -//+:cnd:noEmit -using System.Text; -using System.IdentityModel.Tokens.Jwt; -using Microsoft.Extensions.Caching.Distributed; -using Fido2NetLib; -using Fido2NetLib.Objects; -using Boilerplate.Server.Api.Models.Identity; -using Boilerplate.Shared.Controllers.Identity; - -namespace Boilerplate.Server.Api.Controllers.Identity; - -[ApiController, Route("api/[controller]/[action]")] -public partial class WebAuthnController : AppControllerBase, IWebAuthnController -{ - [AutoInject] private IFido2 fido2 = default!; - [AutoInject] private IDistributedCache cache = default!; - [AutoInject] private UserManager userManager = default!; - - - [HttpGet] - public async Task GetCredentialOptions(CancellationToken cancellationToken) - { - var userId = User.GetUserId(); - var user = await userManager.FindByIdAsync(userId.ToString()) - ?? throw new ResourceNotFoundException("User"); - - var existingCredentials = DbContext.WebAuthnCredential.Where(c => c.UserId == user.Id); - var existingKeys = existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, - c.Id, - c.Transports)); - var fidoUser = new Fido2User - { - Id = Encoding.UTF8.GetBytes(user.Id.ToString()), - Name = user.Email ?? user.PhoneNumber ?? user.UserName, - 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 = $"WebAuthn_Options_{user.Id}"; - await cache.SetAsync(key, Encoding.UTF8.GetBytes(options.ToJson()), cancellationToken); - - return options; - } - - [HttpPut()] - public async Task CreateCredentialAsync([FromRoute] string username, - [FromBody] AuthenticatorAttestationRawResponse attestationResponse, - CancellationToken cancellationToken) - { - var userId = User.GetUserId(); - var user = await userManager.FindByIdAsync(userId.ToString()) - ?? throw new ResourceNotFoundException("User"); - - var key = $"WebAuthn_Options_{user.Id}"; - var cachedBytes = await cache.GetAsync(key, cancellationToken) ?? throw new InvalidOperationException("no create credential options found in the cache."); - var jsonOptions = Encoding.UTF8.GetString(cachedBytes); - var options = CredentialCreateOptions.FromJson(jsonOptions); - - var credential = await fido2.MakeNewCredentialAsync(new MakeNewCredentialParams - { - AttestationResponse = attestationResponse, - OriginalOptions = options, - IsCredentialIdUniqueToUserCallback = CredentialIdUniqueToUserAsync - }, cancellationToken: cancellationToken); - - await DbContext.WebAuthnCredential.AddAsync(new() - { - 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 cache.RemoveAsync(key, cancellationToken); - } - - private static async Task CredentialIdUniqueToUserAsync(IsCredentialIdUniqueToUserParams args, CancellationToken cancellationToken) - { - var users = await _demoStorage.GetUsersByCredentialIdAsync(args.CredentialId, cancellationToken); - return users.Count <= 0; - } - - [HttpGet("{username}/assertion-options")] - [HttpGet("assertion-options")] - public AssertionOptions MakeAssertionOptions([FromRoute] string? username, [FromQuery] UserVerificationRequirement? userVerification) - { - try - { - var existingKeys = new List(); - if (!string.IsNullOrEmpty(username)) - { - // 1. Get user and their credentials from DB - var user = _demoStorage.GetUser(username); - - if (user != null) - existingKeys = _demoStorage.GetCredentialsByUser(user).Select(c => c.Descriptor).ToList(); - } - - var exts = new AuthenticationExtensionsClientInputs - { - UserVerificationMethod = true, - Extensions = true - }; - - // 2. Create options (usernameless users will be prompted by their device to select a credential from their own list) - var options = fido2.GetAssertionOptions(new GetAssertionOptionsParams - { - AllowedCredentials = existingKeys, - UserVerification = userVerification ?? UserVerificationRequirement.Discouraged, - Extensions = exts - }); - - // 4. Temporarily store options, session/in-memory cache/redis/db - _pendingAssertions[new string(options.Challenge.Select(b => (char)b).ToArray())] = options; - - // 5. return options to client - return options; - } - catch (Exception) - { - throw; - } - } - - /// - /// Verifies an assertion response from a client, generating a new JWT for the user. - /// - /// The client's authenticator's response to the challenge. - /// - /// - /// Either a new JWT header or an error message. - /// Example successful response: - /// "Bearer eyyylmaooimtotallyatoken" - /// Example error response: - /// "Error: Invalid assertion" - /// - [HttpPost("assertion")] - public async Task MakeAssertionAsync([FromBody] AuthenticatorAssertionRawResponse clientResponse, - CancellationToken cancellationToken) - { - try - { - // 1. Get the assertion options we sent the client remove them from memory so they can't be used again - var response = JsonSerializer.Deserialize(clientResponse.Response.ClientDataJson); - if (response is null) - { - return "Error: Could not deserialize client data"; - } - - var key = new string(response.Challenge.Select(b => (char)b).ToArray()); - if (!_pendingAssertions.TryGetValue(key, out var options)) - { - return "Error: Challenge not found, please get a new one via GET /{username?}/assertion-options"; - } - _pendingAssertions.Remove(key); - - // 2. Get registered credential from database - var creds = _demoStorage.GetCredentialById(clientResponse.Id) ?? throw new Exception("Unknown credentials"); - - // 3. Make the assertion - var res = await fido2.MakeAssertionAsync(new MakeAssertionParams - { - AssertionResponse = clientResponse, - OriginalOptions = options, - StoredPublicKey = creds.PublicKey, - StoredSignatureCounter = creds.SignCount, - IsUserHandleOwnerOfCredentialIdCallback = UserHandleOwnerOfCredentialIdAsync - }, cancellationToken: cancellationToken); - - // 4. Store the updated counter - _demoStorage.UpdateCounter(res.CredentialId, res.SignCount); - - - // 5. return result to client - var handler = new JwtSecurityTokenHandler(); - var token = handler.CreateEncodedJwt( - HttpContext.Request.Host.Host, - HttpContext.Request.Headers.Referer, - new ClaimsIdentity(new Claim[] { new(ClaimTypes.Actor, Encoding.UTF8.GetString(creds.UserHandle)) }), - DateTime.Now.Subtract(TimeSpan.FromMinutes(1)), - DateTime.Now.AddDays(1), - DateTime.Now, - _signingCredentials, - null); - - if (token is null) - { - return "Error: Token couldn't be created"; - } - - return $"Bearer {token}"; - } - catch (Exception e) - { - return $"Error: {FormatException(e)}"; - } - } - - private static async Task UserHandleOwnerOfCredentialIdAsync(IsUserHandleOwnerOfCredentialIdParams args, CancellationToken cancellationToken) - { - var storedCreds = await _demoStorage.GetCredentialsByUserHandleAsync(args.UserHandle, cancellationToken); - return storedCreds.Exists(c => c.Descriptor.Id.SequenceEqual(args.CredentialId)); - } -} 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/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..b8c29127d0 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,5 +1,6 @@ //+:cnd:noEmit using Boilerplate.Shared.Dtos.Identity; +using Fido2NetLib; namespace Boilerplate.Shared.Controllers.Identity; @@ -45,4 +46,10 @@ 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 VerifyWebAuthAssertionAndSignIn(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 9796336df9..73138ecb53 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; namespace Boilerplate.Shared.Controllers.Identity; @@ -47,4 +48,10 @@ public interface IUserController : IAppController [HttpPost] Task SendElevatedAccessToken(CancellationToken cancellationToken); + + [HttpGet] + Task GetWebAuthnCredentialOptions(CancellationToken cancellationToken); + + [HttpPut] + Task CreateWebAuthnCredential(AuthenticatorAttestationRawResponse attestationResponse, CancellationToken cancellationToken); } diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IWebAuthnController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IWebAuthnController.cs deleted file mode 100644 index f348c8feae..0000000000 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IWebAuthnController.cs +++ /dev/null @@ -1,7 +0,0 @@ -//+:cnd:noEmit -namespace Boilerplate.Shared.Controllers.Identity; - -[Route("api/[controller]/[action]/")] -public interface IWebAuthnController : IAppController -{ -} From c26e33efa290d18f871f9ed2dd7c498989abcd04 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 10 Mar 2025 12:21:40 +0330 Subject: [PATCH 04/17] add migration --- ...250310082820_InitialMigration.Designer.cs} | 61 ++++++++++++++++++- ....cs => 20250310082820_InitialMigration.cs} | 42 ++++++++++++- .../Migrations/AppDbContextModelSnapshot.cs | 59 ++++++++++++++++++ 3 files changed, 160 insertions(+), 2 deletions(-) rename src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/{20250303200825_InitialMigration.Designer.cs => 20250310082820_InitialMigration.Designer.cs} (98%) rename src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/{20250303200825_InitialMigration.cs => 20250310082820_InitialMigration.cs} (96%) 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/20250310082820_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/20250310082820_InitialMigration.Designer.cs index 76d018c1e7..d5c39d90c7 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/20250310082820_InitialMigration.Designer.cs @@ -11,7 +11,7 @@ namespace Boilerplate.Server.Api.Data.Migrations; [DbContext(typeof(AppDbContext))] -[Migration("20250303200825_InitialMigration")] +[Migration("20250310082820_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() + .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") 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/20250310082820_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/20250310082820_InitialMigration.cs index 94f794a3f2..52b05f52aa 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/20250310082820_InitialMigration.cs @@ -1,4 +1,7 @@ -#nullable disable +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable #pragma warning disable CA1814 // Prefer jagged arrays over multidimensional @@ -264,6 +267,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 +574,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 +611,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..e8c4e96cec 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() + .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") From ebe7844f870c5377616716bdc8c4b5b8b6996f7b Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 10 Mar 2025 15:58:45 +0330 Subject: [PATCH 05/17] add registration ui --- .../Settings/PasswordlessSection.razor | 14 ++++++++++ .../Settings/PasswordlessSection.razor.cs | 25 +++++++++++++++++ .../Settings/PasswordlessSection.razor.scss | 5 ++++ .../Authorized/Settings/SettingsPage.razor | 14 +++++++--- .../Authorized/Settings/SettingsPage.razor.cs | 27 +++++++++++++++++++ .../IJSRuntimeWebAuthnExtensions.cs | 10 +++++++ .../Boilerplate.Client.Core/Scripts/app.ts | 2 +- .../Scripts/webAuthn.ts | 25 ++++++++++++++--- .../Identity/UserController.WebAuthn.cs | 15 ++++++----- .../Program.Services.cs | 8 +++--- .../src/Shared/Dtos/AppJsonContext.cs | 9 +++++-- .../src/Shared/Resources/AppStrings.resx | 6 +++++ 12 files changed, 139 insertions(+), 21 deletions(-) create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.scss 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..0e88e75bde --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor @@ -0,0 +1,14 @@ +@inherits AppComponentBase + +
+ + + @Localizer[nameof(AppStrings.EnablePasswordless)] + +
+ + @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..0dddaf309a --- /dev/null +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs @@ -0,0 +1,25 @@ +using Boilerplate.Shared.Controllers.Identity; + +namespace Boilerplate.Client.Core.Components.Pages.Authorized.Settings; + +public partial class PasswordlessSection +{ + [AutoInject] IUserController userController = default!; + + + [Parameter] public EventCallback OnCredentialCreated { get; set; } + + + private async Task EnablePasswordless() + { + var options = await userController.GetWebAuthnCredentialOptions(CurrentCancellationToken); + + var attestationResponse = await JSRuntime.CreateWebAuthnCredential(options); + + await userController.CreateWebAuthnCredential(attestationResponse, CurrentCancellationToken); + + await JSRuntime.StoreWebAuthnConfigured(options.User.Name); + + await OnCredentialCreated.InvokeAsync(); + } +} 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..8086320791 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 @@ -19,14 +19,20 @@ Name="@Urls.SettingsSections.Account" 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..495be29d4d 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; } @@ -20,6 +23,7 @@ public partial class SettingsPage private UserDto? user; private bool isLoading; private string? openedAccordion; + private string? accountSelectedPivot; protected override async Task OnInitAsync() @@ -31,6 +35,7 @@ protected override async Task OnInitAsync() try { user = (await PrerenderStateService.GetValue(() => HttpClient.GetFromJsonAsync("api/User/GetCurrentUser", JsonSerializerOptions.GetTypeInfo(), CurrentCancellationToken)))!; + await CheckShowPasswordless(); } finally { @@ -39,4 +44,26 @@ protected override async Task OnInitAsync() await base.OnInitAsync(); } + + + private async Task HandleOnCredentialCreated() + { + await CheckShowPasswordless(); + + if(showPasswordless is false) + { + accountSelectedPivot = nameof(AppStrings.Email); + StateHasChanged(); + } + } + + private async Task CheckShowPasswordless() + { + if (user?.UserName is null) return; + + var isAvailable = await JSRuntime.IsWebAuthnAvailable(); + var isConfigured = await JSRuntime.IsWebAuthnConfigured(user.UserName); + + showPasswordless = isAvailable && isConfigured is false; + } } 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 a3ffd235a0..3f6ee45fa6 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 @@ -11,6 +11,16 @@ 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) + { + return jsRuntime.InvokeAsync("WebAuthn.isConfigured", username); + } + public static ValueTask CreateWebAuthnCredential(this IJSRuntime jsRuntime, CredentialCreateOptions options) { return jsRuntime.InvokeAsync("WebAuthn.createCredential", options); 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 c0e856ae9e..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 () { 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 2f6d9823e4..53e4a4da1b 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,8 +1,27 @@ 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) { + const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[]; + return storedCredentials.includes(username); + } + + 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.fromBase64Url(options.challenge); @@ -22,7 +41,7 @@ class WebAuthn { cred.id = WebAuthn.fromBase64Url(cred.id); } - var credential = await navigator.credentials.create({ publicKey: options }) as PublicKeyCredential; + const credential = await navigator.credentials.create({ publicKey: options }) as PublicKeyCredential; const response = credential.response as AuthenticatorAttestationResponse; return { @@ -44,14 +63,14 @@ class WebAuthn { } if (options.allowCredentials) { - for (var i = 0; i < options.allowCredentials.length; i++) { + for (let i = 0; i < options.allowCredentials.length; i++) { const id = options.allowCredentials[i].id; if (typeof id === 'string') { options.allowCredentials[i].id = WebAuthn.fromBase64Url(id); } } } - var credential = await navigator.credentials.get({ publicKey: options }) as PublicKeyCredential; + const credential = await navigator.credentials.get({ publicKey: options }) as PublicKeyCredential; const response = credential.response as AuthenticatorAssertionResponse; return { 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 4d37bc3923..2f0c8f7b4f 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 @@ -1,9 +1,9 @@ //+:cnd:noEmit using System.Text; -using Boilerplate.Server.Api.Models.Identity; +using Microsoft.Extensions.Caching.Distributed; using Fido2NetLib; using Fido2NetLib.Objects; -using Microsoft.Extensions.Caching.Distributed; +using Boilerplate.Server.Api.Models.Identity; namespace Boilerplate.Server.Api.Controllers.Identity; @@ -20,14 +20,14 @@ public async Task GetWebAuthnCredentialOptions(Cancella var user = await userManager.FindByIdAsync(userId.ToString()) ?? throw new ResourceNotFoundException("User"); - var existingCredentials = DbContext.WebAuthnCredential.Where(c => c.UserId == user.Id); + 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(user.Id.ToString()), - Name = user.Email ?? user.PhoneNumber ?? user.UserName, + Id = Encoding.UTF8.GetBytes(userId.ToString()), + Name = user.UserName, DisplayName = user.DisplayName }; @@ -45,7 +45,7 @@ public async Task GetWebAuthnCredentialOptions(Cancella } }); - var key = GetWebAuthnKey(user.Id); + var key = GetWebAuthnKey(userId); await cache.SetAsync(key, Encoding.UTF8.GetBytes(options.ToJson()), cancellationToken); return options; @@ -58,7 +58,7 @@ public async Task CreateWebAuthnCredential(AuthenticatorAttestationRawResponse a var user = await userManager.FindByIdAsync(userId.ToString()) ?? throw new ResourceNotFoundException("User"); - var key = GetWebAuthnKey(user.Id); + var key = GetWebAuthnKey(userId); var cachedBytes = await cache.GetAsync(key, cancellationToken) ?? throw new InvalidOperationException("no create credential options found in the cache."); @@ -76,6 +76,7 @@ public async Task CreateWebAuthnCredential(AuthenticatorAttestationRawResponse a var newCredential = new WebAuthnCredential { + UserId = userId, Id = credential.Id, PublicKey = credential.PublicKey, UserHandle = credential.User.Id, 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 ad4a0eb40a..a42abf56f2 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 @@ -301,10 +301,10 @@ void AddDbContext(DbContextOptionsBuilder options) services.AddFido2(options => { - //options.ServerDomain = origin.Host; - //options.ServerName = "FIDO2 Server"; - //options.Origins = new HashSet { origin.AbsoluteUri }; - //options.TimestampDriftTolerance = 1000; + options.ServerDomain = "localhost"; + options.ServerName = "Boilerplate fido2 server"; + options.Origins = new HashSet { "http://localhost:5030" }; + options.TimestampDriftTolerance = 1000; }); } 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..6063e173b1 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs @@ -1,18 +1,19 @@ //+:cnd:noEmit //#if (sample == true) -using Boilerplate.Shared.Dtos.Todo; +using Boilerplate.Shared.Dtos.Categories; //#endif //#if (module == "Admin") using Boilerplate.Shared.Dtos.Dashboard; //#endif //#if (module == "Admin" || module == "Sales") using Boilerplate.Shared.Dtos.Products; -using Boilerplate.Shared.Dtos.Categories; //#endif //#if (notification == true) using Boilerplate.Shared.Dtos.PushNotification; //#endif using Boilerplate.Shared.Dtos.Statistics; +using Boilerplate.Shared.Dtos.Todo; +using Fido2NetLib; namespace Boilerplate.Shared.Dtos; @@ -48,6 +49,10 @@ namespace Boilerplate.Shared.Dtos; [JsonSerializable(typeof(OverallAnalyticsStatsDataResponseDto))] [JsonSerializable(typeof(List))] //#endif +[JsonSerializable(typeof(AssertionOptions))] +[JsonSerializable(typeof(AuthenticatorAssertionRawResponse))] +[JsonSerializable(typeof(AuthenticatorAttestationRawResponse))] +[JsonSerializable(typeof(CredentialCreateOptions))] public partial class AppJsonContext : JsonSerializerContext { } 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..8feff0507a 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx @@ -1210,4 +1210,10 @@ Products in the same category + + Passwordless + + + Enable passwordless login + \ No newline at end of file From c266a755682cd3bdb5bbe0ee8e9fb8b3f72c37ba Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Mon, 10 Mar 2025 23:03:23 +0330 Subject: [PATCH 06/17] first try for disable --- .../Authorized/Settings/PasswordlessSection.razor | 15 ++++++++++++--- .../Settings/PasswordlessSection.razor.cs | 15 ++++++++++++--- .../Pages/Authorized/Settings/SettingsPage.razor | 4 ++-- .../Authorized/Settings/SettingsPage.razor.cs | 15 +-------------- .../Extensions/IJSRuntimeWebAuthnExtensions.cs | 5 +++++ 5 files changed, 32 insertions(+), 22 deletions(-) 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 0e88e75bde..01fcab5268 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 @@ -6,9 +6,18 @@ @Localizer[nameof(AppStrings.EnablePasswordless)]
- - @Localizer[nameof(AppStrings.EnablePasswordless)] - + @if (isConfigured) + { + + @Localizer[nameof(AppStrings.EnablePasswordless)] + + } + 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 index 0dddaf309a..c41fe5316f 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs @@ -4,10 +4,10 @@ namespace Boilerplate.Client.Core.Components.Pages.Authorized.Settings; public partial class PasswordlessSection { - [AutoInject] IUserController userController = default!; + private bool isConfigured; - [Parameter] public EventCallback OnCredentialCreated { get; set; } + [AutoInject] IUserController userController = default!; private async Task EnablePasswordless() @@ -20,6 +20,15 @@ private async Task EnablePasswordless() await JSRuntime.StoreWebAuthnConfigured(options.User.Name); - await OnCredentialCreated.InvokeAsync(); + isConfigured = true; + } + + private async Task DisablePasswordless() + { + var options = await userController.GetWebAuthnCredentialOptions(CurrentCancellationToken); + + await JSRuntime.RemoveWebAuthnConfigured(options.User.Name); + + isConfigured = false; } } 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 8086320791..c7546d7073 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 @@ -19,7 +19,7 @@ Name="@Urls.SettingsSections.Account" Title="@Localizer[nameof(AppStrings.AccountTitle)]" Subtitle="@Localizer[nameof(AppStrings.AccountSubtitle)]"> - + @@ -29,7 +29,7 @@ @if (showPasswordless) { - + } 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 495be29d4d..0ec3c62fd0 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 @@ -23,7 +23,6 @@ public partial class SettingsPage private UserDto? user; private bool isLoading; private string? openedAccordion; - private string? accountSelectedPivot; protected override async Task OnInitAsync() @@ -46,24 +45,12 @@ protected override async Task OnInitAsync() } - private async Task HandleOnCredentialCreated() - { - await CheckShowPasswordless(); - - if(showPasswordless is false) - { - accountSelectedPivot = nameof(AppStrings.Email); - StateHasChanged(); - } - } - private async Task CheckShowPasswordless() { if (user?.UserName is null) return; var isAvailable = await JSRuntime.IsWebAuthnAvailable(); - var isConfigured = await JSRuntime.IsWebAuthnConfigured(user.UserName); - showPasswordless = isAvailable && isConfigured is false; + showPasswordless = isAvailable; } } 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 3f6ee45fa6..376a3ba986 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 @@ -21,6 +21,11 @@ public static ValueTask IsWebAuthnConfigured(this IJSRuntime jsRuntime, st return jsRuntime.InvokeAsync("WebAuthn.isConfigured", username); } + public static ValueTask RemoveWebAuthnConfigured(this IJSRuntime jsRuntime, string username) + { + return jsRuntime.InvokeAsync("WebAuthn.removeConfigured", username); + } + public static ValueTask CreateWebAuthnCredential(this IJSRuntime jsRuntime, CredentialCreateOptions options) { return jsRuntime.InvokeAsync("WebAuthn.createCredential", options); From 7158b9f394482407fce76f7c98ab9baf704155d0 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 11 Mar 2025 10:06:13 +0330 Subject: [PATCH 07/17] finish passwordless pivot --- .../Settings/PasswordlessSection.razor | 8 +-- .../Settings/PasswordlessSection.razor.cs | 58 +++++++++++++++++-- .../Authorized/Settings/SettingsPage.razor | 2 +- .../Authorized/Settings/SettingsPage.razor.cs | 12 +--- .../IJSRuntimeWebAuthnExtensions.cs | 4 +- .../Identity/IdentityController.WebAuthn.cs | 49 +++++++++++----- .../Identity/UserController.WebAuthn.cs | 21 ++++++- .../Identity/IIdentityController.cs | 6 +- .../Controllers/Identity/IUserController.cs | 3 + .../src/Shared/Dtos/AppJsonContext.cs | 2 + .../src/Shared/Resources/AppStrings.resx | 15 +++++ 11 files changed, 138 insertions(+), 42 deletions(-) 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 01fcab5268..918afd886a 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 @@ -3,18 +3,18 @@
- @Localizer[nameof(AppStrings.EnablePasswordless)] + @Localizer[nameof(AppStrings.PasswordlessTitle)]
@if (isConfigured) { - - @Localizer[nameof(AppStrings.EnablePasswordless)] + + @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 index c41fe5316f..975075b26e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs @@ -1,4 +1,6 @@ using Boilerplate.Shared.Controllers.Identity; +using Boilerplate.Shared.Dtos.Identity; +using Fido2NetLib; namespace Boilerplate.Client.Core.Components.Pages.Authorized.Settings; @@ -8,27 +10,75 @@ public partial class PasswordlessSection [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); - var attestationResponse = await JSRuntime.CreateWebAuthnCredential(options); + AuthenticatorAttestationRawResponse attestationResponse; + try + { + attestationResponse = await JSRuntime.CreateWebAuthnCredential(options); + } + catch (Exception ex) + { + ExceptionHandler.Handle(ex, ExceptionDisplayKind.None); + SnackBarService.Error(Localizer[nameof(AppStrings.PasswordlessCredentialErrorMessage)], ex.Message); + return; + } await userController.CreateWebAuthnCredential(attestationResponse, CurrentCancellationToken); - await JSRuntime.StoreWebAuthnConfigured(options.User.Name); + await JSRuntime.StoreWebAuthnConfigured(User.UserName); isConfigured = true; + + SnackBarService.Success(Localizer[nameof(AppStrings.EnablePasswordlessSucsessMessage)]); } private async Task DisablePasswordless() { - var options = await userController.GetWebAuthnCredentialOptions(CurrentCancellationToken); + if (User?.UserName is null) return; + + var options = await identityController.GetWebAuthnAssertionOptions(CurrentCancellationToken); + + AuthenticatorAssertionRawResponse assertion; + try + { + assertion = await JSRuntime.VerifyWebAuthnCredential(options); + } + catch (Exception ex) + { + ExceptionHandler.Handle(ex, ExceptionDisplayKind.None); + SnackBarService.Error(Localizer[nameof(AppStrings.PasswordlessCredentialErrorMessage)], ex.Message); + return; - await JSRuntime.RemoveWebAuthnConfigured(options.User.Name); + } + + 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/SettingsPage.razor b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/SettingsPage.razor index c7546d7073..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 @@ -29,7 +29,7 @@ @if (showPasswordless) { - + } 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 0ec3c62fd0..12d1b0e339 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,7 @@ protected override async Task OnInitAsync() try { user = (await PrerenderStateService.GetValue(() => HttpClient.GetFromJsonAsync("api/User/GetCurrentUser", JsonSerializerOptions.GetTypeInfo(), CurrentCancellationToken)))!; - await CheckShowPasswordless(); + showPasswordless = await JSRuntime.IsWebAuthnAvailable(); } finally { @@ -43,14 +43,4 @@ protected override async Task OnInitAsync() await base.OnInitAsync(); } - - - private async Task CheckShowPasswordless() - { - if (user?.UserName is null) return; - - var isAvailable = await JSRuntime.IsWebAuthnAvailable(); - - showPasswordless = isAvailable; - } } 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 376a3ba986..de9041159b 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 @@ -21,9 +21,9 @@ 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) { - return jsRuntime.InvokeAsync("WebAuthn.removeConfigured", username); + return jsRuntime.InvokeVoidAsync("WebAuthn.removeConfigured", username); } public static ValueTask CreateWebAuthnCredential(this IJSRuntime jsRuntime, CredentialCreateOptions options) 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 c45be1955a..5a43b69799 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/IdentityController.WebAuthn.cs @@ -1,9 +1,10 @@ //+:cnd:noEmit using System.Text; -using Boilerplate.Shared.Dtos.Identity; +using Microsoft.Extensions.Caching.Distributed; using Fido2NetLib; using Fido2NetLib.Objects; -using Microsoft.Extensions.Caching.Distributed; +using Boilerplate.Shared.Dtos.Identity; +using Boilerplate.Server.Api.Models.Identity; namespace Boilerplate.Server.Api.Controllers.Identity; @@ -37,8 +38,35 @@ public async Task GetWebAuthnAssertionOptions(CancellationToke return options; } + [HttpPost] + public async Task VerifyWebAuthAssertion(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken) + { + var (verifyResult, _) = await Verify(clientResponse, cancellationToken); + + return verifyResult; + } + [HttpPost, Produces()] - public async Task VerifyWebAuthAssertionAndSignIn(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken) + 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("User"); + + var otp = await userManager.GenerateUserTokenAsync(user, + TokenOptions.DefaultPhoneProvider, + FormattableString.Invariant($"Otp_WebAuth,{user.OtpRequestedOn?.ToUniversalTime()}")); + + credential.SignCount = verifyResult.SignCount; + + DbContext.WebAuthnCredential.Update(credential); + + await SignIn(new() { Otp = otp }, user, cancellationToken); + } + + + private async Task<(VerifyAssertionResult, WebAuthnCredential)> Verify(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken) { var response = JsonSerializer.Deserialize(clientResponse.Response.ClientDataJson) ?? throw new InvalidOperationException("Invalid client data."); @@ -55,9 +83,6 @@ public async Task VerifyWebAuthAssertionAndSignIn(AuthenticatorAssertionRawRespo var credential = (await DbContext.WebAuthnCredential.FirstOrDefaultAsync(c => c.Id == clientResponse.Id, cancellationToken)) ?? throw new ResourceNotFoundException("Credential"); - var user = await userManager.FindByIdAsync(credential.UserId.ToString()) - ?? throw new ResourceNotFoundException("User"); - var verifyResult = await fido2.MakeAssertionAsync(new MakeAssertionParams { AssertionResponse = clientResponse, @@ -67,20 +92,12 @@ public async Task VerifyWebAuthAssertionAndSignIn(AuthenticatorAssertionRawRespo IsUserHandleOwnerOfCredentialIdCallback = IsUserHandleOwnerOfCredentialId }, cancellationToken); - credential.SignCount = verifyResult.SignCount; - - DbContext.WebAuthnCredential.Update(credential); - - var otp = await userManager.GenerateUserTokenAsync(user, - TokenOptions.DefaultPhoneProvider, - FormattableString.Invariant($"Otp_WebAuth,{user.OtpRequestedOn?.ToUniversalTime()}")); - - await SignIn(new() { Otp = otp }, user, cancellationToken); + return (verifyResult, credential); } private async Task IsUserHandleOwnerOfCredentialId(IsUserHandleOwnerOfCredentialIdParams args, CancellationToken cancellationToken) { - var storedCreds = await DbContext.WebAuthnCredential.Where(c => c.Id == args.UserHandle).ToListAsync(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/UserController.WebAuthn.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Controllers/Identity/UserController.WebAuthn.cs index 2f0c8f7b4f..526cd3b29c 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,6 +3,7 @@ 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; @@ -45,7 +46,7 @@ public async Task GetWebAuthnCredentialOptions(Cancella } }); - var key = GetWebAuthnKey(userId); + var key = GetWebAuthnCacheKey(userId); await cache.SetAsync(key, Encoding.UTF8.GetBytes(options.ToJson()), cancellationToken); return options; @@ -58,7 +59,7 @@ public async Task CreateWebAuthnCredential(AuthenticatorAttestationRawResponse a var user = await userManager.FindByIdAsync(userId.ToString()) ?? throw new ResourceNotFoundException("User"); - var key = GetWebAuthnKey(userId); + var key = GetWebAuthnCacheKey(userId); var cachedBytes = await cache.GetAsync(key, cancellationToken) ?? throw new InvalidOperationException("no create credential options found in the cache."); @@ -98,8 +99,22 @@ public async Task CreateWebAuthnCredential(AuthenticatorAttestationRawResponse a 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("User"); + + var entityToDelete = await DbContext.WebAuthnCredential.FindAsync([credentialId], cancellationToken) + ?? throw new ResourceNotFoundException("WebAuthnCredential"); + + DbContext.WebAuthnCredential.Remove(entityToDelete); + + await DbContext.SaveChangesAsync(cancellationToken); + } - private static string GetWebAuthnKey(Guid userId) => $"WebAuthn_Options_{userId}"; + 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/Shared/Controllers/Identity/IIdentityController.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Controllers/Identity/IIdentityController.cs index b8c29127d0..6bc90ffa57 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,6 +1,7 @@ //+:cnd:noEmit using Boilerplate.Shared.Dtos.Identity; using Fido2NetLib; +using Fido2NetLib.Objects; namespace Boilerplate.Shared.Controllers.Identity; @@ -51,5 +52,8 @@ public interface IIdentityController : IAppController Task GetWebAuthnAssertionOptions(CancellationToken cancellationToken); [HttpPost] - Task VerifyWebAuthAssertionAndSignIn(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken); + Task VerifyWebAuthAssertion(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken); + + [HttpPost] + Task VerifyWebAuthAndSignIn(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 73138ecb53..93f9ffcde6 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 @@ -54,4 +54,7 @@ public interface IUserController : IAppController [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 6063e173b1..ebdd5ec653 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 @@ using Boilerplate.Shared.Dtos.Statistics; using Boilerplate.Shared.Dtos.Todo; using Fido2NetLib; +using Fido2NetLib.Objects; namespace Boilerplate.Shared.Dtos; @@ -53,6 +54,7 @@ namespace Boilerplate.Shared.Dtos; [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/Resources/AppStrings.resx b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx index 8feff0507a..b602e58c7e 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx @@ -1212,8 +1212,23 @@ Passwordless + + + Passwordless login Enable passwordless login + + Passwordless login enabled successfully + + + Disable passwordless login + + + Passwordless login disabled successfully + + + Something went wrong during credential process + \ No newline at end of file From dd9b5ee8648ee48e817f8b38b2a91ddcdcc9b35b Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 11 Mar 2025 11:45:02 +0330 Subject: [PATCH 08/17] add passwordless signin --- .../Components/Layout/MainLayout.razor | 2 + .../Components/Layout/MainLayout.razor.scss | 8 ++ .../Settings/PasswordlessSection.razor.cs | 2 - .../Pages/Identity/SignIn/SignInPage.razor | 16 +++- .../Pages/Identity/SignIn/SignInPage.razor.cs | 90 ++++++++++++++----- .../Pages/Identity/SignIn/SignInPanel.razor | 15 +++- .../Identity/SignIn/SignInPanel.razor.cs | 25 +++++- .../IJSRuntimeWebAuthnExtensions.cs | 2 +- .../Scripts/webAuthn.ts | 4 +- .../Identity/IdentityController.WebAuthn.cs | 12 +-- .../Identity/IdentityController.cs | 6 +- .../Identity/UserController.WebAuthn.cs | 10 +-- .../Identity/IIdentityController.cs | 2 +- .../src/Shared/Resources/AppStrings.fa.resx | 18 ++++ .../src/Shared/Resources/AppStrings.nl.resx | 18 ++++ .../src/Shared/Resources/AppStrings.resx | 3 - 16 files changed, 181 insertions(+), 52 deletions(-) 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.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs index 975075b26e..0a6f900fda 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs @@ -39,7 +39,6 @@ private async Task EnablePasswordless() catch (Exception ex) { ExceptionHandler.Handle(ex, ExceptionDisplayKind.None); - SnackBarService.Error(Localizer[nameof(AppStrings.PasswordlessCredentialErrorMessage)], ex.Message); return; } @@ -66,7 +65,6 @@ private async Task DisablePasswordless() catch (Exception ex) { ExceptionHandler.Handle(ex, ExceptionDisplayKind.None); - SnackBarService.Error(Localizer[nameof(AppStrings.PasswordlessCredentialErrorMessage)], ex.Message); return; } 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..63756f63d1 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,41 @@ 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) + { + 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 +193,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 +236,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 +253,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..2c2d107cf4 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 (isWebAuthnConfigured) + { + + } + @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..d943774c1e 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 @@ -1,15 +1,26 @@ using Boilerplate.Shared.Dtos.Identity; +using Fido2NetLib; 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 isWebAuthnConfigured; + 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 +29,18 @@ 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() + { + isWebAuthnConfigured = await JSRuntime.IsWebAuthnConfigured(); + + if (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 index de9041159b..5e3e52a2a4 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 @@ -16,7 +16,7 @@ public static ValueTask StoreWebAuthnConfigured(this IJSRuntime jsRuntime, strin return jsRuntime.InvokeVoidAsync("WebAuthn.storeConfigured", username); } - public static ValueTask IsWebAuthnConfigured(this IJSRuntime jsRuntime, string username) + public static ValueTask IsWebAuthnConfigured(this IJSRuntime jsRuntime, string? username = null) { return jsRuntime.InvokeAsync("WebAuthn.isConfigured", 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 53e4a4da1b..56d2fa85db 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 @@ -11,9 +11,9 @@ class WebAuthn { localStorage.setItem(WebAuthn.STORE_KEY, JSON.stringify(storedCredentials)); } - public static isConfigured(username: string) { + public static isConfigured(username: string | undefined) { const storedCredentials = JSON.parse(localStorage.getItem(WebAuthn.STORE_KEY) || '[]') as string[]; - return storedCredentials.includes(username); + return !!username ? storedCredentials.includes(username) : storedCredentials.length > 0; } public static removeConfigured(username: string) { 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 5a43b69799..e82c3aef50 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 @@ -52,16 +52,16 @@ public async Task VerifyWebAuthAndSignIn(AuthenticatorAssertionRawResponse clien var (verifyResult, credential) = await Verify(clientResponse, cancellationToken); var user = await userManager.FindByIdAsync(credential.UserId.ToString()) - ?? throw new ResourceNotFoundException("User"); + ?? throw new ResourceNotFoundException(); - var otp = await userManager.GenerateUserTokenAsync(user, - TokenOptions.DefaultPhoneProvider, - FormattableString.Invariant($"Otp_WebAuth,{user.OtpRequestedOn?.ToUniversalTime()}")); + 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); } @@ -73,7 +73,7 @@ public async Task VerifyWebAuthAndSignIn(AuthenticatorAssertionRawResponse clien var key = new string([.. response.Challenge.Select(b => (char)b)]); var cachedBytes = await cache.GetAsync(key, cancellationToken) - ?? throw new InvalidOperationException("no assertion credential options found in the cache."); + ?? throw new ResourceNotFoundException(); var jsonOptions = Encoding.UTF8.GetString(cachedBytes); var options = AssertionOptions.FromJson(jsonOptions); @@ -81,7 +81,7 @@ public async Task VerifyWebAuthAndSignIn(AuthenticatorAssertionRawResponse clien await cache.RemoveAsync(key, cancellationToken); var credential = (await DbContext.WebAuthnCredential.FirstOrDefaultAsync(c => c.Id == clientResponse.Id, cancellationToken)) - ?? throw new ResourceNotFoundException("Credential"); + ?? throw new ResourceNotFoundException(); var verifyResult = await fido2.MakeAssertionAsync(new MakeAssertionParams { 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 9dffcfde84..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,15 +91,17 @@ 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); if (user.TwoFactorEnabled) 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 526cd3b29c..e4fa25ca10 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 @@ -19,7 +19,7 @@ public async Task GetWebAuthnCredentialOptions(Cancella { var userId = User.GetUserId(); var user = await userManager.FindByIdAsync(userId.ToString()) - ?? throw new ResourceNotFoundException("User"); + ?? throw new ResourceNotFoundException(); var existingCredentials = DbContext.WebAuthnCredential.Where(c => c.UserId == userId); var existingKeys = existingCredentials.Select(c => new PublicKeyCredentialDescriptor(PublicKeyCredentialType.PublicKey, @@ -57,11 +57,11 @@ public async Task CreateWebAuthnCredential(AuthenticatorAttestationRawResponse a { var userId = User.GetUserId(); var user = await userManager.FindByIdAsync(userId.ToString()) - ?? throw new ResourceNotFoundException("User"); + ?? throw new ResourceNotFoundException(); var key = GetWebAuthnCacheKey(userId); var cachedBytes = await cache.GetAsync(key, cancellationToken) - ?? throw new InvalidOperationException("no create credential options found in the cache."); + ?? throw new ResourceNotFoundException(); var jsonOptions = Encoding.UTF8.GetString(cachedBytes); var options = CredentialCreateOptions.FromJson(jsonOptions); @@ -104,10 +104,10 @@ public async Task DeleteWebAuthnCredential(byte[] credentialId, CancellationToke { var userId = User.GetUserId(); var user = await userManager.FindByIdAsync(userId.ToString()) - ?? throw new ResourceNotFoundException("User"); + ?? throw new ResourceNotFoundException(); var entityToDelete = await DbContext.WebAuthnCredential.FindAsync([credentialId], cancellationToken) - ?? throw new ResourceNotFoundException("WebAuthnCredential"); + ?? throw new ResourceNotFoundException(); DbContext.WebAuthnCredential.Remove(entityToDelete); 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 6bc90ffa57..225c9764d6 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,5 @@ public interface IIdentityController : IAppController Task VerifyWebAuthAssertion(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken); [HttpPost] - Task VerifyWebAuthAndSignIn(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken); + Task VerifyWebAuthAndSignIn(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken) => default!; } 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..a0da1465a0 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 @@ -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..156c968eb1 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 @@ -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 b602e58c7e..bf650a6475 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Shared/Resources/AppStrings.resx @@ -1228,7 +1228,4 @@ Passwordless login disabled successfully - - Something went wrong during credential process - \ No newline at end of file From 3b085dd92aea3dbdb906b77ff76721de33e90b89 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 11 Mar 2025 12:00:04 +0330 Subject: [PATCH 09/17] fix review --- .../Components/Pages/Identity/SignIn/SignInPanel.razor | 4 ++-- .../Components/Pages/Identity/SignIn/SignInPanel.razor.cs | 1 - .../Extensions/IJSRuntimeWebAuthnExtensions.cs | 1 - .../Data/Migrations/20250310082820_InitialMigration.cs | 5 +---- .../Shared/Controllers/Identity/IIdentityController.cs | 2 +- .../src/Shared/Controllers/Identity/IUserController.cs | 4 ++-- .../Bit.Boilerplate/src/Shared/Dtos/AppJsonContext.cs | 8 ++++---- 7 files changed, 10 insertions(+), 15 deletions(-) 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 2c2d107cf4..969e07a0ec 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 @@ -71,9 +71,9 @@ Size="BitSize.Large" Variant="BitVariant.Text" Color="BitColor.Tertiary" + OnClick="OnPasswordlessSignIn" ButtonType="BitButtonType.Button" - IconName="@BitIconName.Fingerprint" - OnClick="OnPasswordlessSignIn" /> + IconName="@BitIconName.Fingerprint" /> } 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 d943774c1e..950a796fac 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 @@ -1,5 +1,4 @@ using Boilerplate.Shared.Dtos.Identity; -using Fido2NetLib; namespace Boilerplate.Client.Core.Components.Pages.Identity.SignIn; 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 5e3e52a2a4..49be599d9f 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 @@ -1,5 +1,4 @@ //+:cnd:noEmit - using Fido2NetLib; namespace Microsoft.JSInterop; diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250310082820_InitialMigration.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250310082820_InitialMigration.cs index 52b05f52aa..a714ff1cc4 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250310082820_InitialMigration.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250310082820_InitialMigration.cs @@ -1,7 +1,4 @@ -using System; -using Microsoft.EntityFrameworkCore.Migrations; - -#nullable disable +#nullable disable #pragma warning disable CA1814 // Prefer jagged arrays over multidimensional 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 225c9764d6..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,7 +1,7 @@ //+:cnd:noEmit -using Boilerplate.Shared.Dtos.Identity; using Fido2NetLib; using Fido2NetLib.Objects; +using Boilerplate.Shared.Dtos.Identity; namespace Boilerplate.Shared.Controllers.Identity; 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 93f9ffcde6..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,5 +1,5 @@ -using Boilerplate.Shared.Dtos.Identity; -using Fido2NetLib; +using Fido2NetLib; +using Boilerplate.Shared.Dtos.Identity; namespace Boilerplate.Shared.Controllers.Identity; 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 ebdd5ec653..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,20 +1,20 @@ //+:cnd:noEmit +using Fido2NetLib; +using Fido2NetLib.Objects; //#if (sample == true) -using Boilerplate.Shared.Dtos.Categories; +using Boilerplate.Shared.Dtos.Todo; //#endif //#if (module == "Admin") using Boilerplate.Shared.Dtos.Dashboard; //#endif //#if (module == "Admin" || module == "Sales") using Boilerplate.Shared.Dtos.Products; +using Boilerplate.Shared.Dtos.Categories; //#endif //#if (notification == true) using Boilerplate.Shared.Dtos.PushNotification; //#endif using Boilerplate.Shared.Dtos.Statistics; -using Boilerplate.Shared.Dtos.Todo; -using Fido2NetLib; -using Fido2NetLib.Objects; namespace Boilerplate.Shared.Dtos; From 58462671befb444ca6a7d5fb26bcf3d85bcb9c12 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 11 Mar 2025 12:36:57 +0330 Subject: [PATCH 10/17] improve showing passwordless btn --- .../Pages/Authorized/Settings/PasswordlessSection.razor.cs | 3 ++- .../Components/Pages/Identity/SignIn/SignInPage.razor.cs | 1 + .../Components/Pages/Identity/SignIn/SignInPanel.razor | 2 +- .../Components/Pages/Identity/SignIn/SignInPanel.razor.cs | 6 +++--- .../Bit.Boilerplate/src/Shared/Dtos/Identity/UserDto.cs | 3 ++- 5 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs index 0a6f900fda..9e252e95e3 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs @@ -38,6 +38,7 @@ private async Task EnablePasswordless() } catch (Exception ex) { + // ExceptionHandler.Handle(ex, ExceptionDisplayKind.None); return; } @@ -64,9 +65,9 @@ private async Task DisablePasswordless() } catch (Exception ex) { + // ExceptionHandler.Handle(ex, ExceptionDisplayKind.None); return; - } var verifyResult = await identityController.VerifyWebAuthAssertion(assertion, CurrentCancellationToken); 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 63756f63d1..5e6f7ae67a 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 @@ -167,6 +167,7 @@ private async Task HandleOnPasswordlessSignIn() } catch (Exception ex) { + // ExceptionHandler.Handle(ex, ExceptionDisplayKind.None); return; } 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 969e07a0ec..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 @@ -65,7 +65,7 @@ - @if (isWebAuthnConfigured) + @if (isWebAuthnAvailable) { FullName ?? Email ?? PhoneNumber ?? UserName; + public string? DisplayName => FullName ?? DisplayUserName; + public string? DisplayUserName => FullName ?? Email ?? PhoneNumber ?? UserName; public string? GetProfileImageUrl(Uri absoluteServerAddress) { From fa569f7635f8fc061c3c5d2e723fa1ece21d3c72 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 11 Mar 2025 13:43:10 +0330 Subject: [PATCH 11/17] add comments and review --- .../Settings/PasswordlessSection.razor.cs | 8 ++++---- .../Pages/Identity/SignIn/SignInPage.razor.cs | 2 +- .../wwwroot/images/bit-logo.png | Bin 0 -> 2463 bytes .../Bit.Boilerplate/src/Directory.Packages.props | 1 - .../src/Directory.Packages8.props | 1 - .../Boilerplate.Server.Api.csproj | 1 - .../Identity/UserController.WebAuthn.cs | 4 ++-- .../Models/Identity/User.cs | 3 ++- .../Boilerplate.Server.Api/Program.Services.cs | 3 ++- 9 files changed, 11 insertions(+), 12 deletions(-) create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/wwwroot/images/bit-logo.png diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs index 9e252e95e3..f23b7b0826 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Authorized/Settings/PasswordlessSection.razor.cs @@ -1,6 +1,6 @@ -using Boilerplate.Shared.Controllers.Identity; +using Fido2NetLib; using Boilerplate.Shared.Dtos.Identity; -using Fido2NetLib; +using Boilerplate.Shared.Controllers.Identity; namespace Boilerplate.Client.Core.Components.Pages.Authorized.Settings; @@ -38,7 +38,7 @@ private async Task EnablePasswordless() } 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; } @@ -65,7 +65,7 @@ private async Task DisablePasswordless() } 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; } 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 5e6f7ae67a..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 @@ -167,7 +167,7 @@ private async Task HandleOnPasswordlessSignIn() } 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; } 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 0000000000000000000000000000000000000000..1629cbe86b5305df2fb933cbae2cfb6edde90fab GIT binary patch literal 2463 zcmV;Q31Ie#P)K)Vbpw@&EXgRB}Kq~WEiJnseEoT*0xdoi93jgr<(khi$N9==5^;m;0_>k+NBNjgm2LmRd0Cnm3{2y02N->W(9p7iJz(HUG=!;m^PBJ*Cdjdiu5Ut132`JYB zAEf^z#th{%9B}4?{p~^pYBI8+SK~?SLR}S%X&!HYK7(2hF)p&vB>(QqtDAs(fEtgT zb%WKYiJI*NNi7nY?VL}BJvogsa-lAd{4?8SKZcrJ2-j*Ndc7ti8%!rXx_s0DP1uXj ziUvdzfR`Jf0&(Lqt$-)yT|#kzS9ub7Ym&z8KNRdDIr5Okqb*qORKU1af-(s^eOsaZ zmYc$d8qptWJo;5%#msK6!wo%+V!EqgA7WqZrM4q84_6ZoeF4{+h^3XFyU55>+Gg(? z(0&@jh^BAC)yY^|4S-S&^10kS=^BJ1eO|hO*XVZF>5AI@P;lU7nIu?e5XE%B`bhYXI$z+_>RW=e_%^2^?pQt23%;QB|qK3y_&EW7ZUjA zsI#xr`{?mW%sY@-9arI6n=s3|HfzHEP+p%chsUDf-9Yvku&Xy}TXa2u+@6lo%??C! z)?5lg^cJsUd?z7p4KnBe+FH96WtpLQqs%&Taek+vYsnRK0FfKt{yn2#3?%3PDqr&! zc;P{P%-$P_m@}!6ZvlsmY%}_){6?0+j8{(CC>I&^T@c#fCGeee@uR@BoPjQjq!2ah zPxN}<1xTJey*THunFn52t1E3MI57kA1Q^W=Cd)#fXW{X_BiDKzS9|d?}GbU>RZumUF*Fu*j zYPQSW|GtFIs%f|~8PVP}RCOdGQ*$?#-yquA_v$TRH!2duY^R_)(aEd6od1~X&U2dl<^PUi8yFtopj6REERCFUeD{m@86 zml&EK*=sHr-9Rr=9>pNrGh{)%GDS+AWm;U^ky6B)B+fVY2 z!k4zyY?D~}_B>ygm5l~q0V)fyofFc`tDM>oIUE=dGOEZT^B!Dlx}PTk%fWyZM@>eS zOS3Z(_;L}xhl62(%bYYWpVhXS4xjy&oa_rBN&7B@r6tsok2GRU(Kh?#atrZ;tbtcW zN!W8iOt;bnhtiI^*K54y#d1sWi=0b;swm0G9SC<*u60-CCBj^x$=X3z7RW{3HCI(s zon+c(zl;;P47d{rdXLR^)NRzbeMheE1Mq}s zmoGK|ZYcl4x`uMrzC7&~*0pqL!hVEMxPjiIihlnCwxDt|Nqe&ILyLyCl7HRdc_UZx z>cgmP&2Awt z3G4>l1@d=rpVdU|E4~OY)j9L%C4@6`ahV8=z~WU@9@j+VS - diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props index de9c5b12ca..a5ff3d6d3f 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props +++ b/src/Templates/Boilerplate/Bit.Boilerplate/src/Directory.Packages8.props @@ -9,7 +9,6 @@ - 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 7302609dac..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,7 +15,6 @@ - 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 e4fa25ca10..51230b855a 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 @@ -28,8 +28,8 @@ public async Task GetWebAuthnCredentialOptions(Cancella var fidoUser = new Fido2User { Id = Encoding.UTF8.GetBytes(userId.ToString()), - Name = user.UserName, - DisplayName = user.DisplayName + Name = user.DisplayUserName, + DisplayName = user.DisplayName, }; var options = fido2.RequestNewCredential(new RequestNewCredentialParams 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..48a96e651e 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; } 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 a42abf56f2..700a3bd9a3 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 @@ -301,8 +301,9 @@ void AddDbContext(DbContextOptionsBuilder options) services.AddFido2(options => { + options.ServerIcon = "data:image/png;base64,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"; options.ServerDomain = "localhost"; - options.ServerName = "Boilerplate fido2 server"; + options.ServerName = "Boilerplate WebAuthn"; options.Origins = new HashSet { "http://localhost:5030" }; options.TimestampDriftTolerance = 1000; }); From bdb79bdec5e071e3cf56f273b8a6504e202ec3b7 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 11 Mar 2025 13:43:21 +0330 Subject: [PATCH 12/17] rename ts file --- .../Scripts/{webAuthn.ts => WWebAuthn.ts} | 38 +++++++++---------- 1 file changed, 19 insertions(+), 19 deletions(-) rename src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/{webAuthn.ts => WWebAuthn.ts} (67%) 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/WWebAuthn.ts similarity index 67% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/webAuthn.ts rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WWebAuthn.ts index 56d2fa85db..d3bb2b27db 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/WWebAuthn.ts @@ -24,11 +24,11 @@ class WebAuthn { public static async createCredential(options: PublicKeyCredentialCreationOptions) { if (typeof options.challenge === 'string') { - options.challenge = WebAuthn.fromBase64Url(options.challenge); + options.challenge = WebAuthn.urlToArray(options.challenge); } if (typeof options.user.id === 'string') { - options.user.id = WebAuthn.fromBase64Url(options.user.id); + options.user.id = WebAuthn.urlToArray(options.user.id); } if (options.rp.id === null) { @@ -38,20 +38,20 @@ class WebAuthn { for (let cred of options.excludeCredentials || []) { if (typeof cred.id !== 'string') continue; - cred.id = WebAuthn.fromBase64Url(cred.id); + 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.base64StringToUrl(credential.id), - rawId: WebAuthn.toBase64Url(credential.rawId), + id: WebAuthn.base64ToUrl(credential.id), + rawId: WebAuthn.arrayToUrl(credential.rawId), type: credential.type, clientExtensionResults: credential.getClientExtensionResults(), response: { - attestationObject: WebAuthn.toBase64Url(response.attestationObject), - clientDataJSON: WebAuthn.toBase64Url(response.clientDataJSON), + attestationObject: WebAuthn.arrayToUrl(response.attestationObject), + clientDataJSON: WebAuthn.arrayToUrl(response.clientDataJSON), transports: response.getTransports ? response.getTransports() : [] } }; @@ -59,14 +59,14 @@ class WebAuthn { public static async verifyCredential(options: PublicKeyCredentialRequestOptions) { if (typeof options.challenge === 'string') { - options.challenge = WebAuthn.fromBase64Url(options.challenge); + 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.fromBase64Url(id); + options.allowCredentials[i].id = WebAuthn.urlToArray(id); } } } @@ -75,30 +75,30 @@ class WebAuthn { return { id: credential.id, - rawId: WebAuthn.toBase64Url(credential.rawId), + rawId: WebAuthn.arrayToUrl(credential.rawId), type: credential.type, clientExtensionResults: credential.getClientExtensionResults(), response: { - authenticatorData: WebAuthn.toBase64Url(response.authenticatorData), - clientDataJSON: WebAuthn.toBase64Url(response.clientDataJSON), - userHandle: response.userHandle && response.userHandle.byteLength > 0 ? WebAuthn.toBase64Url(response.userHandle) : undefined, - signature: WebAuthn.toBase64Url(response.signature) + 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 toBase64Url(arrayBuffer: ArrayBuffer): string { - return btoa(String.fromCharCode(...new Uint8Array(arrayBuffer))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); + private static arrayToUrl(value: ArrayBuffer): string { + return btoa(String.fromCharCode(...new Uint8Array(value))).replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); } - private static fromBase64Url(value: string): Uint8Array { + private static urlToArray(value: string): Uint8Array { return Uint8Array.from(atob(value.replace(/-/g, "+").replace(/_/g, "/")), c => c.charCodeAt(0)); } - private static base64StringToUrl(base64String: string): string { - return base64String.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); + private static base64ToUrl(value: string): string { + return value.replace(/\+/g, "-").replace(/\//g, "_").replace(/=*$/g, ""); } } From 11aa64ad391ce9598fdbc47f7d0d655882c4b2f6 Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 11 Mar 2025 13:43:35 +0330 Subject: [PATCH 13/17] rename ts file back --- .../Boilerplate.Client.Core/Scripts/{WWebAuthn.ts => WebAuthn.ts} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/{WWebAuthn.ts => WebAuthn.ts} (100%) diff --git a/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WWebAuthn.ts b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebAuthn.ts similarity index 100% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WWebAuthn.ts rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Scripts/WebAuthn.ts From 4c8af44e97604ea2d7500645c7e253557bde09c2 Mon Sep 17 00:00:00 2001 From: ysmoradi Date: Tue, 11 Mar 2025 11:42:46 +0100 Subject: [PATCH 14/17] fix --- .../Pages/Identity/SignIn/SignInPanel.razor.cs | 1 + .../Boilerplate.Server.Api/Program.Services.cs | 17 +++++++++++++---- .../Services/WebServerExceptionHandler.cs | 5 +++++ 3 files changed, 19 insertions(+), 4 deletions(-) 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 3eac883344..f00ce3a307 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 @@ -31,6 +31,7 @@ public partial class SignInPanel protected override async Task OnAfterFirstRenderAsync() { isWebAuthnAvailable = await JSRuntime.IsWebAuthnAvailable(); + StateHasChanged(); if (await JSRuntime.IsWebAuthnConfigured()) { 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 700a3bd9a3..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 @@ -301,11 +301,20 @@ void AddDbContext(DbContextOptionsBuilder options) services.AddFido2(options => { - options.ServerIcon = "data:image/png;base64,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"; - options.ServerDomain = "localhost"; - options.ServerName = "Boilerplate WebAuthn"; - options.Origins = new HashSet { "http://localhost:5030" }; + 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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"; }); } 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. From 267c6d63fcc4143f7b4e0192cd29873f6acd14bf Mon Sep 17 00:00:00 2001 From: Saleh Yusefnejad Date: Tue, 11 Mar 2025 14:24:57 +0330 Subject: [PATCH 15/17] fix appstrings --- .../Components/Pages/Identity/SignIn/SignInPanel.razor.cs | 1 + .../Bit.Boilerplate/src/Shared/Resources/AppStrings.fa.resx | 2 +- .../Bit.Boilerplate/src/Shared/Resources/AppStrings.nl.resx | 2 +- .../Bit.Boilerplate/src/Shared/Resources/AppStrings.resx | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) 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 f00ce3a307..f9158b3e6f 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 @@ -31,6 +31,7 @@ public partial class SignInPanel protected override async Task OnAfterFirstRenderAsync() { isWebAuthnAvailable = await JSRuntime.IsWebAuthnAvailable(); + StateHasChanged(); if (await JSRuntime.IsWebAuthnConfigured()) 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 a0da1465a0..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 @@ حساب کاربری - ایمیل یا شماره تلفن حساب کاربری خود را تغییر دهید + ایمیل، شماره تلفن یا تنظیمات بی رمز حساب کاربری خود را تغییر دهید حذف حساب 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 156c968eb1..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 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 bf650a6475..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 From 0dd4f6b743e1baab0962ee8267ee9dde931c663d Mon Sep 17 00:00:00 2001 From: ysmoradi Date: Tue, 11 Mar 2025 12:05:17 +0100 Subject: [PATCH 16/17] fix --- .../Components/Pages/Authorized/Settings/SettingsPage.razor.cs | 2 +- .../Components/Pages/Identity/SignIn/SignInPanel.razor.cs | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) 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 12d1b0e339..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 @@ -34,7 +34,7 @@ protected override async Task OnInitAsync() try { user = (await PrerenderStateService.GetValue(() => HttpClient.GetFromJsonAsync("api/User/GetCurrentUser", JsonSerializerOptions.GetTypeInfo(), CurrentCancellationToken)))!; - showPasswordless = await JSRuntime.IsWebAuthnAvailable(); + 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/SignInPanel.razor.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Client/Boilerplate.Client.Core/Components/Pages/Identity/SignIn/SignInPanel.razor.cs index f9158b3e6f..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 @@ -30,8 +30,7 @@ public partial class SignInPanel protected override async Task OnAfterFirstRenderAsync() { - isWebAuthnAvailable = await JSRuntime.IsWebAuthnAvailable(); - + isWebAuthnAvailable = await JSRuntime.IsWebAuthnAvailable() && AppPlatform.IsBlazorHybrid is false; StateHasChanged(); if (await JSRuntime.IsWebAuthnConfigured()) From babb85a84b721c4740542cfb756f0d85acf9399d Mon Sep 17 00:00:00 2001 From: ysmoradi Date: Tue, 11 Mar 2025 12:31:01 +0100 Subject: [PATCH 17/17] fix --- .../Identity/IdentityController.WebAuthn.cs | 3 ++- .../Identity/WebAuthnCredentialConfiguration.cs | 14 ++++++++++++++ ...=> 20250311112214_InitialMigration.Designer.cs} | 6 ++++-- ...ation.cs => 20250311112214_InitialMigration.cs} | 0 .../Data/Migrations/AppDbContextModelSnapshot.cs | 4 +++- .../Boilerplate.Server.Api/Models/Identity/User.cs | 2 ++ .../Models/Identity/WebAuthnCredential.cs | 3 +-- .../Services/GoogleRecaptchaService.cs | 4 +++- .../Services/NugetStatisticsService.cs | 3 ++- .../Services/ServerJsonContext.cs | 2 ++ .../Tests/Services/FakeGoogleRecaptchaService.cs | 2 +- 11 files changed, 34 insertions(+), 9 deletions(-) create mode 100644 src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Configurations/Identity/WebAuthnCredentialConfiguration.cs rename src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/{20250310082820_InitialMigration.Designer.cs => 20250311112214_InitialMigration.Designer.cs} (99%) rename src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/{20250310082820_InitialMigration.cs => 20250311112214_InitialMigration.cs} (100%) 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 e82c3aef50..ba63186b45 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 @@ -12,6 +12,7 @@ public partial class IdentityController { [AutoInject] private IFido2 fido2 = default!; [AutoInject] private IDistributedCache cache = default!; + [AutoInject] protected JsonSerializerOptions jsonSerializerOptions = default!; [HttpGet] @@ -68,7 +69,7 @@ public async Task VerifyWebAuthAndSignIn(AuthenticatorAssertionRawResponse clien private async Task<(VerifyAssertionResult, WebAuthnCredential)> Verify(AuthenticatorAssertionRawResponse clientResponse, CancellationToken cancellationToken) { - var response = JsonSerializer.Deserialize(clientResponse.Response.ClientDataJson) + 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)]); 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/20250310082820_InitialMigration.Designer.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.Designer.cs similarity index 99% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250310082820_InitialMigration.Designer.cs rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.Designer.cs index d5c39d90c7..51f46b9bcd 100644 --- a/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250310082820_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("20250310082820_InitialMigration")] +[Migration("20250311112214_InitialMigration")] partial class InitialMigration { /// @@ -2090,7 +2090,7 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder) modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.WebAuthnCredential", b => { b.HasOne("Boilerplate.Server.Api.Models.Identity.User", "User") - .WithMany() + .WithMany("WebAuthnCredentials") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -2191,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/20250310082820_InitialMigration.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.cs similarity index 100% rename from src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250310082820_InitialMigration.cs rename to src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Data/Migrations/20250311112214_InitialMigration.cs 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 e8c4e96cec..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 @@ -2087,7 +2087,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) modelBuilder.Entity("Boilerplate.Server.Api.Models.Identity.WebAuthnCredential", b => { b.HasOne("Boilerplate.Server.Api.Models.Identity.User", "User") - .WithMany() + .WithMany("WebAuthnCredentials") .HasForeignKey("UserId") .OnDelete(DeleteBehavior.Cascade) .IsRequired(); @@ -2188,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/Models/Identity/User.cs b/src/Templates/Boilerplate/Bit.Boilerplate/src/Server/Boilerplate.Server.Api/Models/Identity/User.cs index 48a96e651e..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 @@ -45,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 index 2270429ada..a44563d2cc 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 @@ -4,13 +4,12 @@ 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 required byte[] Id { get; set; } - public byte[]? PublicKey { get; set; } public uint SignCount { get; set; } 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/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) {