diff --git a/src/CMSMicroservice.Application/CMSMicroservice.Application.csproj b/src/CMSMicroservice.Application/CMSMicroservice.Application.csproj index 3879c49..618d4dc 100644 --- a/src/CMSMicroservice.Application/CMSMicroservice.Application.csproj +++ b/src/CMSMicroservice.Application/CMSMicroservice.Application.csproj @@ -7,11 +7,11 @@ - + - - - + + + diff --git a/src/CMSMicroservice.Application/Common/Exceptions/ValidationException.cs b/src/CMSMicroservice.Application/Common/Exceptions/ValidationException.cs index 77d2d7d..bb3bece 100644 --- a/src/CMSMicroservice.Application/Common/Exceptions/ValidationException.cs +++ b/src/CMSMicroservice.Application/Common/Exceptions/ValidationException.cs @@ -11,12 +11,30 @@ public class ValidationException : Exception } public ValidationException(IEnumerable failures) - : this() + : base(BuildMessage(failures, out var errors)) { - Errors = failures - .GroupBy(e => e.PropertyName, e => e.ErrorMessage) - .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + Errors = errors; } public IDictionary Errors { get; } + + private static string BuildMessage(IEnumerable failures, out IDictionary errors) + { + var list = failures?.Where(f => !string.IsNullOrWhiteSpace(f.ErrorMessage)).ToList() ?? new List(); + + errors = list + .GroupBy(e => e.PropertyName, e => e.ErrorMessage) + .ToDictionary(failureGroup => failureGroup.Key, failureGroup => failureGroup.ToArray()); + + var items = list + .Select(f => string.IsNullOrWhiteSpace(f.PropertyName) + ? f.ErrorMessage + : $"{f.PropertyName}: {f.ErrorMessage}") + .Where(s => !string.IsNullOrWhiteSpace(s)) + .ToArray(); + + return items.Length > 0 + ? string.Join(" | ", items) + : "One or more validation failures have occurred."; + } } diff --git a/src/CMSMicroservice.Application/Common/Interfaces/IGenerateJwtToken.cs b/src/CMSMicroservice.Application/Common/Interfaces/IGenerateJwtToken.cs index 1b08985..2a53ce8 100644 --- a/src/CMSMicroservice.Application/Common/Interfaces/IGenerateJwtToken.cs +++ b/src/CMSMicroservice.Application/Common/Interfaces/IGenerateJwtToken.cs @@ -1,5 +1,5 @@ namespace CMSMicroservice.Application.Common.Interfaces; public interface IGenerateJwtToken { - Task GenerateJwtToken(User user); + Task GenerateJwtToken(User user,int? expiryDays=null); } diff --git a/src/CMSMicroservice.Application/UserCQ/Commands/SetPasswordForUser/SetPasswordForUserCommandHandler.cs b/src/CMSMicroservice.Application/UserCQ/Commands/SetPasswordForUser/SetPasswordForUserCommandHandler.cs index 72ca63e..60cfa1a 100644 --- a/src/CMSMicroservice.Application/UserCQ/Commands/SetPasswordForUser/SetPasswordForUserCommandHandler.cs +++ b/src/CMSMicroservice.Application/UserCQ/Commands/SetPasswordForUser/SetPasswordForUserCommandHandler.cs @@ -3,15 +3,50 @@ namespace CMSMicroservice.Application.UserCQ.Commands.SetPasswordForUser; public class SetPasswordForUserCommandHandler : IRequestHandler { private readonly IApplicationDbContext _context; + private readonly IHashService _hashService; - public SetPasswordForUserCommandHandler(IApplicationDbContext context) + public SetPasswordForUserCommandHandler(IApplicationDbContext context, IHashService hashService) { _context = context; + _hashService = hashService; } public async Task Handle(SetPasswordForUserCommand request, CancellationToken cancellationToken) { - //TODO: Implement your business logic - return new Unit(); + // basic validations + if (!string.Equals(request.NewPassword, request.ConfirmPassword, StringComparison.Ordinal)) + { + throw new CMSMicroservice.Application.Common.Exceptions.ValidationException(new[] + { + new FluentValidation.Results.ValidationFailure(nameof(request.ConfirmPassword), "کلمه عبور و تایید آن یکسان نیستند.") + }); + } + + var user = await _context.Users.FirstOrDefaultAsync(u => u.Id == request.UserId, cancellationToken) + ?? throw new NotFoundException(nameof(User), request.UserId); + + var hasExistingPassword = !string.IsNullOrWhiteSpace(user.HashPassword); + if (hasExistingPassword) + { + if (string.IsNullOrWhiteSpace(request.CurrentPassword)) + { + throw new CMSMicroservice.Application.Common.Exceptions.ValidationException(new[] + { + new FluentValidation.Results.ValidationFailure(nameof(request.CurrentPassword), "کلمه عبور فعلی الزامی است.") + }); + } + if (!_hashService.VerifyPassword(request.CurrentPassword, user.HashPassword)) + { + throw new UnauthorizedAccessException("کلمه عبور فعلی نادرست است."); + } + } + + // set new password (PBKDF2) + user.HashPassword = _hashService.HashPassword(request.NewPassword); + _context.Users.Update(user); + user.AddDomainEvent(new CMSMicroservice.Domain.Events.SetPasswordForUserEvent(user)); + await _context.SaveChangesAsync(cancellationToken); + + return Unit.Value; } } diff --git a/src/CMSMicroservice.Application/UserCQ/EventHandlers/SetPasswordForUserEventHandlers/SetPasswordForUserEventHandler.cs b/src/CMSMicroservice.Application/UserCQ/EventHandlers/SetPasswordForUserEventHandlers/SetPasswordForUserEventHandler.cs index 062d465..d52e925 100644 --- a/src/CMSMicroservice.Application/UserCQ/EventHandlers/SetPasswordForUserEventHandlers/SetPasswordForUserEventHandler.cs +++ b/src/CMSMicroservice.Application/UserCQ/EventHandlers/SetPasswordForUserEventHandlers/SetPasswordForUserEventHandler.cs @@ -1,7 +1,7 @@ using CMSMicroservice.Domain.Events; using Microsoft.Extensions.Logging; -namespace CMSMicroservice.Application.UserCQ.EventHandlers; +namespace CMSMicroservice.Application.UserCQ.EventHandlers.SetPasswordForUserEventHandlers; public class SetPasswordForUserEventHandler : INotificationHandler { diff --git a/src/CMSMicroservice.Application/UserCQ/Queries/AdminGetJwtToken/AdminGetJwtTokenQueryHandler.cs b/src/CMSMicroservice.Application/UserCQ/Queries/AdminGetJwtToken/AdminGetJwtTokenQueryHandler.cs index b93985a..f2866e3 100644 --- a/src/CMSMicroservice.Application/UserCQ/Queries/AdminGetJwtToken/AdminGetJwtTokenQueryHandler.cs +++ b/src/CMSMicroservice.Application/UserCQ/Queries/AdminGetJwtToken/AdminGetJwtTokenQueryHandler.cs @@ -1,18 +1,21 @@ namespace CMSMicroservice.Application.UserCQ.Queries.AdminGetJwtToken; + public class AdminGetJwtTokenQueryHandler : IRequestHandler { private readonly IApplicationDbContext _context; private readonly IGenerateJwtToken _generateJwt; private readonly IHashService _hashService; - public AdminGetJwtTokenQueryHandler(IApplicationDbContext context, IGenerateJwtToken generateJwt, IHashService hashService) + public AdminGetJwtTokenQueryHandler(IApplicationDbContext context, IGenerateJwtToken generateJwt, + IHashService hashService) { _context = context; _generateJwt = generateJwt; _hashService = hashService; } - public async Task Handle(AdminGetJwtTokenQuery request, CancellationToken cancellationToken) + public async Task Handle(AdminGetJwtTokenQuery request, + CancellationToken cancellationToken) { var user = await _context.Users .Include(u => u.UserRoles) @@ -21,14 +24,16 @@ public class AdminGetJwtTokenQueryHandler : IRequestHandler a.RoleId != 2)) + throw new Exception("You do not have permission to do that."); + // verify password (supports PBKDF2 or legacy SHA-256) if (!_hashService.VerifyPassword(request.Password, user.HashPassword)) throw new Exception("Invalid username or password."); return new AdminGetJwtTokenResponseDto() { - Token = await _generateJwt.GenerateJwtToken(user), + Token = await _generateJwt.GenerateJwtToken(user,15), }; - } -} +} \ No newline at end of file diff --git a/src/CMSMicroservice.Domain/Events/UserEvents/SetPasswordForUserEvent.cs b/src/CMSMicroservice.Domain/Events/UserEvents/SetPasswordForUserEvent.cs index ea8c7da..8832386 100644 --- a/src/CMSMicroservice.Domain/Events/UserEvents/SetPasswordForUserEvent.cs +++ b/src/CMSMicroservice.Domain/Events/UserEvents/SetPasswordForUserEvent.cs @@ -3,6 +3,7 @@ public class SetPasswordForUserEvent : BaseEvent { public SetPasswordForUserEvent(User item) { + Item = item; } public User Item { get; } } diff --git a/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj b/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj index 6b0d4a6..5261df4 100644 --- a/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj +++ b/src/CMSMicroservice.Infrastructure/CMSMicroservice.Infrastructure.csproj @@ -6,15 +6,15 @@ - - - - + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + diff --git a/src/CMSMicroservice.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs b/src/CMSMicroservice.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs index 79c84d9..f84e914 100644 --- a/src/CMSMicroservice.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/CMSMicroservice.Infrastructure/Persistence/Migrations/ApplicationDbContextModelSnapshot.cs @@ -18,7 +18,7 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("CMS") - .HasAnnotation("ProductVersion", "7.0.0") + .HasAnnotation("ProductVersion", "9.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 128); SqlServerModelBuilderExtensions.UseIdentityColumns(modelBuilder); @@ -474,6 +474,9 @@ namespace CMSMicroservice.Infrastructure.Persistence.Migrations b.Property("FirstName") .HasColumnType("nvarchar(max)"); + b.Property("HashPassword") + .HasColumnType("nvarchar(max)"); + b.Property("IsDeleted") .HasColumnType("bit"); diff --git a/src/CMSMicroservice.Infrastructure/Services/GenerateJwtTokenService.cs b/src/CMSMicroservice.Infrastructure/Services/GenerateJwtTokenService.cs index 381e15e..ae8bc27 100644 --- a/src/CMSMicroservice.Infrastructure/Services/GenerateJwtTokenService.cs +++ b/src/CMSMicroservice.Infrastructure/Services/GenerateJwtTokenService.cs @@ -17,7 +17,7 @@ public class GenerateJwtTokenService : IGenerateJwtToken _configuration = configuration; } - public async Task GenerateJwtToken(User user) + public async Task GenerateJwtToken(User user,int? expiryDays=null) { var lastModified = user.LastModified ?? user.Created; var claims = new List @@ -51,7 +51,10 @@ public class GenerateJwtTokenService : IGenerateJwtToken var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_configuration["JwtSecurityKey"])); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); - var expiry = DateTime.Now.AddDays(Convert.ToInt32(_configuration["JwtExpiryInDays"])); + var expiry =expiryDays!=null? + DateTime.Now.AddDays(Convert.ToInt32(expiryDays)) + : + DateTime.Now.AddDays(Convert.ToInt32(_configuration["JwtExpiryInDays"])); var token = new JwtSecurityToken( _configuration["JwtIssuer"], diff --git a/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj b/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj index 7d9057f..7598d70 100644 --- a/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj +++ b/src/CMSMicroservice.Protobuf/CMSMicroservice.Protobuf.csproj @@ -3,7 +3,7 @@ net9.0 enable enable - 0.0.117 + 0.0.118 None False False diff --git a/src/CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj b/src/CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj index ba6ad0e..ae90b87 100644 --- a/src/CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj +++ b/src/CMSMicroservice.WebApi/CMSMicroservice.WebApi.csproj @@ -15,16 +15,16 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + - - - + + + diff --git a/src/CMSMicroservice.WebApi/Common/Behaviours/ExceptionHandlingBehaviour.cs b/src/CMSMicroservice.WebApi/Common/Behaviours/ExceptionHandlingBehaviour.cs new file mode 100644 index 0000000..74ca6cf --- /dev/null +++ b/src/CMSMicroservice.WebApi/Common/Behaviours/ExceptionHandlingBehaviour.cs @@ -0,0 +1,99 @@ +// filepath: /media/masoud/Project/FourSat/CMS/src/CMSMicroservice.WebApi/Common/Behaviours/ExceptionHandlingBehaviour.cs +using System.Text.Json; +using System.Collections.Generic; +using CMSMicroservice.Application.Common.Exceptions; +using Grpc.Core; +using Grpc.Core.Interceptors; +using Microsoft.Extensions.Logging; + +namespace CMSMicroservice.WebApi.Common.Behaviours; + +public class ExceptionHandlingBehaviour : Interceptor +{ + private readonly ILogger _logger; + + public ExceptionHandlingBehaviour(ILogger logger) + { + _logger = logger; + } + + public override async Task UnaryServerHandler( + TRequest request, + ServerCallContext context, + UnaryServerMethod continuation) + { + try + { + return await continuation(request, context); + } + catch (ValidationException vex) + { + // Flatten validation errors into a trailer so clients can display them + var metadata = new Metadata(); + string description = vex.Message; + try + { + if (vex.Errors is { Count: > 0 }) + { + var payload = JsonSerializer.Serialize(vex.Errors); + metadata.Add("validation-errors-text", payload); + + // Build a human-friendly description out of individual messages + var parts = new List(); + foreach (var kv in vex.Errors) + { + foreach (var msg in kv.Value) + { + if (!string.IsNullOrWhiteSpace(kv.Key)) + parts.Add($"{kv.Key}: {msg}"); + else + parts.Add(msg); + } + } + if (parts.Count > 0) + { + description = string.Join(" | ", parts); + } + } + } + catch + { + // ignore serialization issues, still return InvalidArgument + } + + _logger.LogWarning(vex, "Validation failed for request {Method}", context.Method); + throw new RpcException(new Status(StatusCode.InvalidArgument, description), metadata); + } + catch (NotFoundException nfex) + { + _logger.LogInformation(nfex, "Entity not found for request {Method}", context.Method); + throw new RpcException(new Status(StatusCode.NotFound, nfex.Message)); + } + catch (UnauthorizedAccessException uaex) + { + _logger.LogInformation(uaex, "Unauthorized access for request {Method}", context.Method); + throw new RpcException(new Status(StatusCode.Unauthenticated, uaex.Message)); + } + catch (ForbiddenAccessException fax) + { + _logger.LogInformation(fax, "Forbidden access for request {Method}", context.Method); + throw new RpcException(new Status(StatusCode.PermissionDenied, fax.Message)); + } + catch (DuplicateException dex) + { + _logger.LogInformation(dex, "Duplicate resource for request {Method}", context.Method); + throw new RpcException(new Status(StatusCode.AlreadyExists, dex.Message)); + } + catch (ArgumentException aex) + { + _logger.LogInformation(aex, "Invalid argument for request {Method}", context.Method); + throw new RpcException(new Status(StatusCode.InvalidArgument, aex.Message)); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception for request {Method}", context.Method); + // Hide internal details from clients + throw new RpcException(new Status(StatusCode.Internal, "Internal server error")); + } + } +} diff --git a/src/CMSMicroservice.WebApi/Program.cs b/src/CMSMicroservice.WebApi/Program.cs index 11904d4..1525688 100644 --- a/src/CMSMicroservice.WebApi/Program.cs +++ b/src/CMSMicroservice.WebApi/Program.cs @@ -38,6 +38,7 @@ builder.Services.AddGrpc(options => { options.Interceptors.Add(); options.Interceptors.Add(); + //options.Interceptors.Add(); options.EnableDetailedErrors = true; options.MaxReceiveMessageSize = 1000 * 1024 * 1024; // 1 GB options.MaxSendMessageSize = 1000 * 1024 * 1024; // 1 GB diff --git a/src/CMSMicroservice.WebApi/appsettings.json b/src/CMSMicroservice.WebApi/appsettings.json index 9b32709..297d770 100644 --- a/src/CMSMicroservice.WebApi/appsettings.json +++ b/src/CMSMicroservice.WebApi/appsettings.json @@ -2,7 +2,7 @@ "JwtSecurityKey": "TvlZVx5TJaHs8e9HgUdGzhGP2CIidoI444nAj+8+g7c=", "JwtIssuer": "https://localhost", "JwtAudience": "https://localhost", - "JwtExpiryInDays": 365, + "JwtExpiryInDays": 5, "ConnectionStrings": { "DefaultConnection": "Data Source=185.252.31.42,2019; Initial Catalog=Foursat;User ID=afrino;Password=87zH26nbqT%;Connection Timeout=300000;MultipleActiveResultSets=True;Encrypt=False", "providerName": "System.Data.SqlClient"