Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ riderModule.iml
.git
.env
dbo.db
db.db
db.db
.aspdotnet
2 changes: 2 additions & 0 deletions src/Application/Application.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3"/>
<PackageReference Include="Destructurama.Attributed" Version="5.1.0"/>
<PackageReference Include="FluentValidation" Version="12.0.0"/>
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0"/>
<PackageReference Include="Klean.EntityFrameworkCore.DataProtection" Version="1.2.1"/>
<PackageReference Include="MediatR.Contracts" Version="2.0.1"/>
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6"/>
</ItemGroup>
Expand Down
25 changes: 25 additions & 0 deletions src/Application/Behaviours/RequestLoggingBehaviour.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
using Application.Services;
using MediatR;
using Serilog;

namespace Application.Behaviours;

internal sealed class RequestLoggingBehaviour<TRequest, TResponse>(ICurrentUserAccessor currentUser) : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> 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;
}
}
}
58 changes: 58 additions & 0 deletions src/Application/Behaviours/RequestValidationBehaviour.cs
Original file line number Diff line number Diff line change
@@ -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<TRequest, TResponse>(IServiceProvider serviceProvider, IEnumerable<IValidator<TRequest>> validators) : IPipelineBehavior<TRequest, TResponse>
where TRequest : IRequest<TResponse>
{
public async Task<TResponse> Handle(TRequest request, RequestHandlerDelegate<TResponse> next, CancellationToken ct)
{
var context = new ValidationContext(request, null, null);
var results = new List<DataAnnotationsValidationResult>();
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<FluentValidationResult> DataAnnotationValidate(TRequest request)
{
var context = new ValidationContext(request, serviceProvider, null);
var results = new List<DataAnnotationsValidationResult>();

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]));
}
}
38 changes: 38 additions & 0 deletions src/Application/ConfigurationBase.cs
Original file line number Diff line number Diff line change
@@ -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);

/// <summary>
/// Configures the configurations from all the assembly names.
/// </summary>
public static void ConfigureServicesFromAssemblies(IServiceCollection services, IEnumerable<string> assemblies)
{
ConfigureServicesFromAssemblies(services, assemblies.Select(Assembly.Load));
}

/// <summary>
/// Configures the configurations from all the assemblies and configuration types.
/// </summary>
private static void ConfigureServicesFromAssemblies(IServiceCollection services, IEnumerable<Assembly> 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);
});
}
}
21 changes: 21 additions & 0 deletions src/Application/ConfigureApplicaton.cs
Original file line number Diff line number Diff line change
@@ -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<,>));
});
}
}
3 changes: 3 additions & 0 deletions src/Application/Services/IAppDbContext.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Storage;

namespace Application.Services;

Expand All @@ -7,4 +8,6 @@ public interface IAppDbContext
public DbSet<TEntity> Set<TEntity>() where TEntity : class;

public Task<int> SaveChangesAsync(CancellationToken ct = default);

public Task<IDbContextTransaction> BeginTransactionAsync(CancellationToken ct = default);
}
10 changes: 10 additions & 0 deletions src/Application/Services/ICookieService.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
namespace Application.Services;

public interface ICookieService
{
public Task<string> GetCookieAsync(string key, CancellationToken ct = default);

public Task SetCookieAsync(string key, string value, CancellationToken ct = default);

public Task DeleteCookieAsync(string key, CancellationToken ct = default);
}
15 changes: 15 additions & 0 deletions src/Application/Services/ICurrentUserAccessor.cs
Original file line number Diff line number Diff line change
@@ -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<User?> TryGetCurrentUserAsync(CancellationToken ct = default);

public async Task<User> GetCurrentUserAsync(CancellationToken ct = default) =>
await TryGetCurrentUserAsync(ct) ?? throw new InvalidOperationException("The user is not authenticated");
}
40 changes: 33 additions & 7 deletions src/Application/Users/Commands/RegisterCustomerCommand.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,77 @@
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;

public sealed record RegisterCustomerCommand : IRequest<User>
{
[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<RegisterCustomerCommand>
{
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<User>().WherePdEquals(nameof(User.Email), email).CountAsync(ct);
return usersWithPd == 0;
})
.WithMessage("Email already exists."));
}
}

public sealed record RegisterCustomerCommandHandler(IAppDbContext DbContext) : IRequestHandler<RegisterCustomerCommand, User>
{
public async Task<User> Handle(RegisterCustomerCommand request, CancellationToken ct)
{
var transaction = await DbContext.BeginTransactionAsync(ct);

var user = new User
{
Id = Ulid.NewUlid(),
Expand All @@ -59,6 +84,7 @@ public async Task<User> Handle(RegisterCustomerCommand request, CancellationToke
};
await DbContext.Set<User>().AddAsync(user, ct);
await DbContext.SaveChangesAsync(ct);
await transaction.CommitAsync(ct);
return user;
}
}
43 changes: 14 additions & 29 deletions src/Client/Components/Pages/LogIn.razor
Original file line number Diff line number Diff line change
Expand Up @@ -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

<div class="flex-grow flex items-center justify-center h-screen px-5">
<form class="flex w-1/5 flex-col p-8 bg-background text-foreground border border-border rounded-lg shadow">
Expand Down Expand Up @@ -36,7 +35,7 @@
Submit
</button>
<span class="text-center text-sm text-muted-foreground mt-4">Don't have an account? <a
@onclick="@(() => Nav.NavigateTo("/Register"))" class="text-primary hover:cursor-pointer">Register</a> instead</span>
@onclick="@(() => NavigationManager.NavigateTo("/Register"))" class="text-primary hover:cursor-pointer">Register</a> instead</span>
</form>
</div>

Expand All @@ -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;
}
}
}
Loading