From 81fb917d5553b29b848586047d2f51aace7ab844 Mon Sep 17 00:00:00 2001 From: David Date: Sat, 21 Jun 2025 18:47:02 +0400 Subject: [PATCH 1/4] added http user accessor, made everything into an encrypted queryable, made transactions possible, added global logging behavior and validator --- .gitignore | 3 +- src/Application/Application.csproj | 1 + .../Behaviours/RequestLoggingBehaviour.cs | 25 ++ .../Behaviours/RequestValidationBehaviour.cs | 58 ++++ src/Application/Services/IAppDbContext.cs | 3 + .../Services/ICurrentUserAccessor.cs | 15 + .../Users/Commands/RegisterCustomerCommand.cs | 66 ++-- src/Domain/Common/HttpContextAccessorExt.cs | 44 +++ .../Configurations/UserConfiguration.cs | 16 +- ...keEverythingEncryptedQueryable.Designer.cs | 286 ++++++++++++++++++ ...132925_MakeEverythingEncryptedQueryable.cs | 100 ++++++ .../Migrations/StoreDbContextModelSnapshot.cs | 62 +++- .../Persistence/StoreDbContext.cs | 3 + .../Services/HttpContextUserAccessor.cs | 30 ++ 14 files changed, 677 insertions(+), 35 deletions(-) create mode 100644 src/Application/Behaviours/RequestLoggingBehaviour.cs create mode 100644 src/Application/Behaviours/RequestValidationBehaviour.cs create mode 100644 src/Application/Services/ICurrentUserAccessor.cs create mode 100644 src/Domain/Common/HttpContextAccessorExt.cs create mode 100644 src/Infrastructure/Persistence/Migrations/20250621132925_MakeEverythingEncryptedQueryable.Designer.cs create mode 100644 src/Infrastructure/Persistence/Migrations/20250621132925_MakeEverythingEncryptedQueryable.cs create mode 100644 src/Infrastructure/Services/HttpContextUserAccessor.cs diff --git a/.gitignore b/.gitignore index 08a5320..6ced35a 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,5 @@ riderModule.iml .git .env dbo.db -db.db \ No newline at end of file +db.db +.aspdotnet \ No newline at end of file diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index 8e8dabf..f360e9d 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Application/Behaviours/RequestLoggingBehaviour.cs b/src/Application/Behaviours/RequestLoggingBehaviour.cs new file mode 100644 index 0000000..60a1e6d --- /dev/null +++ b/src/Application/Behaviours/RequestLoggingBehaviour.cs @@ -0,0 +1,25 @@ +using Application.Services; +using MediatR; +using Serilog; + +namespace Application.Behaviours; + +internal sealed class RequestLoggingBehaviour(ICurrentUserAccessor currentUser) : IPipelineBehavior + where TRequest : IRequest +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken ct) + { + var userId = currentUser.Id?.ToString() ?? "unauthenticated"; + Log.Information("{UserId} sent request {RequestName} {@Request}", userId, typeof(TRequest).Name, request); + + try + { + return await next(); + } + catch (Exception ex) + { + Log.Error(ex, "{UserId} failed to handle request {RequestName} {@Request}", userId, typeof(TRequest).Name, request); + throw; + } + } +} \ No newline at end of file diff --git a/src/Application/Behaviours/RequestValidationBehaviour.cs b/src/Application/Behaviours/RequestValidationBehaviour.cs new file mode 100644 index 0000000..e03f73b --- /dev/null +++ b/src/Application/Behaviours/RequestValidationBehaviour.cs @@ -0,0 +1,58 @@ +using System.ComponentModel.DataAnnotations; +using FluentValidation; +using FluentValidation.Results; +using MediatR; +using Serilog; +using ValidationException = FluentValidation.ValidationException; +using DataAnnotationsValidationResult = System.ComponentModel.DataAnnotations.ValidationResult; +using FluentValidationResult = FluentValidation.Results.ValidationResult; + +namespace Application.Behaviours; + +internal sealed class RequestValidationBehaviour(IServiceProvider serviceProvider, IEnumerable> validators) : IPipelineBehavior + where TRequest : IRequest +{ + public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken ct) + { + var context = new ValidationContext(request, null, null); + var results = new List(); + Validator.TryValidateObject(request, context, results, true); + + var tasks = validators.Select(v => + v.ValidateAsync(request, opt => opt.IncludeAllRuleSets(), ct)); + + var fluentValidationResults = await Task.WhenAll(tasks); + var dataAnnotationValidationResults = DataAnnotationValidate(request); + var validationResults = fluentValidationResults.Concat(dataAnnotationValidationResults).ToList(); + + if (validationResults.Any(x => !x.IsValid)) + { + var failures = validationResults + .SelectMany(r => r.Errors) + .Where(f => f is not null) + .ToList(); + + Log.Warning("Validation failed for request {RequestName} {Request} with errors {Errors}", request.GetType().Name, request, string.Join(';', failures)); + + throw new ValidationException(failures); + } + + return await next(ct); + } + + private IEnumerable DataAnnotationValidate(TRequest request) + { + var context = new ValidationContext(request, serviceProvider, null); + var results = new List(); + + Validator.TryValidateObject(request, context, results, true); + + var failures = + from result in results + let memberName = result.MemberNames.First() + let errorMessage = result.ErrorMessage + select new ValidationFailure(memberName, errorMessage); + + return failures.Select(failure => new FluentValidationResult([failure])); + } +} \ No newline at end of file diff --git a/src/Application/Services/IAppDbContext.cs b/src/Application/Services/IAppDbContext.cs index 38410ec..9e87757 100644 --- a/src/Application/Services/IAppDbContext.cs +++ b/src/Application/Services/IAppDbContext.cs @@ -1,4 +1,5 @@ using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; namespace Application.Services; @@ -7,4 +8,6 @@ public interface IAppDbContext public DbSet Set() where TEntity : class; public Task SaveChangesAsync(CancellationToken ct = default); + + public Task BeginTransactionAsync(CancellationToken ct = default); } \ No newline at end of file diff --git a/src/Application/Services/ICurrentUserAccessor.cs b/src/Application/Services/ICurrentUserAccessor.cs new file mode 100644 index 0000000..0cc331e --- /dev/null +++ b/src/Application/Services/ICurrentUserAccessor.cs @@ -0,0 +1,15 @@ +using Domain.Aggregates; +using Domain.ValueObjects; + +namespace Application.Services; + +public interface ICurrentUserAccessor +{ + public Ulid? Id { get; } + + public Role? Role { get; } + public Task TryGetCurrentUserAsync(CancellationToken ct = default); + + public async Task GetCurrentUserAsync(CancellationToken ct = default) => + await TryGetCurrentUserAsync(ct) ?? throw new InvalidOperationException("The user is not authenticated"); +} \ No newline at end of file diff --git a/src/Application/Users/Commands/RegisterCustomerCommand.cs b/src/Application/Users/Commands/RegisterCustomerCommand.cs index e04a457..b4129c6 100644 --- a/src/Application/Users/Commands/RegisterCustomerCommand.cs +++ b/src/Application/Users/Commands/RegisterCustomerCommand.cs @@ -2,8 +2,10 @@ using Destructurama.Attributed; using Domain.Aggregates; using Domain.ValueObjects; +using EntityFrameworkCore.DataProtection.Extensions; using FluentValidation; using MediatR; +using Microsoft.EntityFrameworkCore; using static BCrypt.Net.BCrypt; namespace Application.Users.Commands; @@ -11,36 +13,54 @@ namespace Application.Users.Commands; public sealed record RegisterCustomerCommand : IRequest { [LogMasked] - public string FullName { get; set; } + public string FullName { get; set; } = null!; [LogMasked] - public string Password { get; set; } + public string Password { get; set; } = null!; [LogMasked] - public string Email { get; set; } + public string Email { get; set; } = null!; [LogMasked] - public string ConfirmPassword { get; set; } + public string ConfirmPassword { get; set; } = null!; } public class RegisterCustomerCommandValidator : AbstractValidator { - public RegisterCustomerCommandValidator() + public RegisterCustomerCommandValidator(IAppDbContext dbContext) { RuleFor(x => x.FullName) .NotEmpty() .MinimumLength(5) - .MaximumLength(15); + .MaximumLength(15) + .Matches(@"^[a-zA-Z\s]+$").WithMessage("Full name must contain only letters and spaces."); + RuleFor(x => x.Password) .NotEmpty() .MinimumLength(6) .MaximumLength(50); + + RuleFor(x => x.ConfirmPassword) + .NotEmpty() + .Equal(x => x.Password).WithMessage("Passwords must match.") + .MinimumLength(6) + .MaximumLength(50); + RuleFor(x => x.Email) .NotEmpty() .EmailAddress() - .MaximumLength(50); + .MaximumLength(50) + .WithMessage("Email must be a valid email address and not exceed 50 characters."); + + RuleFor(x => x.Email) + .MustAsync(async (_, email, ct) => + { + var usersWithPd = await dbContext.Set().WherePdEquals(nameof(User.Email), email).CountAsync(ct); + return usersWithPd == 0; + }) + .WithMessage("Email already exists."); } } @@ -48,17 +68,27 @@ public sealed record RegisterCustomerCommandHandler(IAppDbContext DbContext) : I { public async Task Handle(RegisterCustomerCommand request, CancellationToken ct) { - var user = new User + var transaction = await DbContext.BeginTransactionAsync(ct); + try + { + var user = new User + { + Id = Ulid.NewUlid(), + FullName = request.FullName, + HashedPassword = EnhancedHashPassword(request.Password), + Role = Role.Customer, + RegisterDate = DateTime.Now, + Email = request.Email, + }; + await DbContext.Set().AddAsync(user, ct); + await DbContext.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + return user; + } + catch (Exception e) { - Id = Ulid.NewUlid(), - FullName = request.FullName, - HashedPassword = EnhancedHashPassword(request.Password), - Role = Role.Customer, - RegisterDate = DateTime.Now, - Email = request.Email, - }; - await DbContext.Set().AddAsync(user, ct); - await DbContext.SaveChangesAsync(ct); - return user; + await transaction.RollbackAsync(ct); + throw; + } } } \ No newline at end of file diff --git a/src/Domain/Common/HttpContextAccessorExt.cs b/src/Domain/Common/HttpContextAccessorExt.cs new file mode 100644 index 0000000..698c7c7 --- /dev/null +++ b/src/Domain/Common/HttpContextAccessorExt.cs @@ -0,0 +1,44 @@ +using System.Security.Claims; +using System.Security.Cryptography; +using System.Text.Encodings.Web; +using Domain.Aggregates; +using Domain.ValueObjects; + +namespace Domain.Common; + +public static class ClaimsPrincipalExt +{ + public const string IdClaimType = "sid"; + public const string UsernameClaimType = "name"; + public const string EmailClaimType = "email"; + public const string RoleClaimType = "role"; + + public static Ulid? GetId(this ClaimsPrincipal principal) => Ulid.TryParse( + principal.Claims.FirstOrDefault(c => c.Type == IdClaimType)?.Value, null, out var id) + ? id + : null; + + public static string? GetUsername(this ClaimsPrincipal principal) => principal.Claims.FirstOrDefault(c => c.Type == UsernameClaimType)?.Value; + public static string? GetEmail(this ClaimsPrincipal principal) => principal.Claims.FirstOrDefault(c => c.Type == EmailClaimType)?.Value; + + public static Role? GetRole(this ClaimsPrincipal principal) => + Enum.TryParse(principal.Claims.FirstOrDefault(c => c.Type == RoleClaimType)?.Value, out var role) + ? role + : null; + + public static IEnumerable GetAllClaims(this User user) => + [ + new(IdClaimType, user.Id.UlidToString()), + new(UsernameClaimType, user.FullName), + new(EmailClaimType, user.Email), + new(RoleClaimType, user.Role.ToString()), + ]; + + + public static string GetDefaultAvatar(string? username = null) + { + username ??= RandomNumberGenerator.GetHexString(5); + username = UrlEncoder.Default.Encode(username); + return $"https://api.dicebear.com/9.x/glass/svg?backgroundType=gradientLinear&scale=50&seed={username}"; + } +} \ No newline at end of file diff --git a/src/Infrastructure/Persistence/Configurations/UserConfiguration.cs b/src/Infrastructure/Persistence/Configurations/UserConfiguration.cs index 045ba3a..56c65e5 100644 --- a/src/Infrastructure/Persistence/Configurations/UserConfiguration.cs +++ b/src/Infrastructure/Persistence/Configurations/UserConfiguration.cs @@ -10,17 +10,17 @@ internal sealed class UserConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.Property(x => x.Email).IsEncryptedQueryable().HasMaxLength(40).IsRequired(); - builder.Property(x => x.FullName).IsEncrypted().HasMaxLength(15).IsRequired(); - builder.Property(x => x.HashedPassword).IsEncrypted().HasMaxLength(50).IsRequired(); + builder.Property(x => x.FullName).IsEncryptedQueryable().HasMaxLength(15).IsRequired(); + builder.Property(x => x.HashedPassword).IsEncryptedQueryable().HasMaxLength(50).IsRequired(); builder.Property(x => x.Role).IsRequired(); - builder.Property(x => x.RefreshToken).IsEncrypted().IsRequired(false); + builder.Property(x => x.RefreshToken).IsEncryptedQueryable().IsRequired(false); builder.OwnsOne(x => x.Address, address => { - address.Property(a => a.AddressLine1).IsEncrypted().HasMaxLength(100).IsRequired(); - address.Property(a => a.AddressLine2).IsEncrypted().HasMaxLength(100).IsRequired(false); - address.Property(a => a.City).IsEncrypted().HasMaxLength(15).IsRequired(); - address.Property(a => a.Country).IsEncrypted().HasMaxLength(10).IsRequired(); - address.Property(a => a.State).IsEncrypted().HasMaxLength(10).IsRequired(); + address.Property(a => a.AddressLine1).IsEncryptedQueryable().HasMaxLength(100).IsRequired(); + address.Property(a => a.AddressLine2).IsEncryptedQueryable().HasMaxLength(100).IsRequired(false); + address.Property(a => a.City).IsEncryptedQueryable().HasMaxLength(15).IsRequired(); + address.Property(a => a.Country).IsEncryptedQueryable().HasMaxLength(10).IsRequired(); + address.Property(a => a.State).IsEncryptedQueryable().HasMaxLength(10).IsRequired(); address.Property(a => a.ZipCode).IsRequired(); }); diff --git a/src/Infrastructure/Persistence/Migrations/20250621132925_MakeEverythingEncryptedQueryable.Designer.cs b/src/Infrastructure/Persistence/Migrations/20250621132925_MakeEverythingEncryptedQueryable.Designer.cs new file mode 100644 index 0000000..e1b19db --- /dev/null +++ b/src/Infrastructure/Persistence/Migrations/20250621132925_MakeEverythingEncryptedQueryable.Designer.cs @@ -0,0 +1,286 @@ +// +using System; +using Infrastructure.Persistence; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(StoreDbContext))] + [Migration("20250621132925_MakeEverythingEncryptedQueryable")] + partial class MakeEverythingEncryptedQueryable + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("Domain.Aggregates.User", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(40) + .HasColumnType("TEXT") + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b.Property("EmailShadowHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("FullName") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("TEXT") + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b.Property("FullNameShadowHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("HashedPassword") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT") + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b.Property("HashedPasswordShadowHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProfilePicture") + .HasColumnType("BLOB"); + + b.Property("RefreshToken") + .HasColumnType("TEXT") + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b.Property("RefreshTokenShadowHash") + .HasColumnType("TEXT"); + + b.Property("RegisterDate") + .HasColumnType("TEXT"); + + b.Property("Role") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("users"); + }); + + modelBuilder.Entity("Domain.Entities.Order", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("DateOrderFinished") + .HasColumnType("TEXT"); + + b.Property("DateOrdered") + .HasColumnType("TEXT"); + + b.Property("Total") + .HasColumnType("TEXT"); + + b.Property("UserId") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("orders"); + }); + + modelBuilder.Entity("Domain.Entities.OrderProduct", b => + { + b.Property("OrderId") + .HasColumnType("TEXT"); + + b.Property("ProductId") + .HasColumnType("TEXT"); + + b.Property("Id") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("OrderId", "ProductId"); + + b.HasIndex("ProductId"); + + b.ToTable("order_product"); + }); + + modelBuilder.Entity("Domain.Entities.Product", b => + { + b.Property("Id") + .HasColumnType("TEXT"); + + b.Property("Category") + .HasColumnType("INTEGER"); + + b.Property("DiscountAmountPercent") + .HasColumnType("INTEGER"); + + b.Property("PreviewImage") + .HasColumnType("BLOB"); + + b.Property("Price") + .HasColumnType("TEXT"); + + b.Property("ProductDescription") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("ProductName") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.ToTable("products"); + }); + + modelBuilder.Entity("Domain.Aggregates.User", b => + { + b.OwnsOne("Domain.ValueObjects.Address", "Address", b1 => + { + b1.Property("UserId") + .HasColumnType("TEXT"); + + b1.Property("AddressLine1") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b1.Property("AddressLine1ShadowHash") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("AddressLine2") + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b1.Property("AddressLine2ShadowHash") + .HasColumnType("TEXT"); + + b1.Property("City") + .IsRequired() + .HasMaxLength(15) + .HasColumnType("TEXT") + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b1.Property("CityShadowHash") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("Country") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT") + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b1.Property("CountryShadowHash") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("State") + .IsRequired() + .HasMaxLength(10) + .HasColumnType("TEXT") + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b1.Property("StateShadowHash") + .IsRequired() + .HasColumnType("TEXT"); + + b1.Property("ZipCode") + .IsRequired() + .HasColumnType("TEXT"); + + b1.HasKey("UserId"); + + b1.ToTable("users"); + + b1.WithOwner() + .HasForeignKey("UserId"); + }); + + b.Navigation("Address"); + }); + + modelBuilder.Entity("Domain.Entities.Order", b => + { + b.HasOne("Domain.Aggregates.User", "User") + .WithMany("Orders") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.OrderProduct", b => + { + b.HasOne("Domain.Entities.Order", "Order") + .WithMany("OrderProducts") + .HasForeignKey("OrderId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Product", "Product") + .WithMany() + .HasForeignKey("ProductId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Order"); + + b.Navigation("Product"); + }); + + modelBuilder.Entity("Domain.Aggregates.User", b => + { + b.Navigation("Orders"); + }); + + modelBuilder.Entity("Domain.Entities.Order", b => + { + b.Navigation("OrderProducts"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Persistence/Migrations/20250621132925_MakeEverythingEncryptedQueryable.cs b/src/Infrastructure/Persistence/Migrations/20250621132925_MakeEverythingEncryptedQueryable.cs new file mode 100644 index 0000000..8d79d2b --- /dev/null +++ b/src/Infrastructure/Persistence/Migrations/20250621132925_MakeEverythingEncryptedQueryable.cs @@ -0,0 +1,100 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Persistence.Migrations +{ + /// + public partial class MakeEverythingEncryptedQueryable : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "Address_AddressLine1ShadowHash", + table: "users", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Address_AddressLine2ShadowHash", + table: "users", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Address_CityShadowHash", + table: "users", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Address_CountryShadowHash", + table: "users", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "Address_StateShadowHash", + table: "users", + type: "TEXT", + nullable: true); + + migrationBuilder.AddColumn( + name: "FullNameShadowHash", + table: "users", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "HashedPasswordShadowHash", + table: "users", + type: "TEXT", + nullable: false, + defaultValue: ""); + + migrationBuilder.AddColumn( + name: "RefreshTokenShadowHash", + table: "users", + type: "TEXT", + nullable: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Address_AddressLine1ShadowHash", + table: "users"); + + migrationBuilder.DropColumn( + name: "Address_AddressLine2ShadowHash", + table: "users"); + + migrationBuilder.DropColumn( + name: "Address_CityShadowHash", + table: "users"); + + migrationBuilder.DropColumn( + name: "Address_CountryShadowHash", + table: "users"); + + migrationBuilder.DropColumn( + name: "Address_StateShadowHash", + table: "users"); + + migrationBuilder.DropColumn( + name: "FullNameShadowHash", + table: "users"); + + migrationBuilder.DropColumn( + name: "HashedPasswordShadowHash", + table: "users"); + + migrationBuilder.DropColumn( + name: "RefreshTokenShadowHash", + table: "users"); + } + } +} diff --git a/src/Infrastructure/Persistence/Migrations/StoreDbContextModelSnapshot.cs b/src/Infrastructure/Persistence/Migrations/StoreDbContextModelSnapshot.cs index 2a55984..801c96e 100644 --- a/src/Infrastructure/Persistence/Migrations/StoreDbContextModelSnapshot.cs +++ b/src/Infrastructure/Persistence/Migrations/StoreDbContextModelSnapshot.cs @@ -38,20 +38,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasMaxLength(15) .HasColumnType("TEXT") - .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true); + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b.Property("FullNameShadowHash") + .IsRequired() + .HasColumnType("TEXT"); b.Property("HashedPassword") .IsRequired() .HasMaxLength(50) .HasColumnType("TEXT") - .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true); + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b.Property("HashedPasswordShadowHash") + .IsRequired() + .HasColumnType("TEXT"); b.Property("ProfilePicture") .HasColumnType("BLOB"); b.Property("RefreshToken") .HasColumnType("TEXT") - .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true); + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b.Property("RefreshTokenShadowHash") + .HasColumnType("TEXT"); b.Property("RegisterDate") .HasColumnType("TEXT"); @@ -152,30 +169,59 @@ protected override void BuildModel(ModelBuilder modelBuilder) .IsRequired() .HasMaxLength(100) .HasColumnType("TEXT") - .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true); + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b1.Property("AddressLine1ShadowHash") + .IsRequired() + .HasColumnType("TEXT"); b1.Property("AddressLine2") .HasMaxLength(100) .HasColumnType("TEXT") - .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true); + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b1.Property("AddressLine2ShadowHash") + .HasColumnType("TEXT"); b1.Property("City") .IsRequired() .HasMaxLength(15) .HasColumnType("TEXT") - .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true); + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b1.Property("CityShadowHash") + .IsRequired() + .HasColumnType("TEXT"); b1.Property("Country") .IsRequired() .HasMaxLength(10) .HasColumnType("TEXT") - .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true); + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b1.Property("CountryShadowHash") + .IsRequired() + .HasColumnType("TEXT"); b1.Property("State") .IsRequired() .HasMaxLength(10) .HasColumnType("TEXT") - .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true); + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsEncrypted", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsQueryable", true) + .HasAnnotation("Klean.EntityFrameworkCore.DataProtection.IsUniqueIndex", true); + + b1.Property("StateShadowHash") + .IsRequired() + .HasColumnType("TEXT"); b1.Property("ZipCode") .IsRequired() diff --git a/src/Infrastructure/Persistence/StoreDbContext.cs b/src/Infrastructure/Persistence/StoreDbContext.cs index b7ffca3..b6b328d 100644 --- a/src/Infrastructure/Persistence/StoreDbContext.cs +++ b/src/Infrastructure/Persistence/StoreDbContext.cs @@ -8,6 +8,7 @@ using Infrastructure.ValueConverters; using Microsoft.AspNetCore.DataProtection; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Storage; namespace Infrastructure.Persistence; @@ -19,6 +20,8 @@ public class StoreDbContext( public DbSet Users => Set(); public DbSet Orders => Set(); + public Task BeginTransactionAsync(CancellationToken ct = default) => Database.BeginTransactionAsync(ct); + protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.ApplyConfiguration(new UserConfiguration()); diff --git a/src/Infrastructure/Services/HttpContextUserAccessor.cs b/src/Infrastructure/Services/HttpContextUserAccessor.cs new file mode 100644 index 0000000..195ecf1 --- /dev/null +++ b/src/Infrastructure/Services/HttpContextUserAccessor.cs @@ -0,0 +1,30 @@ +using System.Security.Claims; +using Application.Services; +using Domain.Aggregates; +using Domain.Common; +using Domain.ValueObjects; +using Microsoft.AspNetCore.Http; +using Microsoft.EntityFrameworkCore; + +namespace Infrastructure.Services; + +public sealed class HttpContextCurrentUserAccessor(IHttpContextAccessor httpContextAccessor, IAppDbContext dbContext) : ICurrentUserAccessor +{ + private ClaimsPrincipal? User => httpContextAccessor.HttpContext?.User; + + public Ulid? Id => User?.GetId(); + + public Role? Role => User?.GetRole(); + + public async Task TryGetCurrentUserAsync(CancellationToken ct = default) + { + if (Id is not { } id) + return null; + + var user = await dbContext.Set() + .Where(u => u.Id == id) + .FirstOrDefaultAsync(ct); + + return user ?? throw new InvalidOperationException($"Failed to load the user from the database, user with id: {id} not found"); + } +} \ No newline at end of file From 225ec2842f0f2712ae697518bc12c936f29dcb65 Mon Sep 17 00:00:00 2001 From: David Date: Tue, 24 Jun 2025 16:22:26 +0400 Subject: [PATCH 2/4] added cookiservice, AppComponentBase.cs and templates to configure application and infrasturcture from assemblies. --- src/Application/ConfigureApplicaton.cs | 5 ++ src/Application/Services/ICookieService.cs | 10 +++ .../Users/Commands/RegisterCustomerCommand.cs | 33 ++++------ src/Client/Components/Pages/LogIn.razor | 44 ++++--------- src/Client/Components/Pages/Register.razor | 28 +++----- .../Components/shared/AppComponentBase.cs | 65 +++++++++++++++++++ src/Infrastructure/ConfigureInfrastructure.cs | 5 ++ src/Infrastructure/Infrastructure.cs | 5 ++ src/Infrastructure/Services/CookieService.cs | 15 +++++ 9 files changed, 141 insertions(+), 69 deletions(-) create mode 100644 src/Application/ConfigureApplicaton.cs create mode 100644 src/Application/Services/ICookieService.cs create mode 100644 src/Client/Components/shared/AppComponentBase.cs create mode 100644 src/Infrastructure/ConfigureInfrastructure.cs create mode 100644 src/Infrastructure/Infrastructure.cs create mode 100644 src/Infrastructure/Services/CookieService.cs diff --git a/src/Application/ConfigureApplicaton.cs b/src/Application/ConfigureApplicaton.cs new file mode 100644 index 0000000..520567b --- /dev/null +++ b/src/Application/ConfigureApplicaton.cs @@ -0,0 +1,5 @@ +namespace Application; + +internal class ConfigureApplicaton +{ +} \ No newline at end of file diff --git a/src/Application/Services/ICookieService.cs b/src/Application/Services/ICookieService.cs new file mode 100644 index 0000000..44d97c3 --- /dev/null +++ b/src/Application/Services/ICookieService.cs @@ -0,0 +1,10 @@ +namespace Application.Services; + +public interface ICookieService +{ + public Task GetCookieAsync(string key, CancellationToken ct = default); + + public Task SetCookieAsync(string key, string value, CancellationToken ct = default); + + public Task DeleteCookieAsync(string key, CancellationToken ct = default); +} \ No newline at end of file diff --git a/src/Application/Users/Commands/RegisterCustomerCommand.cs b/src/Application/Users/Commands/RegisterCustomerCommand.cs index b4129c6..9c63459 100644 --- a/src/Application/Users/Commands/RegisterCustomerCommand.cs +++ b/src/Application/Users/Commands/RegisterCustomerCommand.cs @@ -69,26 +69,19 @@ public sealed record RegisterCustomerCommandHandler(IAppDbContext DbContext) : I public async Task Handle(RegisterCustomerCommand request, CancellationToken ct) { var transaction = await DbContext.BeginTransactionAsync(ct); - try - { - var user = new User - { - Id = Ulid.NewUlid(), - FullName = request.FullName, - HashedPassword = EnhancedHashPassword(request.Password), - Role = Role.Customer, - RegisterDate = DateTime.Now, - Email = request.Email, - }; - await DbContext.Set().AddAsync(user, ct); - await DbContext.SaveChangesAsync(ct); - await transaction.CommitAsync(ct); - return user; - } - catch (Exception e) + + var user = new User { - await transaction.RollbackAsync(ct); - throw; - } + Id = Ulid.NewUlid(), + FullName = request.FullName, + HashedPassword = EnhancedHashPassword(request.Password), + Role = Role.Customer, + RegisterDate = DateTime.Now, + Email = request.Email, + }; + await DbContext.Set().AddAsync(user, ct); + await DbContext.SaveChangesAsync(ct); + await transaction.CommitAsync(ct); + return user; } } \ No newline at end of file diff --git a/src/Client/Components/Pages/LogIn.razor b/src/Client/Components/Pages/LogIn.razor index fe77a44..e2528b0 100644 --- a/src/Client/Components/Pages/LogIn.razor +++ b/src/Client/Components/Pages/LogIn.razor @@ -1,11 +1,7 @@ @page "/LogIn" @using Application.Auth -@using Application.Services @using Domain.ValueObjects -@inject IToastService Toast -@inject IMediator Mediator -@inject NavigationManager Nav -@inject IJwtGenerator JwtGenerator +@inherits Client.Components.shared.AppComponentBase
@@ -36,7 +32,7 @@ Submit Don't have an account? Register instead + @onclick="@(() => NavigationManager.NavigateTo("/Register"))" class="text-primary hover:cursor-pointer">Register instead
@@ -46,32 +42,18 @@ private async Task OnValidSubmit() { - var validator = new LoginCommandValidator(); - var validationResult = await validator.ValidateAsync(Command); - if (!validationResult.IsValid) + var user = await SendCommandAsync(Command); + switch (user) { - foreach (var error in validationResult.Errors) - { - Toast.ShowError(error.ErrorMessage); - } - - return; - } - - var user = await Mediator.Send(Command); - - if (user is not null) - { - var token = JwtGenerator.GenerateToken(user); - user.RefreshToken = RefreshToken.CreateNew(); - Console.WriteLine(token); - // var token = JwtGenerator.GenerateToken(user.GetClaims(), TimeSpan.FromDays(1), DateTimeProvider); - // await Cookies.SetAsync("authorization", token); - // Nav.NavigateTo("/", true); - } - else - { - Toast.ShowError("Wrong email or password"); + case null: + Toast.ShowError("Wrong email or password"); + return; + default: + var token = JwtGenerator.GenerateToken(user); + user.RefreshToken = RefreshToken.CreateNew(); + await CookieService.SetCookieAsync("authorization", token); + NavigationManager.NavigateTo("/", true); + break; } } } diff --git a/src/Client/Components/Pages/Register.razor b/src/Client/Components/Pages/Register.razor index 81b904b..866db2a 100644 --- a/src/Client/Components/Pages/Register.razor +++ b/src/Client/Components/Pages/Register.razor @@ -1,8 +1,7 @@ @page "/Register" -@using Application.Users.Commands -@inject IToastService Toast -@inject IMediator Mediator @inject NavigationManager Nav +@using Application.Users.Commands +@inherits Client.Components.shared.AppComponentBase
@@ -42,22 +41,15 @@ private async Task OnValidSubmit() { - var validator = new RegisterCustomerCommandValidator(); - var validationResult = await validator.ValidateAsync(Command); - if (!validationResult.IsValid) + var validationResult = await SendCommandAsync(Command); + switch (validationResult) { - foreach (var error in validationResult.Errors) - { - Toast.ShowError(error.ErrorMessage); - } - - return; + case null: + ShowError("An error occurred while processing your request."); + return; + default: + NavigationManager.NavigateTo("/Login"); + break; } - - - await Mediator.Send(Command); - - Toast.ShowSuccess("User registered successfully"); - Nav.NavigateTo("/Login"); } } diff --git a/src/Client/Components/shared/AppComponentBase.cs b/src/Client/Components/shared/AppComponentBase.cs new file mode 100644 index 0000000..8cf8479 --- /dev/null +++ b/src/Client/Components/shared/AppComponentBase.cs @@ -0,0 +1,65 @@ +using Application.Services; +using Blazored.Toast.Configuration; +using Blazored.Toast.Services; +using MediatR; +using Microsoft.AspNetCore.Components; + +namespace Client.Components.shared; + +public abstract class AppComponentBase : ComponentBase, IDisposable +{ + private CancellationTokenSource? _cancellationTokenSource; + + [Inject] + protected ICurrentUserAccessor CurrentUserAccessor { get; set; } = null!; + + [Inject] + protected IJwtGenerator JwtGenerator { get; set; } = null!; + + [Inject] + protected ICookieService CookieService { get; set; } = null!; + + [Inject] + protected IMediator Mediator { get; set; } = null!; + + [Inject] + protected NavigationManager NavigationManager { get; set; } = null!; + + [Inject] + protected IToastService Toast { get; set; } = null!; + + private CancellationToken CancellationToken => (_cancellationTokenSource ??= new CancellationTokenSource()).Token; + + protected bool IsLoading { get; set; } + + /// + void IDisposable.Dispose() + { + GC.SuppressFinalize(this); + + if (_cancellationTokenSource is null) + return; + + _cancellationTokenSource.Cancel(); + _cancellationTokenSource.Dispose(); + _cancellationTokenSource = null; + } + + protected async Task SendCommandAsync(IRequest request) + { + try + { + IsLoading = true; + return await Mediator.Send(request, CancellationToken); + } + finally + { + IsLoading = false; + } + } + + protected void ShowSuccess(string message, Action? settings = null) => Toast.ShowSuccess(message, settings); + protected void ShowInfo(string message, Action? settings = null) => Toast.ShowInfo(message, settings); + protected void ShowWarning(string message, Action? settings = null) => Toast.ShowWarning(message, settings); + protected void ShowError(string message, Action? settings = null) => Toast.ShowError(message, settings); +} \ No newline at end of file diff --git a/src/Infrastructure/ConfigureInfrastructure.cs b/src/Infrastructure/ConfigureInfrastructure.cs new file mode 100644 index 0000000..e3ade5b --- /dev/null +++ b/src/Infrastructure/ConfigureInfrastructure.cs @@ -0,0 +1,5 @@ +namespace Infrastructure; + +public class ConfigureInfrastructure +{ +} \ No newline at end of file diff --git a/src/Infrastructure/Infrastructure.cs b/src/Infrastructure/Infrastructure.cs new file mode 100644 index 0000000..4abd3ac --- /dev/null +++ b/src/Infrastructure/Infrastructure.cs @@ -0,0 +1,5 @@ +namespace Infrastructure; + +public class Infrastructure +{ +} \ No newline at end of file diff --git a/src/Infrastructure/Services/CookieService.cs b/src/Infrastructure/Services/CookieService.cs new file mode 100644 index 0000000..b59c9db --- /dev/null +++ b/src/Infrastructure/Services/CookieService.cs @@ -0,0 +1,15 @@ +using Microsoft.JSInterop; + +namespace Infrastructure.Services; + +public sealed class CookieService(IJSRuntime js) +{ + public async Task GetCookieAsync(string key, CancellationToken ct = default) => + await js.InvokeAsync("window.getCookie", ct, key); + + public async Task SetCookieAsync(string key, string value, CancellationToken ct = default) => + await js.InvokeVoidAsync("window.setCookie", ct, key, value); + + public async Task DeleteCookieAsync(string key, CancellationToken ct = default) => + await js.InvokeVoidAsync("window.delCookie", ct, key); +} \ No newline at end of file From 6f985a1f4a14504351fbe8f608a1c20bf7e333f0 Mon Sep 17 00:00:00 2001 From: David Date: Wed, 25 Jun 2025 16:56:35 +0400 Subject: [PATCH 3/4] removed configuration code from Program.cs, and moved them to their respective assemblies. added global logging pipline behaviour and global validation behaviour --- src/Application/Application.csproj | 1 + src/Application/ConfigurationBase.cs | 38 ++++++++++++++ src/Application/ConfigureApplicaton.cs | 20 +++++++- .../Users/Commands/RegisterCustomerCommand.cs | 17 ++++--- src/Client/Components/Pages/LogIn.razor | 3 ++ src/Client/Components/Pages/Register.razor | 1 + .../Components/shared/AppComponentBase.cs | 6 --- src/Client/ConfigureClient.cs | 12 +++++ src/Client/Program.cs | 51 +++---------------- src/Infrastructure/ConfigureInfrastructure.cs | 44 +++++++++++++++- src/Infrastructure/Infrastructure.cs | 7 ++- src/Infrastructure/Infrastructure.csproj | 4 ++ src/Infrastructure/Services/CookieService.cs | 5 +- 13 files changed, 144 insertions(+), 65 deletions(-) create mode 100644 src/Application/ConfigurationBase.cs create mode 100644 src/Client/ConfigureClient.cs diff --git a/src/Application/Application.csproj b/src/Application/Application.csproj index f360e9d..fd4b1d4 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -14,6 +14,7 @@ + diff --git a/src/Application/ConfigurationBase.cs b/src/Application/ConfigurationBase.cs new file mode 100644 index 0000000..a0c4ff2 --- /dev/null +++ b/src/Application/ConfigurationBase.cs @@ -0,0 +1,38 @@ +using System.Reflection; +using Domain.Common; +using Microsoft.Extensions.DependencyInjection; + +namespace Application; + +public abstract class ConfigurationBase +{ + protected static bool IsDevelopment => "ASPNETCORE_ENVIRONMENT".GetFromEnvRequired() == "Development"; + public abstract void ConfigureServices(IServiceCollection services); + + /// + /// Configures the configurations from all the assembly names. + /// + public static void ConfigureServicesFromAssemblies(IServiceCollection services, IEnumerable assemblies) + { + ConfigureServicesFromAssemblies(services, assemblies.Select(Assembly.Load)); + } + + /// + /// Configures the configurations from all the assemblies and configuration types. + /// + private static void ConfigureServicesFromAssemblies(IServiceCollection services, IEnumerable assemblies) + { + assemblies + .SelectMany(assembly => assembly.GetTypes()) + .Where(type => typeof(ConfigurationBase).IsAssignableFrom(type)) + .Where(type => type is { IsInterface: false, IsAbstract: false }) + .Select(type => (ConfigurationBase)Activator.CreateInstance(type)!) + .ToList() + .ForEach(hostingStartup => + { + var name = hostingStartup.GetType().Name.Replace("Configure", ""); + Console.WriteLine($"[{DateTime.Now:hh:mm:ss} INF] ? Configuring {name}"); + hostingStartup.ConfigureServices(services); + }); + } +} \ No newline at end of file diff --git a/src/Application/ConfigureApplicaton.cs b/src/Application/ConfigureApplicaton.cs index 520567b..f06d7bd 100644 --- a/src/Application/ConfigureApplicaton.cs +++ b/src/Application/ConfigureApplicaton.cs @@ -1,5 +1,21 @@ -namespace Application; +using Application.Behaviours; +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; -internal class ConfigureApplicaton +namespace Application; + +public sealed class ConfigureApplicaton : ConfigurationBase { + public override void ConfigureServices(IServiceCollection services) + { + services.AddValidatorsFromAssembly(Application.Assembly); + + services.AddMediatR(cfg => + { + cfg.RegisterServicesFromAssembly(Application.Assembly); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(RequestLoggingBehaviour<,>)); + cfg.AddBehavior(typeof(IPipelineBehavior<,>), typeof(RequestValidationBehaviour<,>)); + }); + } } \ No newline at end of file diff --git a/src/Application/Users/Commands/RegisterCustomerCommand.cs b/src/Application/Users/Commands/RegisterCustomerCommand.cs index 9c63459..31b0ea9 100644 --- a/src/Application/Users/Commands/RegisterCustomerCommand.cs +++ b/src/Application/Users/Commands/RegisterCustomerCommand.cs @@ -54,13 +54,16 @@ public RegisterCustomerCommandValidator(IAppDbContext dbContext) .MaximumLength(50) .WithMessage("Email must be a valid email address and not exceed 50 characters."); - RuleFor(x => x.Email) - .MustAsync(async (_, email, ct) => - { - var usersWithPd = await dbContext.Set().WherePdEquals(nameof(User.Email), email).CountAsync(ct); - return usersWithPd == 0; - }) - .WithMessage("Email already exists."); + RuleSet("async", + () => + RuleFor(x => x.Email) + .NotEmpty() + .MustAsync(async (_, email, ct) => + { + var usersWithPd = await dbContext.Set().WherePdEquals(nameof(User.Email), email).CountAsync(ct); + return usersWithPd == 0; + }) + .WithMessage("Email already exists.")); } } diff --git a/src/Client/Components/Pages/LogIn.razor b/src/Client/Components/Pages/LogIn.razor index e2528b0..43b976c 100644 --- a/src/Client/Components/Pages/LogIn.razor +++ b/src/Client/Components/Pages/LogIn.razor @@ -1,7 +1,10 @@ @page "/LogIn" @using Application.Auth +@using Application.Services @using Domain.ValueObjects @inherits Client.Components.shared.AppComponentBase +@inject IJwtGenerator JwtGenerator +@inject ICookieService CookieService
diff --git a/src/Client/Components/Pages/Register.razor b/src/Client/Components/Pages/Register.razor index 866db2a..3aa96fa 100644 --- a/src/Client/Components/Pages/Register.razor +++ b/src/Client/Components/Pages/Register.razor @@ -3,6 +3,7 @@ @using Application.Users.Commands @inherits Client.Components.shared.AppComponentBase +
diff --git a/src/Client/Components/shared/AppComponentBase.cs b/src/Client/Components/shared/AppComponentBase.cs index 8cf8479..aace7d3 100644 --- a/src/Client/Components/shared/AppComponentBase.cs +++ b/src/Client/Components/shared/AppComponentBase.cs @@ -13,12 +13,6 @@ public abstract class AppComponentBase : ComponentBase, IDisposable [Inject] protected ICurrentUserAccessor CurrentUserAccessor { get; set; } = null!; - [Inject] - protected IJwtGenerator JwtGenerator { get; set; } = null!; - - [Inject] - protected ICookieService CookieService { get; set; } = null!; - [Inject] protected IMediator Mediator { get; set; } = null!; diff --git a/src/Client/ConfigureClient.cs b/src/Client/ConfigureClient.cs new file mode 100644 index 0000000..12a86b2 --- /dev/null +++ b/src/Client/ConfigureClient.cs @@ -0,0 +1,12 @@ +using Application; +using Blazored.Toast; + +namespace Client; + +public class ConfigureClient : ConfigurationBase +{ + public override void ConfigureServices(IServiceCollection services) + { + services.AddBlazoredToast(); + } +} \ No newline at end of file diff --git a/src/Client/Program.cs b/src/Client/Program.cs index c22c258..5c5307d 100644 --- a/src/Client/Program.cs +++ b/src/Client/Program.cs @@ -1,54 +1,17 @@ -using Application.Services; -using Blazored.Toast; +using Application; using Client.Components; -using Domain.Common; using dotenv.net; -using EntityFrameworkCore.DataProtection.Extensions; -using Infrastructure.Persistence; -using Infrastructure.Services; -using Microsoft.AspNetCore.Authentication.JwtBearer; -using Microsoft.AspNetCore.DataProtection; -using Microsoft.EntityFrameworkCore; -var builder = WebApplication.CreateBuilder(args); -//get environment variables DotEnv.Fluent() .WithTrimValues() .WithOverwriteExistingVars().WithProbeForEnv(6) .Load(); -builder.Services.AddBlazoredToast(); -builder.Services.AddDataProtectionServices("StoreProject") - .PersistKeysToFileSystem(new DirectoryInfo - ("DATAPROTECTION__KEYS__PATH".GetFromEnvRequired())); - -builder.Services - .AddDbContext(o => - { - o.AddDataProtectionInterceptors(); - var dbPath = "DB__PATH".GetFromEnvRequired(); - o.UseSqlite($"DATA SOURCE = {dbPath}"); - }); - - -builder.Services.AddMediatR(cfg => cfg.RegisterServicesFromAssembly(Application.Application.Assembly)); -builder.Services.AddScoped(); -builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(x => -{ - x.TokenValidationParameters = JwtGenerator.TokenValidationParameters; - x.Events = JwtGenerator.Events; -}); -builder.Services.AddAuthorization(); -builder.Services.AddCors(opt => opt.AddDefaultPolicy(cors => -{ - cors.AllowAnyMethod(); - cors.AllowAnyOrigin(); - cors.AllowAnyHeader(); -})); -// Add services to the container. -builder.Services.AddRazorComponents() - .AddInteractiveServerComponents(); +var builder = WebApplication.CreateBuilder(args); +ConfigurationBase.ConfigureServicesFromAssemblies(builder.Services, [ + nameof(Domain), nameof(Application), nameof(Infrastructure), nameof(Client), +]); var app = builder.Build(); @@ -61,8 +24,8 @@ app.UseHsts(); } -app.UseHttpsRedirection(); -app.UseCors(); +// app.UseHttpsRedirection(); +// app.UseCors(); app.UseAuthentication(); app.UseAuthorization(); diff --git a/src/Infrastructure/ConfigureInfrastructure.cs b/src/Infrastructure/ConfigureInfrastructure.cs index e3ade5b..f9f342e 100644 --- a/src/Infrastructure/ConfigureInfrastructure.cs +++ b/src/Infrastructure/ConfigureInfrastructure.cs @@ -1,5 +1,45 @@ -namespace Infrastructure; +using Application; +using Application.Services; +using Domain.Common; +using EntityFrameworkCore.DataProtection.Extensions; +using Infrastructure.Persistence; +using Infrastructure.Services; +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.AspNetCore.DataProtection; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; -public class ConfigureInfrastructure +namespace Infrastructure; + +public sealed class ConfigureInfrastructure : ConfigurationBase { + public override void ConfigureServices(IServiceCollection services) + { + services.AddScoped(); + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme).AddJwtBearer(x => + { + x.TokenValidationParameters = JwtGenerator.TokenValidationParameters; + x.Events = JwtGenerator.Events; + }); + services.AddAuthorization(); + services.AddHttpContextAccessor(); + + + services.AddDataProtectionServices("StoreProject") + .PersistKeysToFileSystem(new DirectoryInfo + ("DATAPROTECTION__KEYS__PATH".GetFromEnvRequired())); + + services + .AddDbContext(o => + { + o.AddDataProtectionInterceptors(); + var dbPath = "DB__PATH".GetFromEnvRequired(); + o.UseSqlite($"DATA SOURCE = {dbPath}"); + }); + + services.AddScoped(); + services.AddScoped(); + services.AddRazorComponents() + .AddInteractiveServerComponents(); + } } \ No newline at end of file diff --git a/src/Infrastructure/Infrastructure.cs b/src/Infrastructure/Infrastructure.cs index 4abd3ac..35bbdc4 100644 --- a/src/Infrastructure/Infrastructure.cs +++ b/src/Infrastructure/Infrastructure.cs @@ -1,5 +1,8 @@ -namespace Infrastructure; +using System.Reflection; -public class Infrastructure +namespace Infrastructure; + +public static class Infrastructure { + public static Assembly Assembly => typeof(Infrastructure).Assembly; } \ No newline at end of file diff --git a/src/Infrastructure/Infrastructure.csproj b/src/Infrastructure/Infrastructure.csproj index 5cf3cf8..ed50427 100644 --- a/src/Infrastructure/Infrastructure.csproj +++ b/src/Infrastructure/Infrastructure.csproj @@ -23,4 +23,8 @@ + + + + diff --git a/src/Infrastructure/Services/CookieService.cs b/src/Infrastructure/Services/CookieService.cs index b59c9db..a12ff52 100644 --- a/src/Infrastructure/Services/CookieService.cs +++ b/src/Infrastructure/Services/CookieService.cs @@ -1,8 +1,9 @@ -using Microsoft.JSInterop; +using Application.Services; +using Microsoft.JSInterop; namespace Infrastructure.Services; -public sealed class CookieService(IJSRuntime js) +public sealed class CookieService(IJSRuntime js) : ICookieService { public async Task GetCookieAsync(string key, CancellationToken ct = default) => await js.InvokeAsync("window.getCookie", ct, key); From 7a5920ef1e4a0e8255ef4d40a69c5c31a5d2b09d Mon Sep 17 00:00:00 2001 From: David Date: Sat, 28 Jun 2025 19:55:36 +0400 Subject: [PATCH 4/4] added error boundary and validator rules --- src/Client/Components/Routes.razor | 23 +++++++++++++++++------ src/Client/Program.cs | 8 ++++---- 2 files changed, 21 insertions(+), 10 deletions(-) diff --git a/src/Client/Components/Routes.razor b/src/Client/Components/Routes.razor index e5978da..c8c2e4d 100644 --- a/src/Client/Components/Routes.razor +++ b/src/Client/Components/Routes.razor @@ -1,7 +1,18 @@ @using Client.Components.Layout - - - - - - \ No newline at end of file + + + + + + + + + + +
+

Something went wrong!

+

We're working on it. Please try again later.

+
+
+
+ diff --git a/src/Client/Program.cs b/src/Client/Program.cs index 5c5307d..12dc8ef 100644 --- a/src/Client/Program.cs +++ b/src/Client/Program.cs @@ -1,6 +1,10 @@ using Application; using Client.Components; using dotenv.net; +using FluentValidation; + +ValidatorOptions.Global.DefaultRuleLevelCascadeMode = CascadeMode.Stop; +ValidatorOptions.Global.LanguageManager.Enabled = true; DotEnv.Fluent() .WithTrimValues() @@ -15,17 +19,13 @@ var app = builder.Build(); -// Configure the HTTP request pipeline. if (!app.Environment.IsDevelopment()) { app.UseExceptionHandler("/Error", createScopeForErrors: true); app.UseDeveloperExceptionPage(); - // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts. app.UseHsts(); } -// app.UseHttpsRedirection(); -// app.UseCors(); app.UseAuthentication(); app.UseAuthorization();