diff --git a/docs/ARCHITECTURE_OVERVIEW.md b/docs/ARCHITECTURE_OVERVIEW.md index 54c8a9a..288428c 100644 --- a/docs/ARCHITECTURE_OVERVIEW.md +++ b/docs/ARCHITECTURE_OVERVIEW.md @@ -71,7 +71,7 @@ UserCreatedEventHandler → gửi email welcome - Validation, Logging, Authorization, etc. ```csharp -Request → ValidationBehavior → AuthorizationBehavior → Handler → Response +Request → ValidationBehavior → Handler → Response ``` ## 📂 **FOLDERS QUAN TRỌNG NHẤT** diff --git a/docs/BEHAVIORS_GUIDE.md b/docs/BEHAVIORS_GUIDE.md index 4a62eda..c4ee0aa 100644 --- a/docs/BEHAVIORS_GUIDE.md +++ b/docs/BEHAVIORS_GUIDE.md @@ -6,7 +6,7 @@ ### **Behaviors = Middleware cho Commands/Queries** ``` -Request → ValidationBehavior → AuthorizationBehavior → LoggingBehavior → Handler → Response +Request → ValidationBehavior → LoggingBehavior → Handler → Response ``` **Mỗi behavior là 1 layer xử lý cross-cutting concerns trước/sau khi handler chạy.** @@ -35,32 +35,7 @@ public class CreateUserCommandValidator : AbstractValidator // Nếu fail → throw ValidationException ``` -### **2. 🔐 AuthorizationBehavior** -- **Mục đích**: Kiểm tra quyền truy cập -- **Khi nào chạy**: Sau validation, trước handler -- **Input**: IAuthorizedRequest interface -- **Output**: Throw UnauthorizedException/ForbiddenException - -```csharp -// Command yêu cầu authorization -public record CreateUserCommand : ICommand>, - IAuthorizedRequest -{ - public AuthorizationRequirement AuthorizationRequirement => new() - { - Roles = ["Admin"], // Cần role Admin - Permissions = ["users.create"], // Cần permission users.create - RequireAuthentication = true - }; -} - -// AuthorizationBehavior sẽ check: -// - User có authenticated không? -// - User có role Admin không? -// - User có permission users.create không? -``` - -### **3. 📊 LoggingBehavior** +### **2. 📊 LoggingBehavior** - **Mục đích**: Log tất cả requests/responses - **Khi nào chạy**: Bao quanh handler (before + after) - **Input**: Request name @@ -92,7 +67,7 @@ public record CreateUserCommand : ICommand>, ```csharp // Query với caching -public record GetUserByIdQuery(Guid UserId) : IQuery, +public record GetUserByIdQuery(Guid UserId) : IQuery, ICacheableQuery { public string CacheKey => $"user-{UserId}"; @@ -104,7 +79,7 @@ public record GetUserByIdQuery(Guid UserId) : IQuery, // 2. Nếu không → execute handler → cache result → return ``` -### **6. 🔄 TransactionBehavior** +### **6. 💳 TransactionBehavior** - **Mục đích**: Wrap commands trong database transaction - **Khi nào chạy**: Chỉ cho commands implement ITransactionalCommand - **Rollback**: Automatic nếu có exception diff --git a/docs/EXCEPTION_GUIDELINES.md b/docs/EXCEPTION_GUIDELINES.md index e7cf098..6a3539a 100644 --- a/docs/EXCEPTION_GUIDELINES.md +++ b/docs/EXCEPTION_GUIDELINES.md @@ -28,7 +28,6 @@ Exception ├── NotFoundException ├── UnauthorizedException ├── BusinessRuleViolationException - ├── RequestProcessingException └── DomainEventDispatchException ``` @@ -203,28 +202,6 @@ if (!user.HasRole("Admin")) throw new UnauthorizedException("Access denied", "Admin"); ``` -#### **RequestProcessingException** -```csharp -namespace Application.Common.Exceptions; - -public sealed class RequestProcessingException : Exception -{ - public RequestProcessingException(string message) : base(message) - { - } - - public RequestProcessingException(string message, Exception innerException) - : base(message, innerException) - { - } -} - -// Usage in MediatR behaviors -catch (Exception ex) -{ - throw new RequestProcessingException($"Request {requestName} failed after {duration}ms", ex); -} -``` ### **Infrastructure Layer Exceptions** @@ -307,8 +284,6 @@ throw new UnauthorizedException("Insufficient permissions"); // ✅ Use for not found throw new NotFoundException("User", userId); -// ✅ Use for request processing -throw new RequestProcessingException("Command processing failed", ex); // ❌ Don't use low-level exceptions // throw new SqlException(...); // TOO LOW LEVEL @@ -472,7 +447,7 @@ public async Task CreateUserAsync(CreateUserCommand command) } catch (Exception ex) { - throw new RequestProcessingException("Failed to create user", ex); + throw; // Re-throw or handle appropriately } } @@ -515,11 +490,6 @@ public async Task CreateUser(CreateUserCommand command) { return BadRequest(new { rule = ex.RuleName, message = ex.Message }); } - catch (RequestProcessingException ex) - { - logger.LogError(ex, "Failed to process create user request"); - return StatusCode(500, new { message = "Internal server error" }); - } } ``` @@ -565,11 +535,6 @@ public class GlobalExceptionMiddleware Message = businessEx.Message, Details = new { Rule = businessEx.RuleName } }, - RequestProcessingException requestEx => new ApiResponse - { - StatusCode = 500, - Message = "Request processing failed" - }, _ => new ApiResponse { StatusCode = 500, @@ -607,8 +572,6 @@ Exception occurred? │ ├── Database → DataPersistenceException │ ├── Event dispatch → DomainEventDispatchException │ └── External service → ExternalServiceException -└── Is it request processing failure? - └── Pipeline error → RequestProcessingException ``` --- @@ -626,7 +589,6 @@ Exception occurred? | Entity not found | `NotFoundException` | Application | | Database operation failure | `DataPersistenceException` | Infrastructure | | Event dispatch failure | `DomainEventDispatchException` | Infrastructure | -| Request processing failure | `RequestProcessingException` | Application | --- diff --git a/file_tree.md b/file_tree.md deleted file mode 100644 index 4035bd9..0000000 --- a/file_tree.md +++ /dev/null @@ -1,218 +0,0 @@ -# File Tree: LegalAssistant.AppService - -**Generated:** 11/3/2025, 10:46:08 AM -**Root Path:** `d:\School\5thYear\1stSemester\Chatbot\LegalAssistant.AppService` - -``` -├── 📁 .github -│ ├── 📁 workflows -│ │ └── ⚙️ build.yml -│ └── ⚙️ dependabot.yml -├── 📁 Infrastructure -├── 📁 docs -│ ├── 📝 AGGREGATE_EXPLAINED.md -│ ├── 📝 ARCHITECTURE_OVERVIEW.md -│ ├── 📝 BEHAVIORS_GUIDE.md -│ ├── 📝 CI_CD_EXPLAINED.md -│ ├── 📝 COMMANDS.md -│ ├── 📝 DI_BEHAVIORS_EXAMPLE.md -│ ├── 📝 EVENT_HANDLER_FLOW.md -│ ├── 📝 EXCEPTION_GUIDELINES.md -│ ├── 📝 NAMING_CONVENTIONS.md -│ └── 📝 README.md -├── 📁 src -│ ├── 📁 Application -│ │ ├── 📁 Common -│ │ │ ├── 📁 Behaviors -│ │ │ │ ├── 📄 AuthorizationBehavior.cs -│ │ │ │ ├── 📄 CachingBehavior.cs -│ │ │ │ ├── 📄 DomainEventBehavior.cs -│ │ │ │ ├── 📄 LoggingBehavior.cs -│ │ │ │ ├── 📄 PerformanceBehavior.cs -│ │ │ │ ├── 📄 TransactionBehavior.cs -│ │ │ │ └── 📄 ValidationBehavior.cs -│ │ │ ├── 📁 Exceptions -│ │ │ │ ├── 📄 ApplicationException.cs -│ │ │ │ ├── 📄 BusinessRuleException.cs -│ │ │ │ ├── 📄 ConflictException.cs -│ │ │ │ ├── 📄 ExternalServiceException.cs -│ │ │ │ ├── 📄 ForbiddenException.cs -│ │ │ │ ├── 📄 NotFoundException.cs -│ │ │ │ ├── 📄 RequestProcessingException.cs -│ │ │ │ ├── 📄 UnauthorizedException.cs -│ │ │ │ └── 📄 ValidationException.cs -│ │ │ ├── 📁 Models -│ │ │ │ ├── 📄 AuditableEntity.cs -│ │ │ │ ├── 📄 PaginatedResult.cs -│ │ │ │ ├── 📄 PaginationRequest.cs -│ │ │ │ └── 📄 SortOrder.cs -│ │ │ ├── 📄 ICommand.cs -│ │ │ ├── 📄 ICommandHandler.cs -│ │ │ ├── 📄 IDomainEventDispatcher.cs -│ │ │ ├── 📄 IDomainEventHandler.cs -│ │ │ ├── 📄 IQuery.cs -│ │ │ └── 📄 IQueryHandler.cs -│ │ ├── 📁 EventHandlers -│ │ │ ├── 📄 UserCreatedEventHandler.cs -│ │ │ ├── 📄 UserDeactivatedEventHandler.cs -│ │ │ └── 📄 UserUpdatedEventHandler.cs -│ │ ├── 📁 Features -│ │ │ ├── 📁 Auth -│ │ │ │ ├── 📁 GetProfile -│ │ │ │ │ ├── 📄 GetProfileQuery.cs -│ │ │ │ │ └── 📄 GetProfileQueryHandler.cs -│ │ │ │ ├── 📁 Login -│ │ │ │ │ ├── 📄 LoginCommand.cs -│ │ │ │ │ ├── 📄 LoginCommandHandler.cs -│ │ │ │ │ └── 📄 LoginCommandValidator.cs -│ │ │ │ ├── 📁 Logout -│ │ │ │ │ ├── 📄 LogoutCommand.cs -│ │ │ │ │ ├── 📄 LogoutCommandHandler.cs -│ │ │ │ │ └── 📄 LogoutCommandValidator.cs -│ │ │ │ └── 📁 Register -│ │ │ │ ├── 📄 RegisterCommand.cs -│ │ │ │ ├── 📄 RegisterCommandHandler.cs -│ │ │ │ ├── 📄 RegisterCommandValidator.cs -│ │ │ │ └── 📄 RegisterResponse.cs -│ │ │ ├── 📁 Conversation -│ │ │ │ └── 📁 CreateConversation -│ │ │ │ └── 📄 CreateConversationCommand.cs -│ │ │ └── 📁 User -│ │ │ └── 📁 GetUsers -│ │ │ └── 📄 GetUsersQuery.cs -│ │ ├── 📁 Interfaces -│ │ │ ├── 📄 IAuthService.cs -│ │ │ ├── 📄 IPasswordHasher.cs -│ │ │ ├── 📄 ITokenService.cs -│ │ │ └── 📄 IUserRepository.cs -│ │ ├── 📄 Application.csproj -│ │ └── 📝 README.md -│ ├── 📁 Domain -│ │ ├── 📁 Aggregates -│ │ │ └── 📁 Conversation -│ │ │ └── 📄 ConversationAggregate.cs -│ │ ├── 📁 Common -│ │ │ ├── 📄 BaseAggregateRoot.cs -│ │ │ ├── 📄 BaseEntity.cs -│ │ │ ├── 📄 Error.cs -│ │ │ ├── 📄 ErrorType.cs -│ │ │ ├── 📄 IDomainEvent.cs -│ │ │ ├── 📄 Result.cs -│ │ │ └── 📄 ValidationError.cs -│ │ ├── 📁 Constants -│ │ │ ├── 📄 MessageRoles.cs -│ │ │ └── 📄 UserRoles.cs -│ │ ├── 📁 Entities -│ │ │ ├── 📄 Conversation.cs -│ │ │ ├── 📄 Message.cs -│ │ │ └── 📄 User.cs -│ │ ├── 📁 Enums -│ │ │ ├── 📄 ConversationStatus.cs -│ │ │ └── 📄 MessageType.cs -│ │ ├── 📁 Events -│ │ │ ├── 📁 Conversation -│ │ │ │ └── 📄 ConversationCreatedEvent.cs -│ │ │ └── 📁 User -│ │ │ ├── 📄 UserCreatedEvent.cs -│ │ │ ├── 📄 UserDeactivatedEvent.cs -│ │ │ └── 📄 UserUpdatedEvent.cs -│ │ ├── 📁 ValueObjects -│ │ │ ├── 📄 Email.cs -│ │ │ └── 📄 Password.cs -│ │ ├── 📄 Domain.csproj -│ │ ├── 📄 GlobalSuppressions.cs -│ │ └── 📝 README.md -│ ├── 📁 Infrastructure -│ │ ├── 📁 Configuration -│ │ │ ├── 📄 EmailSettings.cs -│ │ │ ├── 📄 FileStorageSettings.cs -│ │ │ └── 📄 JwtSettings.cs -│ │ ├── 📁 Data -│ │ │ ├── 📁 Configurations -│ │ │ │ ├── 📄 BaseEntityConfiguration.cs -│ │ │ │ ├── 📄 ConversationConfiguration.cs -│ │ │ │ ├── 📄 MessageConfiguration.cs -│ │ │ │ └── 📄 UserConfiguration.cs -│ │ │ ├── 📁 Contexts -│ │ │ │ ├── 📄 DataContext.cs -│ │ │ │ ├── 📄 IDataContext.cs -│ │ │ │ └── 📄 Schemas.cs -│ │ │ ├── 📁 Migrations -│ │ │ │ ├── 📄 20251027072923_InitialSchema.Designer.cs -│ │ │ │ ├── 📄 20251027072923_InitialSchema.cs -│ │ │ │ ├── 📄 20251027111716_AddIdentityToDataContext.Designer.cs -│ │ │ │ ├── 📄 20251027111716_AddIdentityToDataContext.cs -│ │ │ │ ├── 📄 20251027145835_RemovePasswordHashInDomainUserTable.Designer.cs -│ │ │ │ ├── 📄 20251027145835_RemovePasswordHashInDomainUserTable.cs -│ │ │ │ └── 📄 DataContextModelSnapshot.cs -│ │ │ └── 📁 Seeders -│ │ │ └── 📄 DatabaseSeeder.cs -│ │ ├── 📁 Exceptions -│ │ │ └── 📄 DomainEventDispatchException.cs -│ │ ├── 📁 Extensions -│ │ │ ├── 📄 IdentityServiceExtensions.cs -│ │ │ └── 📄 ServiceCollectionExtensions.cs -│ │ ├── 📁 Identity -│ │ │ ├── 📄 ApplicationRole.cs -│ │ │ ├── 📄 ApplicationUser.cs -│ │ │ ├── 📄 IdentityService.cs -│ │ │ └── 📄 UserMapper.cs -│ │ ├── 📁 Repositories -│ │ │ └── 📄 UserRepository.cs -│ │ ├── 📁 Services -│ │ │ ├── 📁 Email -│ │ │ │ └── ⚙️ .gitkeep -│ │ │ ├── 📄 DomainEventDispatcher.cs -│ │ │ ├── 📄 PasswordHasher.cs -│ │ │ └── 📄 TokenService.cs -│ │ ├── 📁 src -│ │ │ └── 📁 Infrastructure -│ │ ├── 📄 Infrastructure.csproj -│ │ └── 📝 README.md -│ ├── 📁 Web.Api -│ │ ├── 📁 Configurations -│ │ │ └── 📁 Options -│ │ │ └── 📄 JwtOptions.cs -│ │ ├── 📁 Controllers -│ │ │ └── 📁 V1 -│ │ │ ├── 📄 AuthController.cs -│ │ │ ├── 📄 BaseController.cs -│ │ │ ├── 📄 HealthController.cs -│ │ │ └── 📄 UsersController.cs -│ │ ├── 📁 Extensions -│ │ │ ├── 📄 ApplicationServiceExtensions.cs -│ │ │ ├── 📄 AuthenticationExtensions.cs -│ │ │ └── 📄 ServiceCollectionExtensions.cs -│ │ ├── 📁 Filters -│ │ │ └── 📄 ValidateModelFilter.cs -│ │ ├── 📁 Middleware -│ │ │ └── 📄 GlobalExceptionMiddleware.cs -│ │ ├── 📁 Models -│ │ │ └── 📁 Responses -│ │ │ └── 📄 ApiResponse.cs -│ │ ├── 📁 Properties -│ │ │ └── ⚙️ launchSettings.json -│ │ ├── 📁 Services -│ │ │ └── 📄 CurrentUserService.cs -│ │ ├── 🐳 Dockerfile -│ │ ├── 📄 Program.cs -│ │ ├── 📝 README.md -│ │ ├── 📄 Web.Api.csproj -│ │ ├── 📄 Web.Api.http -│ │ ├── ⚙️ appsettings.Development.json -│ │ └── ⚙️ appsettings.json -│ └── 📁 src -├── ⚙️ .cursorrules -├── ⚙️ .dockerignore -├── ⚙️ .editorconfig -├── ⚙️ .gitignore -├── 📝 BUILD_SCRIPTS.md -├── 📄 Directory.Build.props -├── 📄 LegalAssistant.AppService.sln -├── 📝 README.md -├── ⚙️ docker-compose.yml -└── 📝 file_tree.md -``` - ---- -*Generated by FileTree Pro Extension* \ No newline at end of file diff --git a/src/Application/Common/Behaviors/AuthorizationBehavior.cs b/src/Application/Common/Behaviors/AuthorizationBehavior.cs deleted file mode 100644 index 83fbe23..0000000 --- a/src/Application/Common/Behaviors/AuthorizationBehavior.cs +++ /dev/null @@ -1,126 +0,0 @@ -using Application.Common.Exceptions; -using MediatR; -using Microsoft.Extensions.Logging; - -namespace Application.Common.Behaviors; - -/// -/// Authorization requirement -/// -public sealed record AuthorizationRequirement -{ - /// - /// Required roles (user must have at least one) - /// - public List Roles { get; init; } = []; - - /// - /// Required permissions (user must have all) - /// - public string[] Permissions { get; init; } = []; - - /// - /// Require authentication (default true) - /// - public bool RequireAuthentication { get; init; } = true; -} - -/// -/// Marker interface for requests that require authorization -/// -public interface IAuthorizedRequest -{ - /// - /// Authorization requirements - /// - AuthorizationRequirement AuthorizationRequirement { get; } -} - -/// -/// Current user information (injected from HTTP context) -/// -public interface ICurrentUser -{ - /// - /// Current user ID - /// - Guid? UserId { get; } - - /// - /// Is user authenticated - /// - bool IsAuthenticated { get; } - - /// - /// User roles - /// - List Roles { get; } - - /// - /// User permissions - /// - List Permissions { get; } -} - -/// -/// Authorization behavior for MediatR pipeline -/// -/// Request type -/// Response type -public sealed class AuthorizationBehavior( - ICurrentUser currentUser, - ILogger> logger) - : IPipelineBehavior - where TRequest : notnull -{ - public async Task Handle(TRequest request, RequestHandlerDelegate next, CancellationToken cancellationToken) - { - if (request is not IAuthorizedRequest authorizedRequest) - { - return await next(cancellationToken); - } - - var requirement = authorizedRequest.AuthorizationRequirement; - - // Check authentication - if (requirement.RequireAuthentication && !currentUser.IsAuthenticated) - { - logger.LogWarning("Unauthorized access attempt for {RequestName}", typeof(TRequest).Name); - throw new UnauthorizedException("Authentication required"); - } - - // Check roles - if (requirement.Roles.Count > 0) - { - var hasRequiredRole = requirement.Roles.Any(role => - currentUser.Roles.Contains(role, StringComparer.OrdinalIgnoreCase)); - - if (!hasRequiredRole) - { - logger.LogWarning("Access denied for user {UserId} to {RequestName}. Required roles: {RequiredRoles}, User roles: {UserRoles}", - currentUser.UserId, typeof(TRequest).Name, requirement.Roles, currentUser.Roles); - throw new ForbiddenException($"Required roles: {string.Join(", ", requirement.Roles)}"); - } - } - - // Check permissions - if (requirement.Permissions.Length > 0) - { - var hasAllPermissions = requirement.Permissions.ToList().TrueForAll(permission => - currentUser.Permissions.Contains(permission, StringComparer.OrdinalIgnoreCase)); - - if (!hasAllPermissions) - { - var missingPermissions = requirement.Permissions.Except(currentUser.Permissions).ToList(); - logger.LogWarning("Access denied for user {UserId} to {RequestName}. Missing permissions: {MissingPermissions}", - currentUser.UserId, typeof(TRequest).Name, missingPermissions); - throw new ForbiddenException($"Missing permissions: {string.Join(", ", missingPermissions)}"); - } - } - - logger.LogDebug("Authorization passed for user {UserId} to {RequestName}", - currentUser.UserId, typeof(TRequest).Name); - - return await next(cancellationToken); - } -} diff --git a/src/Application/Common/Behaviors/CachingBehavior.cs b/src/Application/Common/Behaviors/CachingBehavior.cs index 9ef5964..f207478 100644 --- a/src/Application/Common/Behaviors/CachingBehavior.cs +++ b/src/Application/Common/Behaviors/CachingBehavior.cs @@ -1,3 +1,4 @@ +using Application.Common; using MediatR; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; @@ -53,21 +54,28 @@ public async Task Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate i.IsGenericType && i.GetGenericTypeDefinition() == typeof(ICommand<>)); diff --git a/src/Application/Common/Behaviors/LoggingBehavior.cs b/src/Application/Common/Behaviors/LoggingBehavior.cs index a32e9bd..468f7d8 100644 --- a/src/Application/Common/Behaviors/LoggingBehavior.cs +++ b/src/Application/Common/Behaviors/LoggingBehavior.cs @@ -1,3 +1,4 @@ +using Application.Common; using Application.Common.Exceptions; using MediatR; using Microsoft.Extensions.Logging; @@ -21,30 +22,24 @@ public async Task Handle(TRequest request, RequestHandlerDelegate Handle(TRequest request, RequestHandlerDelegate SlowRequestThresholdMs) + // Only log performance for successful requests + if (!response.IsFailure() && elapsedMilliseconds > SlowRequestThresholdMs) { var requestName = typeof(TRequest).Name; diff --git a/src/Application/Common/Behaviors/TransactionBehavior.cs b/src/Application/Common/Behaviors/TransactionBehavior.cs index 5a6c8f4..e537476 100644 --- a/src/Application/Common/Behaviors/TransactionBehavior.cs +++ b/src/Application/Common/Behaviors/TransactionBehavior.cs @@ -1,6 +1,7 @@ using Application.Common; using MediatR; using Microsoft.Extensions.Logging; +using Application.Common.Exceptions; namespace Application.Common.Behaviors; diff --git a/src/Application/Common/Exceptions/BusinessRuleException.cs b/src/Application/Common/Exceptions/BusinessRuleException.cs deleted file mode 100644 index 3e08ef4..0000000 --- a/src/Application/Common/Exceptions/BusinessRuleException.cs +++ /dev/null @@ -1,21 +0,0 @@ -namespace Application.Common.Exceptions; - -/// -/// Exception thrown when a business rule is violated -/// -public sealed class BusinessRuleException : ApplicationException -{ - /// - /// Business rule code for identification - /// - public string? RuleCode { get; } - - public BusinessRuleException(string message) : base(message) - { - } - - public BusinessRuleException(string ruleCode, string message) : base(message) - { - RuleCode = ruleCode; - } -} diff --git a/src/Application/Common/Exceptions/ConflictException.cs b/src/Application/Common/Exceptions/ConflictException.cs deleted file mode 100644 index c3530b6..0000000 --- a/src/Application/Common/Exceptions/ConflictException.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Application.Common.Exceptions; - -/// -/// Exception thrown when there is a conflict (e.g., duplicate data) -/// -public sealed class ConflictException : ApplicationException -{ - public ConflictException(string message) : base(message) - { - } - - public ConflictException(string name, object value) - : base($"Entity \"{name}\" with value \"{value}\" already exists.") - { - } -} diff --git a/src/Application/Common/Exceptions/RequestProcessingException.cs b/src/Application/Common/Exceptions/RequestProcessingException.cs deleted file mode 100644 index d76f9a7..0000000 --- a/src/Application/Common/Exceptions/RequestProcessingException.cs +++ /dev/null @@ -1,15 +0,0 @@ -namespace Application.Common.Exceptions; - -/// -/// Exception thrown when request processing fails in MediatR pipeline -/// -public sealed class RequestProcessingException : Exception -{ - public RequestProcessingException(string message) : base(message) - { - } - - public RequestProcessingException(string message, Exception innerException) : base(message, innerException) - { - } -} diff --git a/src/Application/Common/Exceptions/SecurityException.cs b/src/Application/Common/Exceptions/SecurityException.cs deleted file mode 100644 index c4d7898..0000000 --- a/src/Application/Common/Exceptions/SecurityException.cs +++ /dev/null @@ -1,16 +0,0 @@ -namespace Application.Common.Exceptions; - -/// -/// Exception thrown when security violations are detected -/// Used for token assassination and other security events -/// -public sealed class SecurityException : Exception -{ - public SecurityException(string message) : base(message) - { - } - - public SecurityException(string message, Exception innerException) : base(message, innerException) - { - } -} diff --git a/src/Application/Common/ResultExtensions.cs b/src/Application/Common/ResultExtensions.cs new file mode 100644 index 0000000..8cc1e73 --- /dev/null +++ b/src/Application/Common/ResultExtensions.cs @@ -0,0 +1,24 @@ +using Domain.Common; + +namespace Application.Common; + +/// +/// Extension methods for Result types +/// +public static class ResultExtensions +{ + /// + /// Check if response is a failed Result + /// + /// Response type + /// Response to check + /// True if response is a failed Result + public static bool IsFailure(this T response) + { + if (response is Result result && !result.IsSuccess) + { + return true; + } + return false; + } +} diff --git a/src/Application/Features/Auth/GetProfile/GetProfileQuery.cs b/src/Application/Features/Auth/GetProfile/GetProfileQuery.cs deleted file mode 100644 index 3551462..0000000 --- a/src/Application/Features/Auth/GetProfile/GetProfileQuery.cs +++ /dev/null @@ -1,61 +0,0 @@ -using Application.Common; -using Application.Common.Behaviors; -using Domain.Common; - -namespace Application.Features.Auth.GetProfile; - -/// -/// Get profile query with caching -/// -public sealed record GetProfileQuery : IQuery>, ICacheableQuery -{ - /// - /// User ID - /// - public required Guid UserId { get; init; } - - // ICacheableQuery implementation - public string CacheKey => $"user-profile:{UserId}"; - public TimeSpan? CacheExpiration => TimeSpan.FromMinutes(15); -} - -/// -/// Get profile response -/// -public sealed record GetProfileResponse -{ - /// - /// User ID - /// - public required Guid Id { get; init; } - - /// - /// Email address - /// - public required string Email { get; init; } - - /// - /// Full name - /// - public required string FullName { get; init; } - - /// - /// Phone number - /// - public string? PhoneNumber { get; init; } - - /// - /// User roles - /// - public required List Roles { get; init; } - - /// - /// Created date - /// - public required DateTime CreatedAt { get; init; } - - /// - /// Last updated date - /// - public DateTime? UpdatedAt { get; init; } -} diff --git a/src/Application/Features/Auth/GetProfile/GetProfileQueryHandler.cs b/src/Application/Features/Auth/GetProfile/GetProfileQueryHandler.cs deleted file mode 100644 index 73e7a03..0000000 --- a/src/Application/Features/Auth/GetProfile/GetProfileQueryHandler.cs +++ /dev/null @@ -1,25 +0,0 @@ -using Application.Common; -using Application.Interfaces.Repositories; -using Domain.Common; - -namespace Application.Features.Auth.GetProfile; - -/// -/// Get profile query handler -/// -#pragma warning disable CS9113 // Parameter is unread. -public sealed class GetProfileQueryHandler(IUserRepository userRepository) -#pragma warning restore CS9113 // Parameter is unread. - : IQueryHandler> -{ - public async Task> Handle(GetProfileQuery request, CancellationToken cancellationToken) - { - // TODO: Implement get profile logic - // 1. Retrieve user by ID - // 2. Map to GetProfileResponse - // 3. Return result - - await Task.Delay(1, cancellationToken); - throw new NotImplementedException("Get profile logic not implemented yet"); - } -} diff --git a/src/Application/Features/Auth/Login/LoginCommandHandler.cs b/src/Application/Features/Auth/Login/LoginCommandHandler.cs index 362653f..ad067b7 100644 --- a/src/Application/Features/Auth/Login/LoginCommandHandler.cs +++ b/src/Application/Features/Auth/Login/LoginCommandHandler.cs @@ -21,20 +21,23 @@ public sealed class LoginCommandHandler( public async Task> Handle(LoginCommand request, CancellationToken cancellationToken) { // Find user by email - var user = await userRepository.GetByEmailAsync(request.Email, cancellationToken) - ?? throw new UnauthorizedException("Invalid email or password."); + var user = await userRepository.GetByEmailAsync(request.Email, cancellationToken); + if (user is null) + { + return Result.Failure(Error.Unauthorized("Auth.InvalidCredentials", "Invalid email or password.")); + } // Check if user is active if (user.IsDeleted) { - throw new ForbiddenException("User account has been deactivated."); + return Result.Failure(Error.Forbidden("User.Deactivated", "User account has been deactivated.")); } // Verify password using Identity var isValidPassword = await authService.CheckPasswordAsync(request.Email, request.Password); if (!isValidPassword) { - throw new UnauthorizedException("Invalid email or password."); + return Result.Failure(Error.Unauthorized("Auth.InvalidCredentials", "Invalid email or password.")); } // Calculate token expirations based on RememberMe flag diff --git a/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommandHandler.cs b/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommandHandler.cs index fe2dfc0..f3347fd 100644 --- a/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommandHandler.cs +++ b/src/Application/Features/Auth/RefreshAccessToken/RefreshAccessTokenCommandHandler.cs @@ -29,8 +29,11 @@ public sealed class RefreshAccessTokenCommandHandler( public async Task> Handle(RefreshAccessTokenCommand request, CancellationToken cancellationToken) { - var storedRefreshToken = await _refreshTokenRepository.GetByTokenAsync(request.RefreshToken, cancellationToken) - ?? throw new UnauthorizedException("Invalid refresh token."); + var storedRefreshToken = await _refreshTokenRepository.GetByTokenAsync(request.RefreshToken, cancellationToken); + if (storedRefreshToken is null) + { + return Result.Failure(Error.Unauthorized("Auth.InvalidRefreshToken", "Invalid refresh token.")); + } if (storedRefreshToken.RevokedAt.HasValue) { @@ -40,17 +43,20 @@ public async Task> Handle(RefreshAccessTokenC await _unitOfWork.SaveChangesAsync(cancellationToken); // 2. Throw security exception to alert about potential hack - throw new SecurityException("Security violation detected. Cannot refresh access token."); + return Result.Failure(Error.Security("Auth.SecurityViolation", "Security violation detected. Cannot refresh access token.")); } // Check if token is expired (but not revoked) if (storedRefreshToken.IsExpired) { - throw new UnauthorizedException("Refresh token has expired."); + return Result.Failure(Error.Unauthorized("Auth.TokenExpired", "Refresh token has expired.")); } - var user = storedRefreshToken.User - ?? throw new UnauthorizedException("User account not found or deactivated."); + var user = storedRefreshToken.User; + if (user == null) + { + return Result.Failure(Error.Unauthorized("Auth.UserNotFound", "User account not found or deactivated.")); + } var nameParts = user.FullName.Split(' ', 2); var firstName = nameParts.Length > 0 ? nameParts[0] : user.FullName; diff --git a/src/Application/Features/Conversation/GetMessages/GetMessagesQuery.cs b/src/Application/Features/Conversation/GetMessages/GetMessagesQuery.cs index 3365025..63b9d6f 100644 --- a/src/Application/Features/Conversation/GetMessages/GetMessagesQuery.cs +++ b/src/Application/Features/Conversation/GetMessages/GetMessagesQuery.cs @@ -11,17 +11,13 @@ namespace Application.Features.Conversation.GetMessages; /// Query để lấy danh sách tin nhắn trong cuộc hội thoại /// Pattern: Query Pattern (CQRS) + Strategy Pattern (caching) /// -public sealed record GetMessagesQuery : PaginationRequest, IQuery>>, ICacheableQuery +public sealed record GetMessagesQuery : PaginationRequest, IQuery>> { /// /// ID của cuộc hội thoại /// [JsonIgnore] public Guid ConversationId { get; init; } - - // ICacheableQuery implementation - public string CacheKey => $"conversation-messages:{ConversationId}:{PageNumber}:{PageSize}"; - public TimeSpan? CacheExpiration => TimeSpan.FromMinutes(5); } /// diff --git a/src/Application/Features/User/GetUsers/GetUsersQuery.cs b/src/Application/Features/User/GetUsers/GetUsersQuery.cs index 77b9877..bf1ae4d 100644 --- a/src/Application/Features/User/GetUsers/GetUsersQuery.cs +++ b/src/Application/Features/User/GetUsers/GetUsersQuery.cs @@ -9,7 +9,7 @@ namespace Application.Features.User.GetUsers; /// Get users query with pagination, sorting, and caching /// public sealed record GetUsersQuery : PaginationRequest, IQuery>>, - ICacheableQuery, IAuthorizedRequest + ICacheableQuery { /// /// Search term to filter users @@ -29,13 +29,6 @@ public sealed record GetUsersQuery : PaginationRequest, IQuery $"users:{PageNumber}:{PageSize}:{SearchTerm}:{Role}:{IncludeDeleted}"; public TimeSpan? CacheExpiration => TimeSpan.FromMinutes(10); - - // IAuthorizedRequest implementation - public AuthorizationRequirement AuthorizationRequirement => new() - { - Roles = ["Admin", "Manager"], - RequireAuthentication = true - }; } /// diff --git a/src/Domain/Common/Error.cs b/src/Domain/Common/Error.cs index 0b169bc..695212a 100644 --- a/src/Domain/Common/Error.cs +++ b/src/Domain/Common/Error.cs @@ -21,27 +21,75 @@ public Error(string code, string description, ErrorType type) public ErrorType Type { get; } + /// + /// Dùng cho các trường hợp Lỗi Nghiệp Vụ (Business Logic): dữ liệu đúng định dạng nhưng không thỏa mãn business logic + /// + /// + /// + /// + public static Error Problem(string code, string description) => + new(code, description, ErrorType.Problem); + + /// + /// Ví dụ lỗi 500 Internal Server Error + /// + /// + /// + /// public static Error Failure(string code, string description) => new(code, description, ErrorType.Failure); + /// + /// Dùng cho các trường hợp Lỗi Không Tìm Thấy (NotFound): dữ liệu không tồn tại + /// + /// + /// + /// public static Error NotFound(string code, string description) => new(code, description, ErrorType.NotFound); - public static Error Problem(string code, string description) => - new(code, description, ErrorType.Problem); - + /// + /// Dùng cho các trường hợp Lỗi Xung Đột (Conflict): dữ liệu đã tồn tại + /// + /// + /// + /// public static Error Conflict(string code, string description) => new(code, description, ErrorType.Conflict); /// - /// Creates an error for authentication/authorization issues (401/403) + /// Dùng cho các trường hợp Lỗi Validation: dữ liệu không hợp lệ + /// + /// + /// + /// + public static Error Validation(string code, string description) => + new(code, description, ErrorType.Validation); + + /// + /// Dùng cho các trường hợp Lỗi Unauthorized: người dùng không được phép truy cập /// + /// + /// + /// public static Error Unauthorized(string code, string description) => - new(code, description, ErrorType.Failure); + new(code, description, ErrorType.Unauthorized); /// - /// Creates an error for validation issues (400) + /// Dùng cho các trường hợp Lỗi Security: vi phạm bảo mật /// - public static Error Validation(string code, string description) => - new(code, description, ErrorType.Problem); + /// + /// + /// + public static Error Security(string code, string description) => + new(code, description, ErrorType.Security); + + /// + /// Dùng cho các trường hợp Lỗi Forbidden: người dùng không được phép truy cập + /// + /// + /// + /// + public static Error Forbidden(string code, string description) => + new(code, description, ErrorType.Forbidden); } diff --git a/src/Domain/Common/ErrorType.cs b/src/Domain/Common/ErrorType.cs index ff8f68b..1ff2732 100644 --- a/src/Domain/Common/ErrorType.cs +++ b/src/Domain/Common/ErrorType.cs @@ -11,5 +11,8 @@ public enum ErrorType Validation = 1, Problem = 2, NotFound = 3, - Conflict = 4 + Conflict = 4, + Unauthorized = 5, + Forbidden = 6, + Security = 7 } diff --git a/src/Domain/Common/Result.cs b/src/Domain/Common/Result.cs index 69e0c50..e6ac1e6 100644 --- a/src/Domain/Common/Result.cs +++ b/src/Domain/Common/Result.cs @@ -50,7 +50,4 @@ public Result(TValue? value, bool isSuccess, Error error) public static implicit operator Result(TValue? value) => value is not null ? Success(value) : Failure(Error.NullValue); - - public static Result ValidationFailure(Error error) => - new(default, false, error); } diff --git a/src/Web.Api/Configurations/Options/JwtOptions.cs b/src/Web.Api/Configurations/Options/JwtOptions.cs deleted file mode 100644 index 07b2c24..0000000 --- a/src/Web.Api/Configurations/Options/JwtOptions.cs +++ /dev/null @@ -1,37 +0,0 @@ -namespace Web.Api.Configurations.Options; - -/// -/// JWT configuration options -/// -public sealed class JwtOptions -{ - /// - /// Configuration section name - /// - public const string SectionName = "Jwt"; - - /// - /// JWT secret key - /// - public string SecretKey { get; init; } = string.Empty; - - /// - /// JWT issuer - /// - public string Issuer { get; init; } = string.Empty; - - /// - /// JWT audience - /// - public string Audience { get; init; } = string.Empty; - - /// - /// Access token expiration time in minutes - /// - public int AccessTokenExpirationMinutes { get; init; } = 60; - - /// - /// Refresh token expiration time in days - /// - public int RefreshTokenExpirationDays { get; init; } = 7; -} diff --git a/src/Web.Api/Controllers/V1/AuthController.cs b/src/Web.Api/Controllers/V1/AuthController.cs index 91d217b..e0e0022 100644 --- a/src/Web.Api/Controllers/V1/AuthController.cs +++ b/src/Web.Api/Controllers/V1/AuthController.cs @@ -30,13 +30,7 @@ public sealed class AuthController(IMediator mediator) : BaseController public async Task Login([FromBody] LoginCommand command) { var result = await mediator.Send(command); - - if (result.IsSuccess) - { - return Ok(result.Value); - } - - return BadRequest(ApiResponse.CreateFailure(result.Error.Description)); + return HandleResult(result); } /// @@ -51,17 +45,7 @@ public async Task Login([FromBody] LoginCommand command) public async Task Register([FromBody] RegisterCommand command) { var result = await mediator.Send(command); - - if (result.IsSuccess) - { - return Ok(result.Value!); - } - - return result.Error!.Type switch - { - ErrorType.Conflict => Conflict(ApiResponse.CreateFailure(result.Error.Description)), - _ => BadRequest(ApiResponse.CreateFailure(result.Error.Description)) - }; + return HandleResult(result); } /// @@ -76,13 +60,7 @@ public async Task Register([FromBody] RegisterCommand command) public async Task LoginExternal([FromBody] LoginExternalCommand command) { var result = await mediator.Send(command); - - if (result.IsSuccess) - { - return Ok(result.Value); - } - - return BadRequest(ApiResponse.CreateFailure(result.Error!.Description)); + return HandleResult(result); } /// @@ -104,17 +82,7 @@ public async Task VerifyEmail([FromQuery] Guid userId, [FromQuery }; var result = await mediator.Send(command); - - if (result.IsSuccess) - { - return Ok(ApiResponse.CreateSuccess("Email verified successfully! You can now log in.")); - } - - return result.Error!.Type switch - { - ErrorType.NotFound => NotFound(ApiResponse.CreateFailure(result.Error.Description)), - _ => BadRequest(ApiResponse.CreateFailure(result.Error.Description)) - }; + return HandleResult(result); } /// @@ -130,14 +98,6 @@ public async Task VerifyEmail([FromQuery] Guid userId, [FromQuery public async Task RefreshToken([FromBody] RefreshAccessTokenCommand command) { var result = await mediator.Send(command); - - if (result.IsSuccess) - { - return Ok(ApiResponse.CreateSuccess( - result.Value!, - "Tokens refreshed successfully")); - } - - return BadRequest(ApiResponse.CreateFailure(result.Error!.Description)); + return HandleResult(result); } } diff --git a/src/Web.Api/Controllers/V1/BaseController.cs b/src/Web.Api/Controllers/V1/BaseController.cs index 93f9a97..a984c29 100644 --- a/src/Web.Api/Controllers/V1/BaseController.cs +++ b/src/Web.Api/Controllers/V1/BaseController.cs @@ -1,49 +1,58 @@ +using MediatR; using Microsoft.AspNetCore.Mvc; +using Domain.Common; +using Web.Api.Models.Responses; namespace Web.Api.Controllers.V1; -/// -/// Base controller cho tất cả API controllers version 1 -/// [ApiController] [ApiVersion("1.0")] [Route("api/v{version:apiVersion}/[controller]")] [Produces("application/json")] public abstract class BaseController : ControllerBase { - protected BaseController() + private ISender? _sender; + protected ISender Sender => _sender ??= HttpContext.RequestServices.GetRequiredService(); + + protected IActionResult HandleResult(Result result) { + return result.IsSuccess + ? Ok(ApiResponse.CreateSuccess(result.Value)) + : HandleFailure(result.Error); } - /// - /// Tạo response cho success result - /// - /// Type of data - /// Data to return - /// Success response - protected IActionResult Ok(T data) + protected IActionResult HandleResult(Result result) { - return base.Ok(new - { - Success = true, - Data = data, - Message = "Request completed successfully" - }); + return result.IsSuccess + ? Ok(ApiResponse.CreateSuccess()) + : HandleFailure(result.Error); } - /// - /// Tạo response cho error result - /// - /// Error message - /// HTTP status code - /// Error response - protected IActionResult Error(string message, int statusCode = 400) + private ObjectResult HandleFailure(Error error) { - return StatusCode(statusCode, new + var statusCode = error.Type switch + { + ErrorType.Validation => StatusCodes.Status400BadRequest, + ErrorType.NotFound => StatusCodes.Status404NotFound, + ErrorType.Conflict => StatusCodes.Status409Conflict, + + ErrorType.Problem => StatusCodes.Status400BadRequest, + ErrorType.Failure => StatusCodes.Status400BadRequest, + + ErrorType.Unauthorized => StatusCodes.Status401Unauthorized, + ErrorType.Forbidden => StatusCodes.Status403Forbidden, + ErrorType.Security => StatusCodes.Status401Unauthorized, + + _ => StatusCodes.Status500InternalServerError + }; + + var errorInfo = new ErrorInfo { - Success = false, - Message = message, - Data = (object?)null - }); + Type = error.Code, + Details = error.Description, + ValidationErrors = null + }; + + return StatusCode(statusCode, ApiResponse.CreateFailure(error.Description, errorInfo)); } } diff --git a/src/Web.Api/Controllers/V1/ConversationController.cs b/src/Web.Api/Controllers/V1/ConversationController.cs index de12a35..75e28f3 100644 --- a/src/Web.Api/Controllers/V1/ConversationController.cs +++ b/src/Web.Api/Controllers/V1/ConversationController.cs @@ -31,13 +31,7 @@ public class ConversationController(IMediator mediator) : BaseController public async Task GetHistories([FromQuery] GetHistoriesRequest request) { var result = await _mediator.Send(request); - - if (result.IsSuccess) - { - return Ok(result.Value); - } - - return BadRequest(ApiResponse.CreateFailure(result.Error!.Description)); + return HandleResult(result); } /// @@ -54,13 +48,7 @@ public async Task GetMessages(Guid id, [FromQuery] GetMessagesQue { var fullQuery = query with { ConversationId = id }; var result = await _mediator.Send(fullQuery); - - if (result.IsSuccess) - { - return Ok(result.Value); - } - - return BadRequest(ApiResponse.CreateFailure(result.Error!.Description)); + return HandleResult(result); } /// @@ -75,13 +63,7 @@ public async Task GetMessages(Guid id, [FromQuery] GetMessagesQue public async Task SendMessage([FromBody] SendMessageCommand command) { var result = await _mediator.Send(command); - - if (result.IsSuccess) - { - return Ok(result.Value); - } - - return BadRequest(ApiResponse.CreateFailure(result.Error!.Description)); + return HandleResult(result); } /// @@ -98,13 +80,7 @@ public async Task UpdateTitle(Guid id, [FromBody] UpdateTitleComm { var fullCommand = command with { ConversationId = id }; var result = await _mediator.Send(fullCommand); - - if (result.IsSuccess) - { - return Ok(result.Value); - } - - return BadRequest(ApiResponse.CreateFailure(result.Error!.Description)); + return HandleResult(result); } /// @@ -120,13 +96,7 @@ public async Task ToggleStar(Guid id) { var command = new ToggleStarCommand { ConversationId = id }; var result = await _mediator.Send(command); - - if (result.IsSuccess) - { - return Ok(result.Value); - } - - return BadRequest(ApiResponse.CreateFailure(result.Error!.Description)); + return HandleResult(result); } /// @@ -142,12 +112,6 @@ public async Task DeleteConversation(Guid id) { var command = new DeleteConversationCommand { ConversationId = id }; var result = await _mediator.Send(command); - - if (result.IsSuccess) - { - return Ok(result.Value); - } - - return BadRequest(ApiResponse.CreateFailure(result.Error!.Description)); + return HandleResult(result); } } diff --git a/src/Web.Api/Controllers/V1/UsersController.cs b/src/Web.Api/Controllers/V1/UsersController.cs index 4672949..ac72653 100644 --- a/src/Web.Api/Controllers/V1/UsersController.cs +++ b/src/Web.Api/Controllers/V1/UsersController.cs @@ -1,6 +1,7 @@ using Application.Common.Models; using Application.Features.User.GetUsers; using MediatR; +using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Web.Api.Models.Responses; @@ -10,6 +11,7 @@ namespace Web.Api.Controllers.V1; /// Users management controller /// [Route("api/v{version:apiVersion}/users")] +[Authorize(Roles = "Admin,Manager")] public sealed class UsersController(IMediator mediator) : BaseController { /// diff --git a/src/Web.Api/Extensions/ApplicationServiceExtensions.cs b/src/Web.Api/Extensions/ApplicationServiceExtensions.cs index 175a621..9025128 100644 --- a/src/Web.Api/Extensions/ApplicationServiceExtensions.cs +++ b/src/Web.Api/Extensions/ApplicationServiceExtensions.cs @@ -3,7 +3,6 @@ using FluentValidation; using MediatR; using System.Reflection; -using Web.Api.Services; namespace Web.Api.Extensions; @@ -28,7 +27,6 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection // Add MediatR Pipeline Behaviors (order matters!) services.AddScoped(typeof(IPipelineBehavior<,>), typeof(LoggingBehavior<,>)); services.AddScoped(typeof(IPipelineBehavior<,>), typeof(PerformanceBehavior<,>)); - services.AddScoped(typeof(IPipelineBehavior<,>), typeof(AuthorizationBehavior<,>)); services.AddScoped(typeof(IPipelineBehavior<,>), typeof(ValidationBehavior<,>)); services.AddScoped(typeof(IPipelineBehavior<,>), typeof(CachingBehavior<,>)); services.AddScoped(typeof(IPipelineBehavior<,>), typeof(DomainEventBehavior<,>)); @@ -36,7 +34,6 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection // Add Application services services.AddHttpContextAccessor(); - services.AddScoped(); services.AddMemoryCache(); return services; diff --git a/src/Web.Api/Extensions/AuthenticationExtensions.cs b/src/Web.Api/Extensions/AuthenticationExtensions.cs index 09b9ed3..457354d 100644 --- a/src/Web.Api/Extensions/AuthenticationExtensions.cs +++ b/src/Web.Api/Extensions/AuthenticationExtensions.cs @@ -1,6 +1,9 @@ using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.IdentityModel.Tokens; using System.Text; +using System.Text.Json; +using Web.Api.Models.Responses; +using Web.Api.Shared; namespace Web.Api.Extensions; @@ -9,6 +12,7 @@ namespace Web.Api.Extensions; /// public static class AuthenticationExtensions { + /// /// Add JWT Bearer authentication /// @@ -37,6 +41,47 @@ public static IServiceCollection AddJwtAuthentication( ValidateLifetime = true, ClockSkew = TimeSpan.Zero }; + + // Custom response for JWT authentication failures + options.Events = new JwtBearerEvents + { + OnChallenge = async context => + { + // Prevent default response + context.HandleResponse(); + + context.Response.StatusCode = 401; + context.Response.ContentType = "application/json"; + + var error = new ErrorInfo + { + Type = "AuthenticationRequired", + Details = "You must be authenticated to access this resource" + }; + + var response = ApiResponse.CreateFailure("Authentication required", error); + var json = JsonSerializer.Serialize(response, JsonOptions.Default); + + await context.Response.WriteAsync(json); + }, + + OnForbidden = async context => + { + context.Response.StatusCode = 403; + context.Response.ContentType = "application/json"; + + var error = new ErrorInfo + { + Type = "InsufficientPermissions", + Details = "You don't have permission to access this resource" + }; + + var response = ApiResponse.CreateFailure("Insufficient permissions", error); + var json = JsonSerializer.Serialize(response, JsonOptions.Default); + + await context.Response.WriteAsync(json); + } + }; }); services.AddAuthorization(); diff --git a/src/Web.Api/Filters/AuthorizationLoggingFilter.cs b/src/Web.Api/Filters/AuthorizationLoggingFilter.cs new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/src/Web.Api/Filters/AuthorizationLoggingFilter.cs @@ -0,0 +1 @@ + diff --git a/src/Web.Api/Filters/CustomAuthorizeFilter.cs b/src/Web.Api/Filters/CustomAuthorizeFilter.cs new file mode 100644 index 0000000..260456f --- /dev/null +++ b/src/Web.Api/Filters/CustomAuthorizeFilter.cs @@ -0,0 +1,69 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc.Filters; +using Microsoft.Extensions.Logging; +using System.Net; +using Web.Api.Models.Responses; + +namespace Web.Api.Filters; + +/// +/// Custom authorization filter to provide consistent API responses for authorization failures +/// +public sealed class CustomAuthorizeFilter(ILogger logger) : IAuthorizationFilter +{ + public void OnAuthorization(AuthorizationFilterContext context) + { + // If authorization failed, replace default response with custom API response + if (context.Result != null) + { + var user = context.HttpContext.User; + var endpoint = context.HttpContext.GetEndpoint()?.DisplayName ?? "Unknown"; + var method = context.HttpContext.Request.Method; + var path = context.HttpContext.Request.Path; + + if (context.Result is ChallengeResult) + { + logger.LogWarning( + "Authentication required: User is not authenticated. Endpoint: {Endpoint}, Method: {Method}, Path: {Path}", + endpoint, method, path); + + var error = new ErrorInfo + { + Type = "AuthenticationRequired", + Details = "You must be authenticated to access this resource" + }; + + var response = ApiResponse.CreateFailure("Authentication required", error); + + context.Result = new JsonResult(response) + { + StatusCode = (int)HttpStatusCode.Unauthorized + }; + } + else if (context.Result is ForbidResult) + { + // User is authenticated but not authorized (403) + var userId = user.Identity?.IsAuthenticated == true ? user.Identity.Name : "Anonymous"; + var roles = user.Claims.Where(c => c.Type.Contains("role")).Select(c => c.Value); + + logger.LogWarning( + "Authorization failed: User does not have required permissions. User: {UserId}, Roles: {Roles}, Endpoint: {Endpoint}, Method: {Method}, Path: {Path}", + userId, string.Join(",", roles), endpoint, method, path); + + var error = new ErrorInfo + { + Type = "InsufficientPermissions", + Details = "You don't have permission to access this resource" + }; + + var response = ApiResponse.CreateFailure("Insufficient permissions", error); + + context.Result = new JsonResult(response) + { + StatusCode = (int)HttpStatusCode.Forbidden + }; + } + } + } +} diff --git a/src/Web.Api/Middleware/GlobalExceptionMiddleware.cs b/src/Web.Api/Middleware/GlobalExceptionMiddleware.cs index 49b29a7..cdf8733 100644 --- a/src/Web.Api/Middleware/GlobalExceptionMiddleware.cs +++ b/src/Web.Api/Middleware/GlobalExceptionMiddleware.cs @@ -2,6 +2,7 @@ using System.Text.Json; using Application.Common.Exceptions; using Microsoft.AspNetCore.Http; +using Web.Api.Shared; namespace Web.Api.Middleware; @@ -12,10 +13,6 @@ public sealed class GlobalExceptionMiddleware { private readonly RequestDelegate _next; private readonly ILogger _logger; - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase - }; public GlobalExceptionMiddleware(RequestDelegate next, ILogger logger) { @@ -40,14 +37,7 @@ private static async Task HandleExceptionAsync(HttpContext context, Exception ex { context.Response.ContentType = "application/json"; - // Unwrap inner exception if it's RequestProcessingException - var actualException = exception; - if (exception is RequestProcessingException && exception.InnerException != null) - { - actualException = exception.InnerException; - } - - var (statusCode, message, errorDetails) = GetExceptionDetails(actualException); + var (statusCode, message, errorDetails) = GetExceptionDetails(exception); var response = new { @@ -59,7 +49,7 @@ private static async Task HandleExceptionAsync(HttpContext context, Exception ex context.Response.StatusCode = statusCode; - var jsonResponse = JsonSerializer.Serialize(response, JsonOptions); + var jsonResponse = JsonSerializer.Serialize(response, JsonOptions.Default); await context.Response.WriteAsync(jsonResponse); } @@ -99,16 +89,6 @@ private static (int StatusCode, string Message, object ErrorDetails) GetExceptio } ), - SecurityException securityEx => ( - (int)HttpStatusCode.Unauthorized, - securityEx.Message, - new - { - Type = "SecurityError", - Details = securityEx.InnerException?.Message ?? securityEx.Message - } - ), - NotFoundException notFoundEx => ( (int)HttpStatusCode.NotFound, notFoundEx.Message, @@ -119,26 +99,6 @@ private static (int StatusCode, string Message, object ErrorDetails) GetExceptio } ), - ConflictException conflictEx => ( - (int)HttpStatusCode.Conflict, - conflictEx.Message, - new - { - Type = "ConflictError", - Details = conflictEx.InnerException?.Message ?? conflictEx.Message - } - ), - - BusinessRuleException businessEx => ( - (int)HttpStatusCode.BadRequest, - businessEx.Message, - new - { - Type = "BusinessRuleError", - Details = businessEx.InnerException?.Message ?? businessEx.Message - } - ), - ExternalServiceException externalEx => ( (int)HttpStatusCode.ServiceUnavailable, "External service error", diff --git a/src/Web.Api/Program.cs b/src/Web.Api/Program.cs index b278df3..568a713 100644 --- a/src/Web.Api/Program.cs +++ b/src/Web.Api/Program.cs @@ -6,6 +6,7 @@ using Serilog; using Web.Api.Converters; using Web.Api.Extensions; +using Web.Api.Filters; using Web.Api.Middleware; // Configure Serilog @@ -22,7 +23,11 @@ builder.Services.AddHttpContextAccessor(); // Add services to the container. -builder.Services.AddControllers() +builder.Services.AddControllers(options => + { + // Add custom authorization filter globally for consistent API responses + options.Filters.Add(); + }) .AddJsonOptions(options => { // Add custom converter for ExternalProvider enum diff --git a/src/Web.Api/Services/CurrentUserService.cs b/src/Web.Api/Services/CurrentUserService.cs deleted file mode 100644 index 196149e..0000000 --- a/src/Web.Api/Services/CurrentUserService.cs +++ /dev/null @@ -1,91 +0,0 @@ -using Application.Common.Behaviors; -using System.Security.Claims; - -namespace Web.Api.Services; - -/// -/// Current user service implementation -/// -public sealed class CurrentUserService : ICurrentUser -{ - private readonly IHttpContextAccessor _httpContextAccessor; - - public CurrentUserService(IHttpContextAccessor httpContextAccessor) - { - _httpContextAccessor = httpContextAccessor; - } - - public Guid? UserId - { - get - { - var userIdClaim = _httpContextAccessor.HttpContext?.User?.FindFirstValue(ClaimTypes.NameIdentifier); - if (string.IsNullOrEmpty(userIdClaim)) - { - return null; - } - return Guid.TryParse(userIdClaim, out var userId) ? userId : null; - } - } - - public bool IsAuthenticated => _httpContextAccessor.HttpContext?.User?.Identity?.IsAuthenticated ?? false; - - public List Roles => _httpContextAccessor.HttpContext?.User?.FindAll(ClaimTypes.Role) - .Select(c => c.Value) - .ToList() ?? []; - - public List Permissions - { - get - { - // TODO: Implement permissions from claims or external service - // For now, derive from roles - var roles = Roles; - var permissions = new List(); - - foreach (var role in roles) - { - permissions.AddRange(GetPermissionsForRole(role)); - } - - return permissions.Distinct().ToList(); - } - } - - /// - /// Get permissions for a specific role - /// - private static List GetPermissionsForRole(string role) - { - return role.ToUpperInvariant() switch - { - "ADMIN" => [ - "users.create", "users.read", "users.update", "users.delete", - "conversations.create", "conversations.read", "conversations.update", "conversations.delete", - "messages.create", "messages.read", "messages.update", "messages.delete", - "system.manage" - ], - "MANAGER" => [ - "users.read", "users.update", - "conversations.create", "conversations.read", "conversations.update", - "messages.create", "messages.read", "messages.update", - "reports.read" - ], - "LEGALEXPERT" => [ - "conversations.create", "conversations.read", "conversations.update", - "messages.create", "messages.read", "messages.update", - "legal.advise" - ], - "PREMIUM" => [ - "conversations.create", "conversations.read", - "messages.create", "messages.read", - "premium.features" - ], - "USER" => [ - "conversations.create", "conversations.read", - "messages.create", "messages.read" - ], - _ => [] - }; - } -} diff --git a/src/Web.Api/Shared/JsonOptions.cs b/src/Web.Api/Shared/JsonOptions.cs new file mode 100644 index 0000000..af09c7c --- /dev/null +++ b/src/Web.Api/Shared/JsonOptions.cs @@ -0,0 +1,14 @@ +using System.Text.Json; + +namespace Web.Api.Shared; + +/// +/// Shared JSON serializer options for consistent API responses +/// +public static class JsonOptions +{ + public static readonly JsonSerializerOptions Default = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase + }; +}