diff --git a/FileService/FileService.Api/Controllers/FileDocumentController.cs b/FileService/FileService.Api/Controllers/FileDocumentController.cs index 8fdf4924..e3ed9de4 100644 --- a/FileService/FileService.Api/Controllers/FileDocumentController.cs +++ b/FileService/FileService.Api/Controllers/FileDocumentController.cs @@ -7,9 +7,10 @@ namespace FileService.Api.Controllers { - // [Authorize] - // [Authorize(Policy = "Permission")] - // [Authorize(Roles = "ADMIN")] + // [Authorize] + // [Authorize(Policy = "AdminOnly")] + // [Authorize(Roles = "ADMIN")] + // [AllowAnonymous] [Route("file/api/[controller]")] [ApiController] public class FileDocumentController : BaseApiController diff --git a/FileService/FileService.Api/Middlewares/ExceptionMiddleware.cs b/FileService/FileService.Api/Middlewares/ExceptionMiddleware.cs index 7500690f..daf87928 100644 --- a/FileService/FileService.Api/Middlewares/ExceptionMiddleware.cs +++ b/FileService/FileService.Api/Middlewares/ExceptionMiddleware.cs @@ -1,9 +1,11 @@ using FileService.Core.ApiModels; +using FileService.Core.Enums; using FileService.Core.Exceptions; using Newtonsoft.Json; using Newtonsoft.Json.Serialization; using System.Data; using System.Net; +using System.Security; using System.Text; namespace FileService.Api.Middlewares @@ -31,6 +33,7 @@ public async Task InvokeAsync(HttpContext httpContext) // await LogResuest(httpContext); await _next(httpContext); + // log the outgoing response await LogResponse(httpContext); } @@ -38,7 +41,11 @@ public async Task InvokeAsync(HttpContext httpContext) { await HandleCustomExceptionAsync(httpContext, ex); } - catch(UnauthorizedAccessException ex) + catch (SecurityException ex) // Thêm cho 403 + { + await HandleForbiddenExceptionAsync(httpContext, ex); + } + catch (UnauthorizedAccessException ex) { await HandleUnauthorizedExceptionAsync(httpContext, ex); } @@ -55,6 +62,23 @@ public async Task InvokeAsync(HttpContext httpContext) } } + private async Task HandleForbiddenExceptionAsync(HttpContext context, SecurityException exception) + { + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)HttpStatusCode.Forbidden; + var apiResponse = new ApiResponseModel(StatusCodeEnum.Forbidden) + { + Message = "Access denied due to insufficient permissions.", + Result = null + }; + var jsonResponse = JsonConvert.SerializeObject(apiResponse, new JsonSerializerSettings + { + ContractResolver = new CamelCasePropertyNamesContractResolver() + }); + await context.Response.WriteAsync(jsonResponse); + _logger.LogWarning("Forbidden access - {Message}", exception.Message); + } + private async Task HandleCustomExceptionAsync(HttpContext context, ErrorException exception) { diff --git a/FileService/FileService.Api/Middlewares/RoleCheckMiddleware.cs b/FileService/FileService.Api/Middlewares/RoleCheckMiddleware.cs new file mode 100644 index 00000000..9e288733 --- /dev/null +++ b/FileService/FileService.Api/Middlewares/RoleCheckMiddleware.cs @@ -0,0 +1,117 @@ +using FileService.Core.ApiModels; +using FileService.Core.Enums; +using Microsoft.AspNetCore.Authorization; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.IdentityModel.Tokens; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + +namespace FileService.Api.Middlewares +{ + public class RoleCheckMiddleware + { + private readonly RequestDelegate _next; + private readonly ILogger _logger; + private readonly IMemoryCache _cache; + + public RoleCheckMiddleware(RequestDelegate next, ILogger logger, IMemoryCache cache) + { + _next = next; + _logger = logger; + _cache = cache; + } + + public async Task InvokeAsync(HttpContext context) + { + // Bỏ qua middleware nếu endpoint có [AllowAnonymous] + var endpoint = context.GetEndpoint(); + if (endpoint?.Metadata?.GetMetadata() != null) + { + _logger.LogDebug("Bypassing RoleCheckMiddleware for anonymous endpoint: {Path}", context.Request.Path); + await _next(context); + return; + } + + // Lấy token từ header + var token = context.Request.Headers["Authorization"].FirstOrDefault()?.Split(" ").Last(); + if (string.IsNullOrEmpty(token)) + { + _logger.LogWarning("Missing token in request to {Path}", context.Request.Path); + await WriteError(context, StatusCodeEnum.Unauthorized, "Missing token"); + return; + } + + try + { + // Kiểm tra cache trước khi decode token + var cacheKey = $"claims_{token}"; + List claims; + if (!_cache.TryGetValue(cacheKey, out claims)) + { + var tokenHandler = new JwtSecurityTokenHandler(); + var jwtToken = tokenHandler.ReadJwtToken(token); + + var roles = jwtToken.Claims + .Where(c => c.Type == "role" || c.Type == "roles") + .Select(c => c.Value) + .ToList(); + _logger.LogInformation("Roles extracted from token: {Roles}", string.Join(", ", roles)); + + if (!roles.Any()) + { + await WriteError(context, StatusCodeEnum.Forbidden, "No roles found in token"); + return; + } + claims = jwtToken.Claims.ToList(); + _cache.Set(cacheKey, claims, TimeSpan.FromMinutes(5)); + } + else + { + // Check roles again from cached claims + var roles = claims.Where(c => c.Type == "role" || c.Type == "roles").Select(c => c.Value).ToList(); + _logger.LogInformation("Roles extracted from cached claims: {Roles}", string.Join(", ", roles)); + if (!roles.Any()) + { + _logger.LogWarning("No roles found in cached token claims for request to {Path}", context.Request.Path); + await WriteError(context, StatusCodeEnum.Forbidden, "No roles found in cached token claims"); + return; + } + } + + // Gắn claims vào context.User + context.User = new ClaimsPrincipal(new ClaimsIdentity(claims, "jwt")); + _logger.LogInformation("Passing RoleCheckMiddleware successfully for {Path}", context.Request.Path); + + await _next(context); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error processing token for request to {Path}", context.Request.Path); + await WriteError(context, StatusCodeEnum.Unauthorized, "Invalid token format"); + } + } + + private async Task WriteError(HttpContext context, StatusCodeEnum status, string message) + { + context.Response.StatusCode = (int)status; + context.Response.ContentType = "application/json"; + + var response = new ApiResponseModel(status) + { + Result = null, + Message = message + }; + + await context.Response.WriteAsJsonAsync(response); + } + } + + public static class RoleCheckMiddlewareExtensions + { + public static IApplicationBuilder UseRoleCheck(this IApplicationBuilder builder) + { + return builder.UseMiddleware(); + } + } +} diff --git a/FileService/FileService.Api/Program.cs b/FileService/FileService.Api/Program.cs index 1759da09..e138bf37 100644 --- a/FileService/FileService.Api/Program.cs +++ b/FileService/FileService.Api/Program.cs @@ -19,6 +19,7 @@ using FileService.Service.Interfaces; using Microsoft.Extensions.FileProviders; using System.Security.Claims; +using Microsoft.AspNetCore.DataProtection; BsonSerializer.RegisterSerializer(new GuidSerializer(GuidRepresentation.Standard)); @@ -40,7 +41,6 @@ var env = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT"); Console.WriteLine($"ASPNETCORE_ENVIRONMENT: {env}"); -//builder.Services.Configure(builder.Configuration.GetSection("MinioConfig")); builder.Services.Configure(builder.Configuration.GetSection("MongoDbSettings")); builder.Services.Configure(builder.Configuration.GetSection("FfmpegSettings")); @@ -84,6 +84,14 @@ options.Password.RequiredUniqueChars = 1; }); +// Add Data Protection with persistence and encryption +builder.Services.AddDataProtection() + .PersistKeysToFileSystem(new DirectoryInfo("/root/.aspnet/DataProtection-Keys")) + .ProtectKeysWithDpapi() // Use DPAPI for Windows compatibility + .SetApplicationName("FileService"); + + +//add authen builder.Services.AddAuthentication(options => { options.DefaultAuthenticateScheme = JwtBearerDefaults.AuthenticationScheme; @@ -93,18 +101,34 @@ { options.TokenValidationParameters = new TokenValidationParameters { - ValidateIssuer = true, - ValidateAudience = true, - ValidateLifetime = true, + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = false, ValidateIssuerSigningKey = true, ValidIssuer = appSettings.Jwt.Issuer, - ValidAudience = appSettings.Jwt.Audience, + // ValidAudience = appSettings.Jwt.Audience, IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(appSettings.Jwt.Key)), // Cấu hình claim để nhận Role - RoleClaimType = ClaimTypes.Role - + // RoleClaimType = "roles" }; + options.TokenValidationParameters.RoleClaimType = "roles"; // Khớp với token +}); + + +// Thêm MemoryCache để cache claims +builder.Services.AddMemoryCache(); + +//add author policies +builder.Services.AddAuthorization(options => +{ + options.AddPolicy("AdminOnly", policy => policy.RequireRole("ADMIN")); + options.AddPolicy("TeacherOrAdmin", policy => policy.RequireRole("TEACHER", "SYS_ADMIN")); + options.AddPolicy("SysAdminOnly", policy => policy.RequireRole("SYS_ADMIN")); + options.AddPolicy("OrgAdminOnly", policy => policy.RequireRole("ORG_ADMIN")); + options.AddPolicy("TeacherOnly", policy => policy.RequireRole("TEACHER")); + options.AddPolicy("UserOnly", policy => policy.RequireRole("USER")); + options.AddPolicy("LoggedInUsers", policy => policy.RequireRole("USER", "TEACHER", "ORG_ADMIN", "SYS_ADMIN")); }); builder.Services.AddCors(options => @@ -164,6 +188,15 @@ options.Limits.MaxRequestBodySize = 6L * 1024 * 1024 * 1024; // 6GB }); +// Configure HTTP client for MinIO with SSL handling +builder.Services.AddHttpClient("MinioClient").ConfigurePrimaryHttpMessageHandler(() => +{ + return new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (sender, cert, chain, sslPolicyErrors) => true // Temporary for dev/staging, remove in production + }; +}); + var app = builder.Build(); @@ -183,10 +216,14 @@ app.UseHttpsRedirection(); app.UseAuthentication(); +// Middleware pipeline +//app.UseRoleCheck(); app.UseAuthorization(); -app.UseMiddleware(); + app.UseMiddleware(); +app.UseMiddleware(); +//app.UseMiddleware(); app.UseMiddleware(); app.MapControllers(); diff --git a/FileService/FileService.Api/appsettings.Development.json b/FileService/FileService.Api/appsettings.Development.json index 3bdd1c33..03c99548 100644 --- a/FileService/FileService.Api/appsettings.Development.json +++ b/FileService/FileService.Api/appsettings.Development.json @@ -23,7 +23,7 @@ "Jwt": { "Key": "1TjXchw5FloESb63Kc+DFhTARvpWL4jUGCwfGWxuG5SIf/1y/LgJxHnMqaF6A/ij", "Issuer": "Code Campus", - "Audience": "Code Campus", + //"Audience": "Code Campus", "AccessTokenExpiresTime": 60, "RefreshTokenExpiresTime": 300 }, diff --git a/FileService/FileService.Api/appsettings.Staging.json b/FileService/FileService.Api/appsettings.Staging.json index cb14b20e..1bcd9008 100644 --- a/FileService/FileService.Api/appsettings.Staging.json +++ b/FileService/FileService.Api/appsettings.Staging.json @@ -23,7 +23,7 @@ "Jwt": { "Key": "1TjXchw5FloESb63Kc+DFhTARvpWL4jUGCwfGWxuGSSIf/1y/LgJxHnMqaF6A/ij", "Issuer": "Code Campus", - "Audience": "Code Campus", + // "Audience": "Code Campus", "AccessTokenExpiresTime": 60, "RefreshTokenExpiresTime": 3 }, @@ -43,5 +43,15 @@ "BucketName": "", "Secure": false } + }, + "Kestrel": { + "Endpoints": { + "Http": { + "Url": "http://+:8082" + }, + "Https": { + "Url": "https://+:443" + } + } } } diff --git a/FileService/FileService.Core/ApiModels/AppSettings.cs b/FileService/FileService.Core/ApiModels/AppSettings.cs index 803d8710..c2cd2013 100644 --- a/FileService/FileService.Core/ApiModels/AppSettings.cs +++ b/FileService/FileService.Core/ApiModels/AppSettings.cs @@ -21,7 +21,7 @@ public class Jwt { public string Key { get; set; } public string Issuer { get; set; } - public string Audience { get; set; } + // public string Audience { get; set; } public int AccessTokenExpiresTime { get; set; } public int RefreshTokenExpiresTime { get; set; } } diff --git a/FileService/FileService.Core/Enums/StatusCodeEnum.cs b/FileService/FileService.Core/Enums/StatusCodeEnum.cs index 1fdb005f..113a07d2 100644 --- a/FileService/FileService.Core/Enums/StatusCodeEnum.cs +++ b/FileService/FileService.Core/Enums/StatusCodeEnum.cs @@ -9,8 +9,6 @@ namespace FileService.Core.Enums { public enum StatusCodeEnum { - [Description("Thành công.")] - Success = 2008200, [Description("Lỗi hệ thống.")] Error = 5008201, @@ -51,13 +49,29 @@ public enum StatusCodeEnum [Description("Download Interrupted. Please check your internet connection and try again.")] C05 = 5038214, - [Description("Bad request.")] - BadRequest = 4008215, [Description("Invalid filter option.")] InvalidOption = 4008216, [Description("Unmatched columns found.")] UnmatchedColumns = 4008217, + + [Description("Success")] + Success = 200, + + [Description("Bad Request")] + BadRequest = 400, + + [Description("Unauthorized")] + Unauthorized = 401, + + [Description("Forbidden")] + Forbidden = 403, + + [Description("Not Found")] + NotFound = 404, + + [Description("Internal Server Error")] + InternalServerError = 500 } } diff --git a/docker/docker-compose.services.yml b/docker/docker-compose.services.yml index f7f779e2..cf43fe17 100644 --- a/docker/docker-compose.services.yml +++ b/docker/docker-compose.services.yml @@ -153,6 +153,8 @@ services: environment: # cấu hình môi trường ASP.NET - ASPNETCORE_ENVIRONMENT=staging + - ASPNETCORE_URLS=http://+:8082;https://+:443 + - ASPNETCORE_HTTPS_PORT=443 # ---- MongoDB (file-db) ---- - MongoDbSettings__ConnectionStrings="mongodb://${FILE_USERNAME}:${FILE_PASSWORD}@file-db:27017/${FILE_DATABASE}?authSource=${FILE_DATABASE}" - MongoDbSettings__DatabaseName=${FILE_DATABASE} @@ -165,6 +167,8 @@ services: - AppSettings__MinioConfig__Secure=false ports: - "8082:8082" + volumes: + - C:\DockerVolumes\DataProtectionKeys:/root/.aspnet/DataProtection-Keys networks: [ backend ] gateway-service: