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..fd4b1d4 100644 --- a/src/Application/Application.csproj +++ b/src/Application/Application.csproj @@ -14,6 +14,8 @@ + + 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/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 new file mode 100644 index 0000000..f06d7bd --- /dev/null +++ b/src/Application/ConfigureApplicaton.cs @@ -0,0 +1,21 @@ +using Application.Behaviours; +using FluentValidation; +using MediatR; +using Microsoft.Extensions.DependencyInjection; + +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/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/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/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..31b0ea9 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,57 @@ 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."); + + 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.")); } } @@ -48,6 +71,8 @@ public sealed record RegisterCustomerCommandHandler(IAppDbContext DbContext) : I { public async Task Handle(RegisterCustomerCommand request, CancellationToken ct) { + var transaction = await DbContext.BeginTransactionAsync(ct); + var user = new User { Id = Ulid.NewUlid(), @@ -59,6 +84,7 @@ public async Task Handle(RegisterCustomerCommand request, CancellationToke }; 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..43b976c 100644 --- a/src/Client/Components/Pages/LogIn.razor +++ b/src/Client/Components/Pages/LogIn.razor @@ -2,10 +2,9 @@ @using Application.Auth @using Application.Services @using Domain.ValueObjects -@inject IToastService Toast -@inject IMediator Mediator -@inject NavigationManager Nav +@inherits Client.Components.shared.AppComponentBase @inject IJwtGenerator JwtGenerator +@inject ICookieService CookieService
@@ -36,7 +35,7 @@ Submit Don't have an account? Register instead + @onclick="@(() => NavigationManager.NavigateTo("/Register"))" class="text-primary hover:cursor-pointer">Register instead
@@ -46,32 +45,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..3aa96fa 100644 --- a/src/Client/Components/Pages/Register.razor +++ b/src/Client/Components/Pages/Register.razor @@ -1,8 +1,8 @@ @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 +42,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/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/Components/shared/AppComponentBase.cs b/src/Client/Components/shared/AppComponentBase.cs new file mode 100644 index 0000000..aace7d3 --- /dev/null +++ b/src/Client/Components/shared/AppComponentBase.cs @@ -0,0 +1,59 @@ +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 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/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..12dc8ef 100644 --- a/src/Client/Program.cs +++ b/src/Client/Program.cs @@ -1,68 +1,31 @@ -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; +using FluentValidation; + +ValidatorOptions.Global.DefaultRuleLevelCascadeMode = CascadeMode.Stop; +ValidatorOptions.Global.LanguageManager.Enabled = true; -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(); -// 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(); 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/ConfigureInfrastructure.cs b/src/Infrastructure/ConfigureInfrastructure.cs new file mode 100644 index 0000000..f9f342e --- /dev/null +++ b/src/Infrastructure/ConfigureInfrastructure.cs @@ -0,0 +1,45 @@ +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; + +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 new file mode 100644 index 0000000..35bbdc4 --- /dev/null +++ b/src/Infrastructure/Infrastructure.cs @@ -0,0 +1,8 @@ +using System.Reflection; + +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/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/CookieService.cs b/src/Infrastructure/Services/CookieService.cs new file mode 100644 index 0000000..a12ff52 --- /dev/null +++ b/src/Infrastructure/Services/CookieService.cs @@ -0,0 +1,16 @@ +using Application.Services; +using Microsoft.JSInterop; + +namespace Infrastructure.Services; + +public sealed class CookieService(IJSRuntime js) : ICookieService +{ + 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 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