From 97b4503bd568780bb17a80a4dee910652c68a598 Mon Sep 17 00:00:00 2001 From: Matt LaPaglia Date: Thu, 28 Aug 2025 22:41:57 -0400 Subject: [PATCH 01/23] passkey --- .../UpdatePlateNumberCommand.cs | 1 + .../UpdatePlateNumberCommandHandler.cs | 1 + .../Services/LicensePlateFeatureExtractor.cs | 2 +- .../Features/Users/AuthController.cs | 103 ++++++ .../AuthenticateCommandHandler.cs | 12 +- .../AuthenticatePasskeyCommand.cs | 6 + .../AuthenticatePasskeyCommandHandler.cs | 61 ++++ .../AuthenticatePasskeyResponse.cs | 6 + .../CompletePasskeyAuthenticationCommand.cs | 10 + ...letePasskeyAuthenticationCommandHandler.cs | 108 ++++++ .../CompletePasskeyRegistrationCommand.cs | 10 + ...mpletePasskeyRegistrationCommandHandler.cs | 98 +++++ .../CompletePasskeyRegistrationResponse.cs | 4 + .../DeletePasskey/DeletePasskeyCommand.cs | 7 + .../DeletePasskeyCommandHandler.cs | 41 +++ .../DeletePasskey/DeletePasskeyResponse.cs | 4 + .../RegisterPasskey/RegisterPasskeyCommand.cs | 7 + .../RegisterPasskeyCommandHandler.cs | 69 ++++ .../RegisterPasskeyResponse.cs | 6 + .../Features/Users/Data/ApplicationUser.cs | 3 + ...250829010333_AddPasskeySupport.Designer.cs | 341 ++++++++++++++++++ .../20250829010333_AddPasskeySupport.cs | 60 +++ .../Migrations/UsersContextModelSnapshot.cs | 70 +++- .../Features/Users/Data/PasskeyCredential.cs | 43 +++ .../Features/Users/Data/UsersContext.cs | 14 + .../Users/Queries/GetAllUsers/UserDto.cs | 2 + .../GetUserPasskeys/GetUserPasskeysQuery.cs | 7 + .../GetUserPasskeysQueryHandler.cs | 43 +++ .../GetUserPasskeysResponse.cs | 13 + .../OpenAlprWebhookProcessor.Server.csproj | 2 + OpenAlprWebhookProcessor.Server/Startup.cs | 13 + .../AuthenticateCommandHandlerTests.cs | 2 +- .../src/app/_models/user.ts | 1 + .../src/app/account/account.service.ts | 35 ++ .../src/app/account/login/login.component.css | 58 +++ .../app/account/login/login.component.html | 29 +- .../src/app/account/login/login.component.ts | 109 ++++++ .../verify-2fa/verify-2fa.component.css | 51 +++ .../verify-2fa/verify-2fa.component.html | 39 +- .../verify-2fa/verify-2fa.component.ts | 111 ++++++ .../predictions-section.component.html | 1 + .../predictions-section.component.ts | 1 + .../users/user-2fa/user-2fa.component.css | 24 +- .../users/user-2fa/user-2fa.component.html | 7 +- .../user-passkeys/user-passkeys.component.css | 150 ++++++++ .../user-passkeys.component.html | 129 +++++++ .../user-passkeys/user-passkeys.component.ts | 242 +++++++++++++ .../app/settings/users/users.component.html | 8 + .../src/app/settings/users/users.component.ts | 19 + .../qr-code-display.component.css | 4 +- .../qr-code-display.component.ts | 2 +- 51 files changed, 2165 insertions(+), 24 deletions(-) create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/AuthenticatePasskey/AuthenticatePasskeyCommand.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/AuthenticatePasskey/AuthenticatePasskeyCommandHandler.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/AuthenticatePasskey/AuthenticatePasskeyResponse.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyAuthentication/CompletePasskeyAuthenticationCommand.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyAuthentication/CompletePasskeyAuthenticationCommandHandler.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyRegistration/CompletePasskeyRegistrationCommand.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyRegistration/CompletePasskeyRegistrationCommandHandler.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyRegistration/CompletePasskeyRegistrationResponse.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/DeletePasskey/DeletePasskeyCommand.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/DeletePasskey/DeletePasskeyCommandHandler.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/DeletePasskey/DeletePasskeyResponse.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/RegisterPasskey/RegisterPasskeyCommand.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/RegisterPasskey/RegisterPasskeyCommandHandler.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Commands/RegisterPasskey/RegisterPasskeyResponse.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Data/Migrations/20250829010333_AddPasskeySupport.Designer.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Data/Migrations/20250829010333_AddPasskeySupport.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Data/PasskeyCredential.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetUserPasskeys/GetUserPasskeysQuery.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetUserPasskeys/GetUserPasskeysQueryHandler.cs create mode 100644 OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetUserPasskeys/GetUserPasskeysResponse.cs create mode 100644 openalprwebhookprocessor.client/src/app/settings/users/user-passkeys/user-passkeys.component.css create mode 100644 openalprwebhookprocessor.client/src/app/settings/users/user-passkeys/user-passkeys.component.html create mode 100644 openalprwebhookprocessor.client/src/app/settings/users/user-passkeys/user-passkeys.component.ts diff --git a/OpenAlprWebhookProcessor.Server/Features/LicensePlates/Commands/UpdatePlateNumber/UpdatePlateNumberCommand.cs b/OpenAlprWebhookProcessor.Server/Features/LicensePlates/Commands/UpdatePlateNumber/UpdatePlateNumberCommand.cs index 2aa4e082..6dfd70c6 100644 --- a/OpenAlprWebhookProcessor.Server/Features/LicensePlates/Commands/UpdatePlateNumber/UpdatePlateNumberCommand.cs +++ b/OpenAlprWebhookProcessor.Server/Features/LicensePlates/Commands/UpdatePlateNumber/UpdatePlateNumberCommand.cs @@ -11,3 +11,4 @@ public class UpdatePlateNumberCommand : ICommand } + diff --git a/OpenAlprWebhookProcessor.Server/Features/LicensePlates/Commands/UpdatePlateNumber/UpdatePlateNumberCommandHandler.cs b/OpenAlprWebhookProcessor.Server/Features/LicensePlates/Commands/UpdatePlateNumber/UpdatePlateNumberCommandHandler.cs index 94a85ddf..6589016b 100644 --- a/OpenAlprWebhookProcessor.Server/Features/LicensePlates/Commands/UpdatePlateNumber/UpdatePlateNumberCommandHandler.cs +++ b/OpenAlprWebhookProcessor.Server/Features/LicensePlates/Commands/UpdatePlateNumber/UpdatePlateNumberCommandHandler.cs @@ -34,3 +34,4 @@ public async ValueTask Handle(UpdatePlateNumberCommand request, Cancellati } + diff --git a/OpenAlprWebhookProcessor.Server/Features/MachineLearning/Services/LicensePlateFeatureExtractor.cs b/OpenAlprWebhookProcessor.Server/Features/MachineLearning/Services/LicensePlateFeatureExtractor.cs index 36bfc681..25d3452d 100644 --- a/OpenAlprWebhookProcessor.Server/Features/MachineLearning/Services/LicensePlateFeatureExtractor.cs +++ b/OpenAlprWebhookProcessor.Server/Features/MachineLearning/Services/LicensePlateFeatureExtractor.cs @@ -53,7 +53,7 @@ public async Task> ExtractTrainingDataAsync( var nextTime = DateTimeOffset.FromUnixTimeMilliseconds(next.ReceivedOnEpoch).DateTime; var hoursUntilNext = (float)(nextTime - currentTime).TotalHours; - if (hoursUntilNext > 0 && hoursUntilNext < 96) // Less than 4 days - filter out irregular visitors + if (hoursUntilNext > 0 && hoursUntilNext <= 8760) // Include intervals up to 1 year (365 days) { var features = ExtractFeatures(current, sightings.Take(i + 1).ToList()); features.HoursUntilNextSeen = hoursUntilNext; diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/AuthController.cs b/OpenAlprWebhookProcessor.Server/Features/Users/AuthController.cs index 8807c844..c921c700 100644 --- a/OpenAlprWebhookProcessor.Server/Features/Users/AuthController.cs +++ b/OpenAlprWebhookProcessor.Server/Features/Users/AuthController.cs @@ -9,6 +9,12 @@ using OpenAlprWebhookProcessor.Features.Users.Commands.Logout; using OpenAlprWebhookProcessor.Features.Users.Queries.GetCurrentUser; using OpenAlprWebhookProcessor.Features.Users.Queries.CanRegister; +using OpenAlprWebhookProcessor.Features.Users.Commands.RegisterPasskey; +using OpenAlprWebhookProcessor.Features.Users.Commands.CompletePasskeyRegistration; +using OpenAlprWebhookProcessor.Features.Users.Commands.AuthenticatePasskey; +using OpenAlprWebhookProcessor.Features.Users.Commands.CompletePasskeyAuthentication; +using OpenAlprWebhookProcessor.Features.Users.Commands.DeletePasskey; +using OpenAlprWebhookProcessor.Features.Users.Queries.GetUserPasskeys; namespace OpenAlprWebhookProcessor.Features.Users { @@ -101,5 +107,102 @@ public async Task GetCurrentUser(CancellationToken cancellationTo return Ok(user); } + + [HttpPost("passkey/register")] + public async Task RegisterPasskey([FromBody] RegisterPasskeyRequest request, CancellationToken cancellationToken) + { + try + { + var command = new RegisterPasskeyCommand(User, request.Name); + var response = await _mediator.Send(command, cancellationToken); + return Ok(response); + } + catch (AppException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpPost("passkey/complete-registration")] + public async Task CompletePasskeyRegistration([FromBody] CompletePasskeyRegistrationRequest request, CancellationToken cancellationToken) + { + try + { + var command = new CompletePasskeyRegistrationCommand(User, request.AttestationResponse, request.Name); + var response = await _mediator.Send(command, cancellationToken); + return Ok(response); + } + catch (AppException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [AllowAnonymous] + [HttpPost("passkey/authenticate")] + public async Task AuthenticatePasskey([FromBody] AuthenticatePasskeyRequest request, CancellationToken cancellationToken) + { + try + { + var command = new AuthenticatePasskeyCommand(request.Username); + var response = await _mediator.Send(command, cancellationToken); + return Ok(response); + } + catch (AppException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [AllowAnonymous] + [HttpPost("passkey/complete-authentication")] + public async Task CompletePasskeyAuthentication([FromBody] CompletePasskeyAuthenticationRequest request, CancellationToken cancellationToken) + { + try + { + var command = new CompletePasskeyAuthenticationCommand(request.Username, request.AssertionResponse, request.RememberMe); + var response = await _mediator.Send(command, cancellationToken); + return Ok(response); + } + catch (AppException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpGet("passkey/list")] + public async Task GetPasskeys(CancellationToken cancellationToken) + { + try + { + var query = new GetUserPasskeysQuery(User); + var response = await _mediator.Send(query, cancellationToken); + return Ok(response); + } + catch (AppException ex) + { + return BadRequest(new { message = ex.Message }); + } + } + + [HttpDelete("passkey/{passkeyId}")] + public async Task DeletePasskey(int passkeyId, CancellationToken cancellationToken) + { + try + { + var command = new DeletePasskeyCommand(User, passkeyId); + var response = await _mediator.Send(command, cancellationToken); + return Ok(response); + } + catch (AppException ex) + { + return BadRequest(new { message = ex.Message }); + } + } } + + public record RegisterPasskeyRequest(string? Name = null); + public record CompletePasskeyRegistrationRequest(string AttestationResponse, string? Name = null); + public record AuthenticatePasskeyRequest(string Username); + public record CompletePasskeyAuthenticationRequest(string Username, string AssertionResponse, bool RememberMe = false); } \ No newline at end of file diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/Authenticate/AuthenticateCommandHandler.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/Authenticate/AuthenticateCommandHandler.cs index 4bc20278..a6667c90 100644 --- a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/Authenticate/AuthenticateCommandHandler.cs +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/Authenticate/AuthenticateCommandHandler.cs @@ -1,7 +1,9 @@ using Mediator; using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; using OpenAlprWebhookProcessor.Features.Users.Data; using OpenAlprWebhookProcessor.Features.Users.Queries.GetAllUsers; +using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -11,13 +13,16 @@ public class AuthenticateCommandHandler : IQueryHandler _userManager; private readonly SignInManager _signInManager; + private readonly UsersContext _context; public AuthenticateCommandHandler( UserManager userManager, - SignInManager signInManager) + SignInManager signInManager, + UsersContext context) { _userManager = userManager; _signInManager = signInManager; + _context = context; } public async ValueTask Handle(AuthenticateCommand request, CancellationToken cancellationToken) @@ -37,11 +42,16 @@ public async ValueTask Handle(AuthenticateCommand request, Cancellation if (!result.Succeeded) throw new AppException("Username or password is incorrect"); + // Check if user has passkeys + var hasPasskeys = await _context.PasskeyCredentials + .AnyAsync(p => p.UserId == user.Id, cancellationToken); + var authUser = new UserDto { FirstName = user.FirstName, Id = user.Id, TwoFactorEnabled = false, + HasPasskeys = hasPasskeys, LastName = user.LastName, Username = user.UserName, }; diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/AuthenticatePasskey/AuthenticatePasskeyCommand.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/AuthenticatePasskey/AuthenticatePasskeyCommand.cs new file mode 100644 index 00000000..ee962d29 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/AuthenticatePasskey/AuthenticatePasskeyCommand.cs @@ -0,0 +1,6 @@ +using Mediator; + +namespace OpenAlprWebhookProcessor.Features.Users.Commands.AuthenticatePasskey +{ + public record AuthenticatePasskeyCommand(string Username) : IQuery; +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/AuthenticatePasskey/AuthenticatePasskeyCommandHandler.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/AuthenticatePasskey/AuthenticatePasskeyCommandHandler.cs new file mode 100644 index 00000000..ae568b98 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/AuthenticatePasskey/AuthenticatePasskeyCommandHandler.cs @@ -0,0 +1,61 @@ +using Mediator; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using OpenAlprWebhookProcessor.Features.Users.Data; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Fido2NetLib; +using Fido2NetLib.Objects; + +namespace OpenAlprWebhookProcessor.Features.Users.Commands.AuthenticatePasskey +{ + public class AuthenticatePasskeyCommandHandler : IQueryHandler + { + private readonly UserManager _userManager; + private readonly IFido2 _fido2; + private readonly UsersContext _context; + private readonly IMemoryCache _cache; + + public AuthenticatePasskeyCommandHandler( + UserManager userManager, + IFido2 fido2, + UsersContext context, + IMemoryCache cache) + { + _userManager = userManager; + _fido2 = fido2; + _context = context; + _cache = cache; + } + + public async ValueTask Handle(AuthenticatePasskeyCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByNameAsync(request.Username); + if (user == null) + throw new AppException("User not found"); + + // Get existing credentials for this user + var existingCredentials = await _context.PasskeyCredentials + .Where(c => c.UserId == user.Id) + .Select(c => new PublicKeyCredentialDescriptor(Convert.FromBase64String(c.CredentialId))) + .ToListAsync(cancellationToken); + + if (!existingCredentials.Any()) + throw new AppException("No passkeys registered for this user"); + + // Create assertion options + var options = _fido2.GetAssertionOptions( + existingCredentials, + UserVerificationRequirement.Preferred); + + // Store options in cache for later verification (expires in 5 minutes) + var cacheKey = $"passkey_authentication_{user.Id}"; + _cache.Set(cacheKey, options, TimeSpan.FromMinutes(5)); + + return new AuthenticatePasskeyResponse(options); + } + } +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/AuthenticatePasskey/AuthenticatePasskeyResponse.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/AuthenticatePasskey/AuthenticatePasskeyResponse.cs new file mode 100644 index 00000000..3705015b --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/AuthenticatePasskey/AuthenticatePasskeyResponse.cs @@ -0,0 +1,6 @@ +using Fido2NetLib; + +namespace OpenAlprWebhookProcessor.Features.Users.Commands.AuthenticatePasskey +{ + public record AuthenticatePasskeyResponse(AssertionOptions Options); +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyAuthentication/CompletePasskeyAuthenticationCommand.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyAuthentication/CompletePasskeyAuthenticationCommand.cs new file mode 100644 index 00000000..982f3765 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyAuthentication/CompletePasskeyAuthenticationCommand.cs @@ -0,0 +1,10 @@ +using Mediator; +using OpenAlprWebhookProcessor.Features.Users.Queries.GetAllUsers; + +namespace OpenAlprWebhookProcessor.Features.Users.Commands.CompletePasskeyAuthentication +{ + public record CompletePasskeyAuthenticationCommand( + string Username, + string AssertionResponse, + bool RememberMe = false) : IQuery; +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyAuthentication/CompletePasskeyAuthenticationCommandHandler.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyAuthentication/CompletePasskeyAuthenticationCommandHandler.cs new file mode 100644 index 00000000..232f4381 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyAuthentication/CompletePasskeyAuthenticationCommandHandler.cs @@ -0,0 +1,108 @@ +using Mediator; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using OpenAlprWebhookProcessor.Features.Users.Data; +using OpenAlprWebhookProcessor.Features.Users.Queries.GetAllUsers; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Fido2NetLib; +using Fido2NetLib.Objects; +using System.Text.Json; + +namespace OpenAlprWebhookProcessor.Features.Users.Commands.CompletePasskeyAuthentication +{ + public class CompletePasskeyAuthenticationCommandHandler : IQueryHandler + { + private readonly UserManager _userManager; + private readonly SignInManager _signInManager; + private readonly IFido2 _fido2; + private readonly UsersContext _context; + private readonly IMemoryCache _cache; + + public CompletePasskeyAuthenticationCommandHandler( + UserManager userManager, + SignInManager signInManager, + IFido2 fido2, + UsersContext context, + IMemoryCache cache) + { + _userManager = userManager; + _signInManager = signInManager; + _fido2 = fido2; + _context = context; + _cache = cache; + } + + public async ValueTask Handle(CompletePasskeyAuthenticationCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.FindByNameAsync(request.Username); + if (user == null) + throw new AppException("User not found"); + + try + { + // Parse the assertion response + var assertionResponse = JsonSerializer.Deserialize(request.AssertionResponse); + if (assertionResponse == null) + throw new AppException("Invalid assertion response"); + + // Get the credential from database + var credentialId = Convert.ToBase64String(assertionResponse.Id); + var credential = await _context.PasskeyCredentials + .FirstOrDefaultAsync(c => c.CredentialId == credentialId && c.UserId == user.Id, cancellationToken); + + if (credential == null) + throw new AppException("Credential not found"); + + // Retrieve the original options from cache + var optionsCacheKey = $"passkey_authentication_{user.Id}"; + var originalOptions = _cache.Get(optionsCacheKey); + + if (originalOptions == null) + throw new AppException("Authentication session expired or invalid. Please try again."); + + // Remove the options from cache after use to prevent replay attacks + _cache.Remove(optionsCacheKey); + + // Verify the assertion + var result = await _fido2.MakeAssertionAsync( + assertionResponse, + originalOptions, + credential.PublicKey, + credential.SignatureCounter, + async (args, cancellationToken) => + { + // Verify user handle matches + return credential.UserHandle.SequenceEqual(args.UserHandle); + }); + + if (result.Status != "ok") + throw new AppException($"Failed to authenticate with passkey: {result.ErrorMessage}"); + + // Update signature counter + credential.SignatureCounter = result.Counter; + await _context.SaveChangesAsync(cancellationToken); + + // Sign in the user + await _signInManager.SignInAsync(user, request.RememberMe); + + return new UserDto + { + FirstName = user.FirstName, + Id = user.Id, + TwoFactorEnabled = false, // Passkey bypasses 2FA + HasPasskeys = true, // User just authenticated with a passkey + LastName = user.LastName, + Username = user.UserName, + }; + } + catch (Exception ex) + { + throw new AppException($"Passkey authentication failed: {ex.Message}"); + } + } + } +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyRegistration/CompletePasskeyRegistrationCommand.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyRegistration/CompletePasskeyRegistrationCommand.cs new file mode 100644 index 00000000..4b8d5fe0 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyRegistration/CompletePasskeyRegistrationCommand.cs @@ -0,0 +1,10 @@ +using Mediator; +using System.Security.Claims; + +namespace OpenAlprWebhookProcessor.Features.Users.Commands.CompletePasskeyRegistration +{ + public record CompletePasskeyRegistrationCommand( + ClaimsPrincipal User, + string AttestationResponse, + string? Name = null) : IQuery; +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyRegistration/CompletePasskeyRegistrationCommandHandler.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyRegistration/CompletePasskeyRegistrationCommandHandler.cs new file mode 100644 index 00000000..7613730a --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyRegistration/CompletePasskeyRegistrationCommandHandler.cs @@ -0,0 +1,98 @@ +using Mediator; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using OpenAlprWebhookProcessor.Features.Users.Data; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Fido2NetLib; +using Fido2NetLib.Objects; +using System.Text.Json; + +namespace OpenAlprWebhookProcessor.Features.Users.Commands.CompletePasskeyRegistration +{ + public class CompletePasskeyRegistrationCommandHandler : IQueryHandler + { + private readonly UserManager _userManager; + private readonly IFido2 _fido2; + private readonly UsersContext _context; + private readonly IMemoryCache _cache; + + public CompletePasskeyRegistrationCommandHandler( + UserManager userManager, + IFido2 fido2, + UsersContext context, + IMemoryCache cache) + { + _userManager = userManager; + _fido2 = fido2; + _context = context; + _cache = cache; + } + + public async ValueTask Handle(CompletePasskeyRegistrationCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(request.User); + if (user == null) + throw new AppException("User not found"); + + try + { + // Parse the attestation response + var attestationResponse = JsonSerializer.Deserialize(request.AttestationResponse); + if (attestationResponse == null) + throw new AppException("Invalid attestation response"); + + // Retrieve the original options from cache + var optionsCacheKey = $"passkey_registration_{user.Id}"; + var originalOptions = _cache.Get(optionsCacheKey); + + if (originalOptions == null) + throw new AppException("Registration session expired or invalid. Please try again."); + + // Remove the options from cache after use to prevent replay attacks + _cache.Remove(optionsCacheKey); + + // Verify the attestation + var result = await _fido2.MakeNewCredentialAsync( + attestationResponse, + originalOptions, + async (args, cancellationToken) => + { + // Check if credential ID already exists + var existingCredential = await _context.PasskeyCredentials + .FirstOrDefaultAsync(c => c.CredentialId == Convert.ToBase64String(args.CredentialId), cancellationToken); + return existingCredential == null; + }); + + if (result.Status != "ok") + throw new AppException($"Failed to register passkey: {result.ErrorMessage}"); + + // Store the credential in the database + var credential = new PasskeyCredential + { + UserId = user.Id, + CredentialId = Convert.ToBase64String(result.Result.CredentialId), + PublicKey = result.Result.PublicKey, + UserHandle = result.Result.User.Id, + SignatureCounter = result.Result.Counter, + CredType = result.Result.CredType, + RegDate = DateTime.UtcNow, + AaGuid = result.Result.Aaguid.ToString(), + Name = request.Name ?? $"Passkey {DateTime.UtcNow:yyyy-MM-dd HH:mm}" + }; + + _context.PasskeyCredentials.Add(credential); + await _context.SaveChangesAsync(cancellationToken); + + return new CompletePasskeyRegistrationResponse("Passkey registered successfully", true); + } + catch (Exception ex) + { + return new CompletePasskeyRegistrationResponse($"Failed to register passkey: {ex.Message}", false); + } + } + } +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyRegistration/CompletePasskeyRegistrationResponse.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyRegistration/CompletePasskeyRegistrationResponse.cs new file mode 100644 index 00000000..24349057 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/CompletePasskeyRegistration/CompletePasskeyRegistrationResponse.cs @@ -0,0 +1,4 @@ +namespace OpenAlprWebhookProcessor.Features.Users.Commands.CompletePasskeyRegistration +{ + public record CompletePasskeyRegistrationResponse(string Message, bool Success); +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/DeletePasskey/DeletePasskeyCommand.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/DeletePasskey/DeletePasskeyCommand.cs new file mode 100644 index 00000000..6b645fe3 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/DeletePasskey/DeletePasskeyCommand.cs @@ -0,0 +1,7 @@ +using Mediator; +using System.Security.Claims; + +namespace OpenAlprWebhookProcessor.Features.Users.Commands.DeletePasskey +{ + public record DeletePasskeyCommand(ClaimsPrincipal User, int PasskeyId) : IQuery; +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/DeletePasskey/DeletePasskeyCommandHandler.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/DeletePasskey/DeletePasskeyCommandHandler.cs new file mode 100644 index 00000000..86d62ba4 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/DeletePasskey/DeletePasskeyCommandHandler.cs @@ -0,0 +1,41 @@ +using Mediator; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using OpenAlprWebhookProcessor.Features.Users.Data; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenAlprWebhookProcessor.Features.Users.Commands.DeletePasskey +{ + public class DeletePasskeyCommandHandler : IQueryHandler + { + private readonly UserManager _userManager; + private readonly UsersContext _context; + + public DeletePasskeyCommandHandler( + UserManager userManager, + UsersContext context) + { + _userManager = userManager; + _context = context; + } + + public async ValueTask Handle(DeletePasskeyCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(request.User); + if (user == null) + throw new AppException("User not found"); + + var passkey = await _context.PasskeyCredentials + .FirstOrDefaultAsync(p => p.Id == request.PasskeyId && p.UserId == user.Id, cancellationToken); + + if (passkey == null) + throw new AppException("Passkey not found"); + + _context.PasskeyCredentials.Remove(passkey); + await _context.SaveChangesAsync(cancellationToken); + + return new DeletePasskeyResponse("Passkey deleted successfully", true); + } + } +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/DeletePasskey/DeletePasskeyResponse.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/DeletePasskey/DeletePasskeyResponse.cs new file mode 100644 index 00000000..19e7fffa --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/DeletePasskey/DeletePasskeyResponse.cs @@ -0,0 +1,4 @@ +namespace OpenAlprWebhookProcessor.Features.Users.Commands.DeletePasskey +{ + public record DeletePasskeyResponse(string Message, bool Success); +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/RegisterPasskey/RegisterPasskeyCommand.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/RegisterPasskey/RegisterPasskeyCommand.cs new file mode 100644 index 00000000..9cb169bf --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/RegisterPasskey/RegisterPasskeyCommand.cs @@ -0,0 +1,7 @@ +using Mediator; +using System.Security.Claims; + +namespace OpenAlprWebhookProcessor.Features.Users.Commands.RegisterPasskey +{ + public record RegisterPasskeyCommand(ClaimsPrincipal User, string? Name = null) : IQuery; +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/RegisterPasskey/RegisterPasskeyCommandHandler.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/RegisterPasskey/RegisterPasskeyCommandHandler.cs new file mode 100644 index 00000000..9d9f4bee --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/RegisterPasskey/RegisterPasskeyCommandHandler.cs @@ -0,0 +1,69 @@ +using Mediator; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Caching.Memory; +using OpenAlprWebhookProcessor.Features.Users.Data; +using System; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Fido2NetLib; +using Fido2NetLib.Objects; +using System.Text; + +namespace OpenAlprWebhookProcessor.Features.Users.Commands.RegisterPasskey +{ + public class RegisterPasskeyCommandHandler : IQueryHandler + { + private readonly UserManager _userManager; + private readonly IFido2 _fido2; + private readonly UsersContext _context; + private readonly IMemoryCache _cache; + + public RegisterPasskeyCommandHandler( + UserManager userManager, + IFido2 fido2, + UsersContext context, + IMemoryCache cache) + { + _userManager = userManager; + _fido2 = fido2; + _context = context; + _cache = cache; + } + + public async ValueTask Handle(RegisterPasskeyCommand request, CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(request.User); + if (user == null) + throw new AppException("User not found"); + + // Get existing credentials for this user + var existingCredentials = await _context.PasskeyCredentials + .Where(c => c.UserId == user.Id) + .Select(c => new PublicKeyCredentialDescriptor(Convert.FromBase64String(c.CredentialId))) + .ToListAsync(cancellationToken); + + // Create user entity for FIDO2 + var fido2User = new Fido2User + { + DisplayName = $"{user.FirstName} {user.LastName}".Trim(), + Name = user.UserName ?? user.Email ?? user.Id.ToString(), + Id = Encoding.UTF8.GetBytes(user.Id.ToString()) + }; + + // Create registration options + var options = _fido2.RequestNewCredential( + fido2User, + existingCredentials, + AuthenticatorSelection.Default, + AttestationConveyancePreference.None); + + // Store options in cache for later verification (expires in 5 minutes) + var cacheKey = $"passkey_registration_{user.Id}"; + _cache.Set(cacheKey, options, TimeSpan.FromMinutes(5)); + + return new RegisterPasskeyResponse(options); + } + } +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Commands/RegisterPasskey/RegisterPasskeyResponse.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/RegisterPasskey/RegisterPasskeyResponse.cs new file mode 100644 index 00000000..35feb52a --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Commands/RegisterPasskey/RegisterPasskeyResponse.cs @@ -0,0 +1,6 @@ +using Fido2NetLib; + +namespace OpenAlprWebhookProcessor.Features.Users.Commands.RegisterPasskey +{ + public record RegisterPasskeyResponse(CredentialCreateOptions Options); +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Data/ApplicationUser.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Data/ApplicationUser.cs index 38effb4b..0d703e8e 100644 --- a/OpenAlprWebhookProcessor.Server/Features/Users/Data/ApplicationUser.cs +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Data/ApplicationUser.cs @@ -1,4 +1,5 @@ using Microsoft.AspNetCore.Identity; +using System.Collections.Generic; namespace OpenAlprWebhookProcessor.Features.Users.Data { @@ -7,5 +8,7 @@ public class ApplicationUser : IdentityUser public string FirstName { get; set; } public string LastName { get; set; } + + public virtual ICollection PasskeyCredentials { get; set; } = new List(); } } \ No newline at end of file diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Data/Migrations/20250829010333_AddPasskeySupport.Designer.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Data/Migrations/20250829010333_AddPasskeySupport.Designer.cs new file mode 100644 index 00000000..cbf9eda9 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Data/Migrations/20250829010333_AddPasskeySupport.Designer.cs @@ -0,0 +1,341 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using OpenAlprWebhookProcessor.Features.Users.Data; + +#nullable disable + +namespace OpenAlprWebhookProcessor.Migrations.Users +{ + [DbContext(typeof(UsersContext))] + [Migration("20250829010333_AddPasskeySupport")] + partial class AddPasskeySupport + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.8"); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("ClaimType") + .HasColumnType("TEXT"); + + b.Property("ClaimValue") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("ProviderKey") + .HasColumnType("TEXT"); + + b.Property("ProviderDisplayName") + .HasColumnType("TEXT"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.Property("LoginProvider") + .HasColumnType("TEXT"); + + b.Property("Name") + .HasColumnType("TEXT"); + + b.Property("Value") + .HasColumnType("TEXT"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("OpenAlprWebhookProcessor.Features.Users.Data.ApplicationUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccessFailedCount") + .HasColumnType("INTEGER"); + + b.Property("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("TEXT"); + + b.Property("Email") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("EmailConfirmed") + .HasColumnType("INTEGER"); + + b.Property("FirstName") + .HasColumnType("TEXT"); + + b.Property("LastName") + .HasColumnType("TEXT"); + + b.Property("LockoutEnabled") + .HasColumnType("INTEGER"); + + b.Property("LockoutEnd") + .HasColumnType("TEXT"); + + b.Property("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .HasColumnType("TEXT"); + + b.Property("PhoneNumber") + .HasColumnType("TEXT"); + + b.Property("PhoneNumberConfirmed") + .HasColumnType("INTEGER"); + + b.Property("SecurityStamp") + .HasColumnType("TEXT"); + + b.Property("TwoFactorEnabled") + .HasColumnType("INTEGER"); + + b.Property("UserName") + .HasMaxLength(256) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("OpenAlprWebhookProcessor.Features.Users.Data.PasskeyCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AaGuid") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CredType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("RegDate") + .HasColumnType("TEXT"); + + b.Property("SignatureCounter") + .HasColumnType("INTEGER"); + + b.Property("UserHandle") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId") + .IsUnique(); + + b.HasIndex("UserId", "CredentialId"); + + b.ToTable("PasskeyCredentials"); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim", b => + { + b.HasOne("OpenAlprWebhookProcessor.Features.Users.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin", b => + { + b.HasOne("OpenAlprWebhookProcessor.Features.Users.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("OpenAlprWebhookProcessor.Features.Users.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken", b => + { + b.HasOne("OpenAlprWebhookProcessor.Features.Users.Data.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("OpenAlprWebhookProcessor.Features.Users.Data.PasskeyCredential", b => + { + b.HasOne("OpenAlprWebhookProcessor.Features.Users.Data.ApplicationUser", "User") + .WithMany("PasskeyCredentials") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenAlprWebhookProcessor.Features.Users.Data.ApplicationUser", b => + { + b.Navigation("PasskeyCredentials"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Data/Migrations/20250829010333_AddPasskeySupport.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Data/Migrations/20250829010333_AddPasskeySupport.cs new file mode 100644 index 00000000..051212d4 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Data/Migrations/20250829010333_AddPasskeySupport.cs @@ -0,0 +1,60 @@ + using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace OpenAlprWebhookProcessor.Migrations.Users +{ + /// + public partial class AddPasskeySupport : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PasskeyCredentials", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + UserId = table.Column(type: "INTEGER", nullable: false), + CredentialId = table.Column(type: "TEXT", maxLength: 255, nullable: false), + PublicKey = table.Column(type: "BLOB", nullable: false), + UserHandle = table.Column(type: "BLOB", nullable: false), + SignatureCounter = table.Column(type: "INTEGER", nullable: false), + CredType = table.Column(type: "TEXT", maxLength: 255, nullable: false), + RegDate = table.Column(type: "TEXT", nullable: false), + AaGuid = table.Column(type: "TEXT", maxLength: 255, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 255, nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_PasskeyCredentials", x => x.Id); + table.ForeignKey( + name: "FK_PasskeyCredentials_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_PasskeyCredentials_CredentialId", + table: "PasskeyCredentials", + column: "CredentialId", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_PasskeyCredentials_UserId_CredentialId", + table: "PasskeyCredentials", + columns: new[] { "UserId", "CredentialId" }); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "PasskeyCredentials"); + } + } +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Data/Migrations/UsersContextModelSnapshot.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Data/Migrations/UsersContextModelSnapshot.cs index 1480e321..591fcb3b 100644 --- a/OpenAlprWebhookProcessor.Server/Features/Users/Data/Migrations/UsersContextModelSnapshot.cs +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Data/Migrations/UsersContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class UsersContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "9.0.7"); + modelBuilder.HasAnnotation("ProductVersion", "9.0.8"); modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => { @@ -214,6 +214,58 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("AspNetUsers", (string)null); }); + modelBuilder.Entity("OpenAlprWebhookProcessor.Features.Users.Data.PasskeyCredential", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AaGuid") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CredType") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("CredentialId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("Name") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property("PublicKey") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("RegDate") + .HasColumnType("TEXT"); + + b.Property("SignatureCounter") + .HasColumnType("INTEGER"); + + b.Property("UserHandle") + .IsRequired() + .HasColumnType("BLOB"); + + b.Property("UserId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("CredentialId") + .IsUnique(); + + b.HasIndex("UserId", "CredentialId"); + + b.ToTable("PasskeyCredentials"); + }); + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim", b => { b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) @@ -264,6 +316,22 @@ protected override void BuildModel(ModelBuilder modelBuilder) .OnDelete(DeleteBehavior.Cascade) .IsRequired(); }); + + modelBuilder.Entity("OpenAlprWebhookProcessor.Features.Users.Data.PasskeyCredential", b => + { + b.HasOne("OpenAlprWebhookProcessor.Features.Users.Data.ApplicationUser", "User") + .WithMany("PasskeyCredentials") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("OpenAlprWebhookProcessor.Features.Users.Data.ApplicationUser", b => + { + b.Navigation("PasskeyCredentials"); + }); #pragma warning restore 612, 618 } } diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Data/PasskeyCredential.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Data/PasskeyCredential.cs new file mode 100644 index 00000000..0b0719e9 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Data/PasskeyCredential.cs @@ -0,0 +1,43 @@ +using System; +using System.ComponentModel.DataAnnotations; + +namespace OpenAlprWebhookProcessor.Features.Users.Data +{ + public class PasskeyCredential + { + [Key] + public int Id { get; set; } + + [Required] + public int UserId { get; set; } + + [Required] + [MaxLength(255)] + public string CredentialId { get; set; } + + [Required] + public byte[] PublicKey { get; set; } + + [Required] + public byte[] UserHandle { get; set; } + + [Required] + public uint SignatureCounter { get; set; } + + [Required] + [MaxLength(255)] + public string CredType { get; set; } + + [Required] + public DateTime RegDate { get; set; } + + [Required] + [MaxLength(255)] + public string AaGuid { get; set; } + + [MaxLength(255)] + public string? Name { get; set; } + + public virtual ApplicationUser User { get; set; } + } +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Data/UsersContext.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Data/UsersContext.cs index 725fb636..5f5827d9 100644 --- a/OpenAlprWebhookProcessor.Server/Features/Users/Data/UsersContext.cs +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Data/UsersContext.cs @@ -8,6 +8,8 @@ namespace OpenAlprWebhookProcessor.Features.Users.Data { public class UsersContext : IdentityDbContext, int> { + public DbSet PasskeyCredentials { get; set; } + public UsersContext(DbContextOptions options) : base(options) { @@ -16,6 +18,18 @@ public UsersContext(DbContextOptions options) protected override void OnModelCreating(ModelBuilder builder) { base.OnModelCreating(builder); + + builder.Entity(entity => + { + entity.HasKey(e => e.Id); + entity.HasOne(e => e.User) + .WithMany(u => u.PasskeyCredentials) + .HasForeignKey(e => e.UserId) + .OnDelete(DeleteBehavior.Cascade); + + entity.HasIndex(e => e.CredentialId).IsUnique(); + entity.HasIndex(e => new { e.UserId, e.CredentialId }); + }); } public override async Task SaveChangesAsync(CancellationToken cancellationToken = default) diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetAllUsers/UserDto.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetAllUsers/UserDto.cs index 25472929..7be322af 100644 --- a/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetAllUsers/UserDto.cs +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetAllUsers/UserDto.cs @@ -11,5 +11,7 @@ public class UserDto public string LastName { get; set; } public bool TwoFactorEnabled { get; set; } + + public bool HasPasskeys { get; set; } } } \ No newline at end of file diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetUserPasskeys/GetUserPasskeysQuery.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetUserPasskeys/GetUserPasskeysQuery.cs new file mode 100644 index 00000000..01b5a2a6 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetUserPasskeys/GetUserPasskeysQuery.cs @@ -0,0 +1,7 @@ +using Mediator; +using System.Security.Claims; + +namespace OpenAlprWebhookProcessor.Features.Users.Queries.GetUserPasskeys +{ + public record GetUserPasskeysQuery(ClaimsPrincipal User) : IQuery; +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetUserPasskeys/GetUserPasskeysQueryHandler.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetUserPasskeys/GetUserPasskeysQueryHandler.cs new file mode 100644 index 00000000..ce7b62c7 --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetUserPasskeys/GetUserPasskeysQueryHandler.cs @@ -0,0 +1,43 @@ +using Mediator; +using Microsoft.AspNetCore.Identity; +using Microsoft.EntityFrameworkCore; +using OpenAlprWebhookProcessor.Features.Users.Data; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace OpenAlprWebhookProcessor.Features.Users.Queries.GetUserPasskeys +{ + public class GetUserPasskeysQueryHandler : IQueryHandler + { + private readonly UserManager _userManager; + private readonly UsersContext _context; + + public GetUserPasskeysQueryHandler( + UserManager userManager, + UsersContext context) + { + _userManager = userManager; + _context = context; + } + + public async ValueTask Handle(GetUserPasskeysQuery request, CancellationToken cancellationToken) + { + var user = await _userManager.GetUserAsync(request.User); + if (user == null) + throw new AppException("User not found"); + + var passkeys = await _context.PasskeyCredentials + .Where(p => p.UserId == user.Id) + .OrderByDescending(p => p.RegDate) + .Select(p => new PasskeyDto( + p.Id, + p.Name ?? "Unnamed Passkey", + p.RegDate, + p.AaGuid)) + .ToListAsync(cancellationToken); + + return new GetUserPasskeysResponse(passkeys); + } + } +} diff --git a/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetUserPasskeys/GetUserPasskeysResponse.cs b/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetUserPasskeys/GetUserPasskeysResponse.cs new file mode 100644 index 00000000..75b69d2d --- /dev/null +++ b/OpenAlprWebhookProcessor.Server/Features/Users/Queries/GetUserPasskeys/GetUserPasskeysResponse.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; + +namespace OpenAlprWebhookProcessor.Features.Users.Queries.GetUserPasskeys +{ + public record GetUserPasskeysResponse(List Passkeys); + + public record PasskeyDto( + int Id, + string Name, + DateTime RegDate, + string AaGuid); +} diff --git a/OpenAlprWebhookProcessor.Server/OpenAlprWebhookProcessor.Server.csproj b/OpenAlprWebhookProcessor.Server/OpenAlprWebhookProcessor.Server.csproj index 3f7263db..bdcf87f9 100644 --- a/OpenAlprWebhookProcessor.Server/OpenAlprWebhookProcessor.Server.csproj +++ b/OpenAlprWebhookProcessor.Server/OpenAlprWebhookProcessor.Server.csproj @@ -70,6 +70,8 @@ + + diff --git a/OpenAlprWebhookProcessor.Server/Startup.cs b/OpenAlprWebhookProcessor.Server/Startup.cs index 95cb674c..0f3000f6 100644 --- a/OpenAlprWebhookProcessor.Server/Startup.cs +++ b/OpenAlprWebhookProcessor.Server/Startup.cs @@ -12,7 +12,10 @@ using OpenAlprWebhookProcessor.Infrastructure.Middleware; using Serilog; using System; +using System.Collections.Generic; using System.Threading.Tasks; +using Fido2NetLib; +using Fido2NetLib.Development; namespace OpenAlprWebhookProcessor { @@ -96,6 +99,16 @@ public void ConfigureServices(IServiceCollection services) services.AddScoped(); + // Configure FIDO2/WebAuthn + services.AddFido2(options => + { + options.ServerDomain = Configuration["Fido2:ServerDomain"] ?? "localhost"; + options.ServerName = "OpenALPR Webhook Processor"; + options.Origins = new HashSet(Configuration.GetSection("Fido2:Origins").Get() ?? new[] { "https://localhost:4200", "https://localhost:5001" }); + options.TimestampDriftTolerance = 300000; + options.MDSCacheDirPath = "./config/mds-cache"; + }); + services.AddExternalServices(); services.AddBackgroundServices(Configuration); diff --git a/Tests/Features/Users/Commands/AuthenticateCommandHandlerTests.cs b/Tests/Features/Users/Commands/AuthenticateCommandHandlerTests.cs index a18505ea..9b1a934f 100644 --- a/Tests/Features/Users/Commands/AuthenticateCommandHandlerTests.cs +++ b/Tests/Features/Users/Commands/AuthenticateCommandHandlerTests.cs @@ -54,7 +54,7 @@ public override void SetUp() _userManager = serviceProvider.GetRequiredService>(); _signInManager = serviceProvider.GetRequiredService>(); - _handler = new AuthenticateCommandHandler(_userManager, _signInManager); + _handler = new AuthenticateCommandHandler(_userManager, _signInManager, UsersContext); } [TearDown] diff --git a/openalprwebhookprocessor.client/src/app/_models/user.ts b/openalprwebhookprocessor.client/src/app/_models/user.ts index a6a9fb01..39bf0dad 100644 --- a/openalprwebhookprocessor.client/src/app/_models/user.ts +++ b/openalprwebhookprocessor.client/src/app/_models/user.ts @@ -6,4 +6,5 @@ firstName: string; lastName: string; twoFactorEnabled: boolean; + hasPasskeys: boolean; } diff --git a/openalprwebhookprocessor.client/src/app/account/account.service.ts b/openalprwebhookprocessor.client/src/app/account/account.service.ts index 20fce5b6..434fc429 100644 --- a/openalprwebhookprocessor.client/src/app/account/account.service.ts +++ b/openalprwebhookprocessor.client/src/app/account/account.service.ts @@ -155,4 +155,39 @@ export class AccountService { return x; })); } + + // Passkey methods + registerPasskey(name?: string) { + return this.http.post<{ options: any }>('/api/auth/passkey/register', { name }); + } + + completePasskeyRegistration(attestationResponse: string, name?: string) { + return this.http.post<{ message: string, success: boolean }>('/api/auth/passkey/complete-registration', { + attestationResponse, + name + }); + } + + authenticatePasskey(username: string) { + return this.http.post<{ options: any }>('/api/auth/passkey/authenticate', { username }); + } + + completePasskeyAuthentication(username: string, assertionResponse: string, rememberMe: boolean = false) { + return this.http.post('/api/auth/passkey/complete-authentication', { + username, + assertionResponse, + rememberMe + }).pipe(map((user) => { + this.userSubject.next(user); + return user; + })); + } + + getPasskeys() { + return this.http.get<{ passkeys: any[] }>('/api/auth/passkey/list'); + } + + deletePasskey(passkeyId: number) { + return this.http.delete<{ message: string, success: boolean }>(`/api/auth/passkey/${passkeyId}`); + } } diff --git a/openalprwebhookprocessor.client/src/app/account/login/login.component.css b/openalprwebhookprocessor.client/src/app/account/login/login.component.css index 8790c924..feff1819 100644 --- a/openalprwebhookprocessor.client/src/app/account/login/login.component.css +++ b/openalprwebhookprocessor.client/src/app/account/login/login.component.css @@ -165,6 +165,64 @@ display: inline-block; } +.divider { + position: relative; + text-align: center; + margin: 24px 0; +} + +.divider::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: var(--mat-sys-outline-variant); +} + +.divider-text { + background: var(--mat-sys-surface); + padding: 0 16px; + color: var(--mat-sys-on-surface-variant); + font-size: 14px; + position: relative; +} + +.passkey-button { + height: 56px; + border-radius: 12px; + font-size: 16px; + font-weight: 500; + text-transform: none; + border: 2px solid var(--mat-sys-outline); + transition: all 0.3s ease; + margin-bottom: 16px; + position: relative; + overflow: hidden; +} + +.passkey-button:hover:not([disabled]) { + transform: translateY(-1px); + border-color: var(--mat-sys-primary); + background: var(--mat-sys-primary-container); +} + +.passkey-button:active:not([disabled]) { + transform: translateY(0); +} + +.passkey-button mat-icon { + margin-right: 8px; +} + +.passkey-hint { + font-size: 12px; + color: var(--mat-sys-on-surface-variant); + text-align: center; + margin: 8px 0 16px 0; +} + .register-section { text-align: center; padding-top: 16px; diff --git a/openalprwebhookprocessor.client/src/app/account/login/login.component.html b/openalprwebhookprocessor.client/src/app/account/login/login.component.html index 48295da9..ae2132df 100644 --- a/openalprwebhookprocessor.client/src/app/account/login/login.component.html +++ b/openalprwebhookprocessor.client/src/app/account/login/login.component.html @@ -61,7 +61,7 @@

Welcome Back

color="primary" type="submit" class="login-button full-width" - [disabled]="loading" + [disabled]="loading || passkeyLoading" i18n> @if (loading) { @@ -71,6 +71,33 @@

Welcome Back

} +
+ or +
+ + + + @if (!f.username.value) { +

Enter your username to use passkey authentication

+ } + @if (canRegister) {

Don't have an account?

diff --git a/openalprwebhookprocessor.client/src/app/account/login/login.component.ts b/openalprwebhookprocessor.client/src/app/account/login/login.component.ts index a062c1ad..77eba40d 100644 --- a/openalprwebhookprocessor.client/src/app/account/login/login.component.ts +++ b/openalprwebhookprocessor.client/src/app/account/login/login.component.ts @@ -46,6 +46,7 @@ export class LoginComponent extends OnPushBaseComponent implements OnInit { form: FormGroup; loading = false; + passkeyLoading = false; submitted = false; public canRegister = false; public currentThemeName = ''; @@ -137,6 +138,8 @@ export class LoginComponent extends OnPushBaseComponent implements OnInit { void this.router.navigate(['/account/verify-2fa'], { queryParams: { userId: response.id, + username: response.username, + hasPasskeys: response.hasPasskeys, rememberMe: this.f.rememberMe.value, returnUrl: this.route.snapshot.queryParams['returnUrl'] ?? '/', }, @@ -153,4 +156,110 @@ export class LoginComponent extends OnPushBaseComponent implements OnInit { }, ); } + + async authenticateWithPasskey() { + const username = this.f.username.value; + if (!username) { + this.snackbarService.create('Please enter your username first', SnackBarType.Error); + return; + } + + this.passkeyLoading = true; + this.markForCheck(); + + try { + // Check if WebAuthn is supported + if (!window.navigator.credentials || !window.PublicKeyCredential) { + throw new Error('Passkeys are not supported in this browser'); + } + + // Step 1: Get authentication options from server + const optionsResponse = await this.accountService.authenticatePasskey(username).pipe(first()).toPromise(); + + if (!optionsResponse?.options) { + throw new Error('Failed to get authentication options'); + } + + // Step 2: Get credential using WebAuthn API + const credential = await navigator.credentials.get({ + publicKey: this.convertAuthenticationOptions(optionsResponse.options) + }) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to authenticate with passkey'); + } + + // Step 3: Send credential to server for verification + const assertionResponse = this.encodeAssertionResponse(credential); + + const user = await this.accountService.completePasskeyAuthentication( + username, + assertionResponse, + this.f.rememberMe.value + ).pipe(first()).toPromise(); + + if (user) { + const returnUrl = this.route.snapshot.queryParams['returnUrl'] ?? '/'; + void this.router.navigateByUrl(returnUrl); + } + + } catch (error) { + console.error('Passkey authentication error:', error); + this.snackbarService.create( + `Passkey authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + SnackBarType.Error + ); + } finally { + this.passkeyLoading = false; + this.markForCheck(); + } + } + + private convertAuthenticationOptions(options: any): PublicKeyCredentialRequestOptions { + return { + ...options, + challenge: this.base64urlToBuffer(options.challenge), + allowCredentials: options.allowCredentials?.map((cred: any) => ({ + ...cred, + id: this.base64urlToBuffer(cred.id) + })) + }; + } + + private encodeAssertionResponse(credential: PublicKeyCredential): string { + const response = credential.response as AuthenticatorAssertionResponse; + + return JSON.stringify({ + id: credential.id, + rawId: this.bufferToBase64url(credential.rawId), + type: credential.type, + response: { + authenticatorData: this.bufferToBase64url(response.authenticatorData), + clientDataJSON: this.bufferToBase64url(response.clientDataJSON), + signature: this.bufferToBase64url(response.signature), + userHandle: response.userHandle ? this.bufferToBase64url(response.userHandle) : null + } + }); + } + + private base64urlToBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '='); + const binary = atob(padded); + const buffer = new ArrayBuffer(binary.length); + const bytes = new Uint8Array(buffer); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return buffer; + } + + private bufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } } diff --git a/openalprwebhookprocessor.client/src/app/account/verify-2fa/verify-2fa.component.css b/openalprwebhookprocessor.client/src/app/account/verify-2fa/verify-2fa.component.css index 25e680cf..c8b7445b 100644 --- a/openalprwebhookprocessor.client/src/app/account/verify-2fa/verify-2fa.component.css +++ b/openalprwebhookprocessor.client/src/app/account/verify-2fa/verify-2fa.component.css @@ -201,6 +201,57 @@ display: inline-block; } +.divider { + position: relative; + text-align: center; + margin: 24px 0; +} + +.divider::before { + content: ''; + position: absolute; + top: 50%; + left: 0; + right: 0; + height: 1px; + background: var(--mat-sys-outline-variant); +} + +.divider-text { + background: var(--mat-sys-surface); + padding: 0 16px; + color: var(--mat-sys-on-surface-variant); + font-size: 14px; + position: relative; +} + +.passkey-button { + height: 56px; + border-radius: 12px; + font-size: 16px; + font-weight: 500; + text-transform: none; + border: 2px solid var(--mat-sys-outline); + transition: all 0.3s ease; + margin-bottom: 16px; + position: relative; + overflow: hidden; +} + +.passkey-button:hover:not([disabled]) { + transform: translateY(-1px); + border-color: var(--mat-sys-primary); + background: var(--mat-sys-primary-container); +} + +.passkey-button:active:not([disabled]) { + transform: translateY(0); +} + +.passkey-button mat-icon { + margin-right: 8px; +} + .login-card { animation: fadeInScale 0.8s ease-out; } diff --git a/openalprwebhookprocessor.client/src/app/account/verify-2fa/verify-2fa.component.html b/openalprwebhookprocessor.client/src/app/account/verify-2fa/verify-2fa.component.html index 457b1a9f..df14fb43 100644 --- a/openalprwebhookprocessor.client/src/app/account/verify-2fa/verify-2fa.component.html +++ b/openalprwebhookprocessor.client/src/app/account/verify-2fa/verify-2fa.component.html @@ -46,20 +46,43 @@

Two-Factor Authentication

diff --git a/openalprwebhookprocessor.client/src/app/account/verify-2fa/verify-2fa.component.ts b/openalprwebhookprocessor.client/src/app/account/verify-2fa/verify-2fa.component.ts index f3b1c376..a2c1bb97 100644 --- a/openalprwebhookprocessor.client/src/app/account/verify-2fa/verify-2fa.component.ts +++ b/openalprwebhookprocessor.client/src/app/account/verify-2fa/verify-2fa.component.ts @@ -15,6 +15,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatIconModule } from '@angular/material/icon'; import { SnackBarType } from 'app/snackbar/snackbartype'; +import { RefreshButtonComponent } from 'app/shared/refresh-button/refresh-button.component'; @Component({ selector: 'app-verify-2fa', @@ -30,6 +31,7 @@ import { SnackBarType } from 'app/snackbar/snackbartype'; MatButtonModule, MatProgressSpinnerModule, MatIconModule, + RefreshButtonComponent, ], }) export class Verify2FAComponent extends OnPushBaseComponent implements OnInit, OnDestroy { @@ -42,8 +44,11 @@ export class Verify2FAComponent extends OnPushBaseComponent implements OnInit, O form!: FormGroup; loading = false; + passkeyLoading = false; submitted = false; userId!: string; + username!: string; + hasPasskeys = false; rememberMe = false; returnUrl = '/'; @@ -58,6 +63,8 @@ export class Verify2FAComponent extends OnPushBaseComponent implements OnInit, O // Get parameters from query string this.userId = this.route.snapshot.queryParams['userId']; + this.username = this.route.snapshot.queryParams['username']; + this.hasPasskeys = this.route.snapshot.queryParams['hasPasskeys'] === 'true'; this.rememberMe = this.route.snapshot.queryParams['rememberMe'] === 'true'; this.returnUrl = this.route.snapshot.queryParams['returnUrl'] ?? '/'; @@ -117,4 +124,108 @@ export class Verify2FAComponent extends OnPushBaseComponent implements OnInit, O }, }); } + + async authenticateWithPasskey() { + if (!this.username) { + this.snackbarService.create('Username not available for passkey authentication', SnackBarType.Error); + return; + } + + this.passkeyLoading = true; + this.markForCheck(); + + try { + // Check if WebAuthn is supported + if (!window.navigator.credentials || !window.PublicKeyCredential) { + throw new Error('Passkeys are not supported in this browser'); + } + + // Step 1: Get authentication options from server + const optionsResponse = await this.accountService.authenticatePasskey(this.username).pipe(first()).toPromise(); + + if (!optionsResponse?.options) { + throw new Error('Failed to get authentication options'); + } + + // Step 2: Get credential using WebAuthn API + const credential = await navigator.credentials.get({ + publicKey: this.convertAuthenticationOptions(optionsResponse.options) + }) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to authenticate with passkey'); + } + + // Step 3: Send credential to server for verification + const assertionResponse = this.encodeAssertionResponse(credential); + + const user = await this.accountService.completePasskeyAuthentication( + this.username, + assertionResponse, + this.rememberMe + ).pipe(first()).toPromise(); + + if (user) { + void this.router.navigateByUrl(this.returnUrl); + } + + } catch (error) { + console.error('Passkey authentication error:', error); + this.snackbarService.create( + `Passkey authentication failed: ${error instanceof Error ? error.message : 'Unknown error'}`, + SnackBarType.Error + ); + } finally { + this.passkeyLoading = false; + this.markForCheck(); + } + } + + private convertAuthenticationOptions(options: any): PublicKeyCredentialRequestOptions { + return { + ...options, + challenge: this.base64urlToBuffer(options.challenge), + allowCredentials: options.allowCredentials?.map((cred: any) => ({ + ...cred, + id: this.base64urlToBuffer(cred.id) + })) + }; + } + + private encodeAssertionResponse(credential: PublicKeyCredential): string { + const response = credential.response as AuthenticatorAssertionResponse; + + return JSON.stringify({ + id: credential.id, + rawId: this.bufferToBase64url(credential.rawId), + type: credential.type, + response: { + authenticatorData: this.bufferToBase64url(response.authenticatorData), + clientDataJSON: this.bufferToBase64url(response.clientDataJSON), + signature: this.bufferToBase64url(response.signature), + userHandle: response.userHandle ? this.bufferToBase64url(response.userHandle) : null + } + }); + } + + private base64urlToBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '='); + const binary = atob(padded); + const buffer = new ArrayBuffer(binary.length); + const bytes = new Uint8Array(buffer); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return buffer; + } + + private bufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } } diff --git a/openalprwebhookprocessor.client/src/app/home/predictions-section/predictions-section.component.html b/openalprwebhookprocessor.client/src/app/home/predictions-section/predictions-section.component.html index 5dec7eaf..2029ed43 100644 --- a/openalprwebhookprocessor.client/src/app/home/predictions-section/predictions-section.component.html +++ b/openalprwebhookprocessor.client/src/app/home/predictions-section/predictions-section.component.html @@ -65,6 +65,7 @@

{{ nextExpectedWithFormatting.licensePlate }}

{{ prediction.licensePlate }}
+ {{ prediction.formattedTime }} {{ prediction.totalHistoricalVisits }} visits
({ ...prediction, + formattedTime: this.formatTimeUntil(prediction.predictedNextSeen), confidenceColor: this.getConfidenceColor(prediction.confidenceScore), confidencePercentage: (prediction.confidenceScore * 100).toFixed(0), })); diff --git a/openalprwebhookprocessor.client/src/app/settings/users/user-2fa/user-2fa.component.css b/openalprwebhookprocessor.client/src/app/settings/users/user-2fa/user-2fa.component.css index 9fbfb5eb..4bf3a603 100644 --- a/openalprwebhookprocessor.client/src/app/settings/users/user-2fa/user-2fa.component.css +++ b/openalprwebhookprocessor.client/src/app/settings/users/user-2fa/user-2fa.component.css @@ -1,18 +1,32 @@ -mat-card { +.user-form-card { width: 100%; margin: 0; box-shadow: none; border-radius: 0; + overflow: visible; } -mat-card-header { - padding: 16px 20px 8px; +.card-header { + padding: 24px 24px 16px; + background: linear-gradient(135deg, var(--mdc-theme-primary, #1976d2) 0%, var(--mdc-theme-primary-variant, #1565c0) 100%); + color: var(--mdc-theme-on-primary, #ffffff); + display: flex; + align-items: center; + gap: 16px; +} + +.header-icon { + font-size: 32px; + width: 32px; + height: 32px; + opacity: 0.9; } -mat-card-header h1 { - font-size: 1.25rem; +.card-title h1 { margin: 0; + font-size: 28px; font-weight: 500; + letter-spacing: -0.5px; } mat-card-content { diff --git a/openalprwebhookprocessor.client/src/app/settings/users/user-2fa/user-2fa.component.html b/openalprwebhookprocessor.client/src/app/settings/users/user-2fa/user-2fa.component.html index 54cd28aa..56c15d92 100644 --- a/openalprwebhookprocessor.client/src/app/settings/users/user-2fa/user-2fa.component.html +++ b/openalprwebhookprocessor.client/src/app/settings/users/user-2fa/user-2fa.component.html @@ -1,6 +1,7 @@ - - - + + + security +

Manage 2FA for {{userName}}

diff --git a/openalprwebhookprocessor.client/src/app/settings/users/user-passkeys/user-passkeys.component.css b/openalprwebhookprocessor.client/src/app/settings/users/user-passkeys/user-passkeys.component.css new file mode 100644 index 00000000..4761db10 --- /dev/null +++ b/openalprwebhookprocessor.client/src/app/settings/users/user-passkeys/user-passkeys.component.css @@ -0,0 +1,150 @@ +.user-form-card { + width: 100%; + margin: 0; + box-shadow: none; + border-radius: 0; + overflow: visible; +} + +.passkey-management-container { + padding: 20px; + max-width: 600px; +} + +.card-header { + padding: 24px 24px 16px; + background: linear-gradient(135deg, var(--mdc-theme-primary, #1976d2) 0%, var(--mdc-theme-primary-variant, #1565c0) 100%); + color: var(--mdc-theme-on-primary, #ffffff); + display: flex; + align-items: center; + gap: 16px; +} + +.header-icon { + font-size: 32px; + width: 32px; + height: 32px; + opacity: 0.9; +} + +.card-title h1 { + margin: 0; + font-size: 28px; + font-weight: 500; + letter-spacing: -0.5px; +} + +.card-content { + padding: 24px; + background: var(--mdc-theme-surface); +} + +.loading-card, +.passkeys-list-card, +.registration-card, +.info-card { + margin-bottom: 16px; +} + +.registration-card mat-card-header { + margin-bottom: 16px; +} + +.registration-card mat-card-title { + display: flex; + align-items: center; + gap: 8px; +} + +.full-width { + width: 100%; +} + +.button-spinner { + margin-right: 8px; +} + +.info-card ul { + margin: 0; + padding-left: 20px; +} + +.info-card li { + margin-bottom: 8px; + color: var(--mat-sys-on-surface-variant); +} + +.info-card mat-card-title { + display: flex; + align-items: center; + gap: 8px; +} + +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + padding: 24px; + gap: 16px; +} + +.loading-container p { + margin: 0; + color: var(--mat-sys-on-surface-variant); +} + +.passkey-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 0; + border-bottom: 1px solid var(--mat-sys-outline-variant); +} + +.passkey-item:last-child { + border-bottom: none; +} + +.passkey-info { + flex: 1; +} + +.passkey-name { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} + +.passkey-name mat-icon { + color: var(--mat-sys-primary); +} + +.passkey-details { + font-size: 12px; + color: var(--mat-sys-on-surface-variant); +} + +.passkey-actions { + display: flex; + gap: 8px; +} + +.info-card mat-card-content ul { + color: var(--mat-sys-on-surface); + margin: 0; + padding-left: 20px; +} + +.info-card mat-card-content ul li { + color: var(--mat-sys-on-surface); + margin-bottom: 8px; + line-height: 1.5; +} + +.dialog-actions { + display: flex; + justify-content: flex-end; + padding-top: 16px; + border-top: 1px solid var(--mat-sys-outline-variant); +} diff --git a/openalprwebhookprocessor.client/src/app/settings/users/user-passkeys/user-passkeys.component.html b/openalprwebhookprocessor.client/src/app/settings/users/user-passkeys/user-passkeys.component.html new file mode 100644 index 00000000..fe83624e --- /dev/null +++ b/openalprwebhookprocessor.client/src/app/settings/users/user-passkeys/user-passkeys.component.html @@ -0,0 +1,129 @@ + + + fingerprint + +

Manage Passkeys for {{ userName }}

+
+
+ +

Passkeys provide secure, passwordless authentication using biometrics or device security.

+
+ +
+ + + @if (loading) { + + +
+ +

Loading passkeys...

+
+
+
+ } @else if (passkeys.length > 0) { + + + + security + Your Passkeys + + + Manage your registered passkeys + + + + + @for (passkey of passkeys; track passkey.id) { +
+
+
+ fingerprint + {{ passkey.name }} +
+
+ Registered: {{ passkey.regDate | date:'medium' }} +
+
+
+ +
+
+ } +
+
+ } + + + + + add + Register New Passkey + + + Add a new passkey to enable passwordless authentication + + + + +
+ + Passkey Name + + label + @if (registrationForm.get('name')?.errors?.['required']) { + Passkey name is required + } + @if (registrationForm.get('name')?.errors?.['maxlength']) { + Name must be 50 characters or less + } + +
+
+ + + + +
+ + + + + info + About Passkeys + + + + +
    +
  • Passkeys use your device's built-in security (Face ID, Touch ID, Windows Hello, etc.)
  • +
  • They're more secure than passwords and can't be phished
  • +
  • Once registered, passkeys can be used as an alternative to 2FA
  • +
  • Your passkey data never leaves your device
  • +
+
+
+ +
+ +
+
+
diff --git a/openalprwebhookprocessor.client/src/app/settings/users/user-passkeys/user-passkeys.component.ts b/openalprwebhookprocessor.client/src/app/settings/users/user-passkeys/user-passkeys.component.ts new file mode 100644 index 00000000..d4073179 --- /dev/null +++ b/openalprwebhookprocessor.client/src/app/settings/users/user-passkeys/user-passkeys.component.ts @@ -0,0 +1,242 @@ +import { Component, inject, type OnInit, ChangeDetectionStrategy } from '@angular/core'; +import { first } from 'rxjs/operators'; +import { AccountService, AlertService } from 'app/_services'; +import { OnPushBaseComponent } from 'app/_helpers/onpush-base.component'; +import { CommonModule } from '@angular/common'; +import { MatCardModule } from '@angular/material/card'; +import { MatButtonModule } from '@angular/material/button'; +import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; +import { MatExpansionModule } from '@angular/material/expansion'; +import { MatIconModule } from '@angular/material/icon'; +import { MatDialog, MatDialogRef, MAT_DIALOG_DATA } from '@angular/material/dialog'; +import { MatFormFieldModule } from '@angular/material/form-field'; +import { MatInputModule } from '@angular/material/input'; +import { MatTooltipModule } from '@angular/material/tooltip'; +import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; +import { ConfirmationDialogComponent, type ConfirmationDialogData } from '../../../shared/confirmation-dialog/confirmation-dialog.component'; +import { RefreshButtonComponent } from '../../../shared/refresh-button/refresh-button.component'; + +interface DialogData { + userId: string; + userName: string; +} + +interface PasskeyInfo { + id: number; + name: string; + regDate: string; + aaGuid: string; +} + +@Component({ + selector: 'app-user-passkeys', + templateUrl: 'user-passkeys.component.html', + styleUrl: 'user-passkeys.component.css', + changeDetection: ChangeDetectionStrategy.OnPush, + imports: [ + CommonModule, + ReactiveFormsModule, + MatCardModule, + MatButtonModule, + MatProgressSpinnerModule, + MatExpansionModule, + MatIconModule, + MatFormFieldModule, + MatInputModule, + MatTooltipModule, + RefreshButtonComponent, + ], +}) +export class UserPasskeysComponent extends OnPushBaseComponent implements OnInit { + data = inject(MAT_DIALOG_DATA); + + private readonly accountService = inject(AccountService); + private readonly alertService = inject(AlertService); + private readonly dialog = inject(MatDialog); + private readonly dialogRef = inject(MatDialogRef); + private readonly formBuilder = inject(FormBuilder); + + userId = ''; + userName = ''; + loading = false; + registering = false; + + passkeys: PasskeyInfo[] = []; + registrationForm: FormGroup; + + ngOnInit() { + this.userId = this.data.userId; + this.userName = this.data.userName; + + this.registrationForm = this.formBuilder.group({ + name: ['', [Validators.required, Validators.maxLength(50)]] + }); + + this.loadPasskeys(); + } + + loadPasskeys() { + this.loading = true; + this.markForCheck(); + + this.subscribeAndMarkForCheck( + this.accountService.getPasskeys().pipe(first()), + (response) => { + this.passkeys = response.passkeys || []; + this.loading = false; + }, + (error) => { + this.alertService.error(`Failed to load passkeys: ${error}`); + this.loading = false; + } + ); + } + + async registerPasskey() { + if (this.registrationForm.invalid) { + return; + } + + this.registering = true; + this.markForCheck(); + + try { + // Check if WebAuthn is supported + if (!window.navigator.credentials || !window.PublicKeyCredential) { + throw new Error('WebAuthn is not supported in this browser'); + } + + const passkeyName = this.registrationForm.get('name')?.value; + + // Step 1: Get registration options from server + const optionsResponse = await this.accountService.registerPasskey(passkeyName).pipe(first()).toPromise(); + + if (!optionsResponse?.options) { + throw new Error('Failed to get registration options'); + } + + // Step 2: Create credential using WebAuthn API + const credential = await navigator.credentials.create({ + publicKey: this.convertRegistrationOptions(optionsResponse.options) + }) as PublicKeyCredential; + + if (!credential) { + throw new Error('Failed to create credential'); + } + + // Step 3: Send credential to server for verification + const attestationResponse = this.encodeAttestationResponse(credential); + + const result = await this.accountService.completePasskeyRegistration( + attestationResponse, + passkeyName + ).pipe(first()).toPromise(); + + if (result?.success) { + this.alertService.success('Passkey registered successfully!'); + this.registrationForm.reset(); + this.loadPasskeys(); // Refresh the list + } else { + throw new Error(result?.message || 'Failed to register passkey'); + } + + } catch (error) { + console.error('Passkey registration error:', error); + this.alertService.error(`Failed to register passkey: ${error instanceof Error ? error.message : 'Unknown error'}`); + } finally { + this.registering = false; + this.markForCheck(); + } + } + + private convertRegistrationOptions(options: any): PublicKeyCredentialCreationOptions { + return { + ...options, + challenge: this.base64urlToBuffer(options.challenge), + user: { + ...options.user, + id: this.base64urlToBuffer(options.user.id) + }, + excludeCredentials: options.excludeCredentials?.map((cred: any) => ({ + ...cred, + id: this.base64urlToBuffer(cred.id) + })) + }; + } + + private encodeAttestationResponse(credential: PublicKeyCredential): string { + const response = credential.response as AuthenticatorAttestationResponse; + + return JSON.stringify({ + id: credential.id, + rawId: this.bufferToBase64url(credential.rawId), + type: credential.type, + response: { + attestationObject: this.bufferToBase64url(response.attestationObject), + clientDataJSON: this.bufferToBase64url(response.clientDataJSON) + } + }); + } + + private base64urlToBuffer(base64url: string): ArrayBuffer { + const base64 = base64url.replace(/-/g, '+').replace(/_/g, '/'); + const padded = base64.padEnd(base64.length + (4 - base64.length % 4) % 4, '='); + const binary = atob(padded); + const buffer = new ArrayBuffer(binary.length); + const bytes = new Uint8Array(buffer); + for (let i = 0; i < binary.length; i++) { + bytes[i] = binary.charCodeAt(i); + } + return buffer; + } + + private bufferToBase64url(buffer: ArrayBuffer): string { + const bytes = new Uint8Array(buffer); + let binary = ''; + for (let i = 0; i < bytes.byteLength; i++) { + binary += String.fromCharCode(bytes[i]); + } + return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, ''); + } + + deletePasskey(passkey: PasskeyInfo) { + const dialogData: ConfirmationDialogData = { + title: 'Delete Passkey', + message: `Are you sure you want to delete the passkey "${passkey.name}"? This action cannot be undone.`, + confirmText: 'Delete', + cancelText: 'Cancel', + color: 'warn', + }; + + const dialogRef = this.dialog.open(ConfirmationDialogComponent, { + width: '400px', + data: dialogData, + }); + + this.subscribeAndMarkForCheck( + dialogRef.afterClosed().pipe(first()), + (confirmed) => { + if (confirmed) { + this.subscribeAndMarkForCheck( + this.accountService.deletePasskey(passkey.id).pipe(first()), + (result) => { + if (result.success) { + this.alertService.success('Passkey deleted successfully'); + this.loadPasskeys(); // Refresh the list + } else { + this.alertService.error(result.message || 'Failed to delete passkey'); + } + }, + (error) => { + this.alertService.error(`Failed to delete passkey: ${error}`); + } + ); + } + } + ); + } + + onClose() { + this.dialogRef.close(); + } +} diff --git a/openalprwebhookprocessor.client/src/app/settings/users/users.component.html b/openalprwebhookprocessor.client/src/app/settings/users/users.component.html index b206a9cd..720baf04 100644 --- a/openalprwebhookprocessor.client/src/app/settings/users/users.component.html +++ b/openalprwebhookprocessor.client/src/app/settings/users/users.component.html @@ -52,6 +52,10 @@ security Manage 2FA + + + buttonType="raised" + color="primary" + buttonText="Sign In" + buttonRefreshingText="Signing In..." + [fullWidth]="true" + (refreshStarted)="onSubmit()" + class="login-button" + i18n-buttonText + i18n-buttonRefreshingText />
or
- + buttonType="raised" + color="accent" + buttonText="Sign in with Passkey" + buttonRefreshingText="Authenticating..." + icon="fingerprint" + [fullWidth]="true" + (refreshStarted)="authenticateWithPasskey()" + class="passkey-button" + i18n-buttonText + i18n-buttonRefreshingText /> @if (!f.username.value) {

Enter your username to use passkey authentication

diff --git a/openalprwebhookprocessor.client/src/app/account/login/login.component.spec.ts b/openalprwebhookprocessor.client/src/app/account/login/login.component.spec.ts index 55c3b440..d56f0be3 100644 --- a/openalprwebhookprocessor.client/src/app/account/login/login.component.spec.ts +++ b/openalprwebhookprocessor.client/src/app/account/login/login.component.spec.ts @@ -1,7 +1,6 @@ import { TestBed, type ComponentFixture } from '@angular/core/testing'; import { FormBuilder, ReactiveFormsModule } from '@angular/forms'; import { Router, ActivatedRoute } from '@angular/router'; -import { NoopAnimationsModule } from '@angular/platform-browser/animations'; import { MatCardModule } from '@angular/material/card'; import { MatFormFieldModule } from '@angular/material/form-field'; import { MatInputModule } from '@angular/material/input'; @@ -51,7 +50,6 @@ describe('LoginComponent', () => { imports: [ LoginComponent, ReactiveFormsModule, - NoopAnimationsModule, MatCardModule, MatFormFieldModule, MatInputModule, diff --git a/openalprwebhookprocessor.client/src/app/account/login/login.component.ts b/openalprwebhookprocessor.client/src/app/account/login/login.component.ts index d5951e6f..e30e6cc5 100644 --- a/openalprwebhookprocessor.client/src/app/account/login/login.component.ts +++ b/openalprwebhookprocessor.client/src/app/account/login/login.component.ts @@ -17,6 +17,7 @@ import { MatButtonModule } from '@angular/material/button'; import { MatIconModule } from '@angular/material/icon'; import { MatProgressSpinnerModule } from '@angular/material/progress-spinner'; import { MatCheckboxModule } from '@angular/material/checkbox'; +import { RefreshButtonComponent } from 'app/shared/refresh-button/refresh-button.component'; @Component({ selector: 'app-login', @@ -31,6 +32,7 @@ import { MatCheckboxModule } from '@angular/material/checkbox'; MatIconModule, MatProgressSpinnerModule, MatCheckboxModule, + RefreshButtonComponent, ], templateUrl: 'login.component.html', styleUrl: 'login.component.css', diff --git a/openalprwebhookprocessor.client/src/app/account/register/register.component.css b/openalprwebhookprocessor.client/src/app/account/register/register.component.css index 6f600f05..c70c8e31 100644 --- a/openalprwebhookprocessor.client/src/app/account/register/register.component.css +++ b/openalprwebhookprocessor.client/src/app/account/register/register.component.css @@ -131,11 +131,6 @@ align-items: center; } -.login-card-wrapper app-alert { - width: 100%; - margin-bottom: 16px; -} - .login-card { border-radius: 24px; overflow: hidden; diff --git a/openalprwebhookprocessor.client/src/app/account/register/register.component.html b/openalprwebhookprocessor.client/src/app/account/register/register.component.html index 8ab1b14f..511db498 100644 --- a/openalprwebhookprocessor.client/src/app/account/register/register.component.html +++ b/openalprwebhookprocessor.client/src/app/account/register/register.component.html @@ -12,7 +12,6 @@