From c0d02e0fa14ba3de1c52ed466e7690c1f2fa216a Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Mon, 21 Jul 2025 14:51:57 +0700 Subject: [PATCH] Add access control for media downloads Introduced IAccesserToDownloadMedia and its implementation to enforce access checks when downloading media files. Updated MediaController to use the new accesser service and improved error handling and validation in upload/download actions. Refactored and moved MediaService to the Medias namespace, registered new services in DI, and added comprehensive tests for access logic. Also fixed GroupMembershipConfiguration to make InvitationId optional and performed minor test and namespace cleanups. --- .../Controllers/MediaControllerTests.cs | 8 +- Govor.API/Controllers/MediaController.cs | 62 ++++--- .../ConfigurationProgramExtensions.cs | 2 + .../Govor.Application.Tests.csproj | 2 +- .../Validators/UsernameValidatorTests.cs | 2 +- .../AccesserToDownloadMediaServiceTests.cs | 152 ++++++++++++++++++ .../UserSessions/UserSessionOpenerTests.cs | 2 + .../Medias/IAccesserToDownloadMedia.cs | 6 + .../Medias/AccesserToDownloadMediaService.cs | 33 ++++ .../{Messages => Medias}/MediaService.cs | 2 +- .../GroupMembershipConfiguration.cs | 2 +- 11 files changed, 243 insertions(+), 30 deletions(-) rename Govor.Application.Tests/{ => Infrastructure}/Validators/UsernameValidatorTests.cs (95%) create mode 100644 Govor.Application.Tests/Services/Medias/AccesserToDownloadMediaServiceTests.cs create mode 100644 Govor.Application/Interfaces/Medias/IAccesserToDownloadMedia.cs create mode 100644 Govor.Application/Services/Medias/AccesserToDownloadMediaService.cs rename Govor.Application/Services/{Messages => Medias}/MediaService.cs (98%) diff --git a/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs index fcb5e4c..9f80ed2 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/MediaControllerTests.cs @@ -18,6 +18,7 @@ public class MediaControllerTests private Mock _currentUserMock; private Mock> _loggerMock; private Mock _mockMedia; + private Mock _mockAccesser; private MediaController _controller; private Guid _userId = Guid.NewGuid(); @@ -30,11 +31,16 @@ public class MediaControllerTests _currentUserMock = new Mock(); _loggerMock = new Mock>(); + _mockAccesser = new Mock(); _mockMedia = new Mock(); _currentUserMock.Setup(f => f.GetCurrentUserId()).Returns(_userId); - _controller = new MediaController(_loggerMock.Object, _mockMedia.Object, _currentUserMock.Object); + _controller = new MediaController( + _loggerMock.Object, + _mockMedia.Object, + _mockAccesser.Object, + _currentUserMock.Object); } // Test for Upload action diff --git a/Govor.API/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs index 4768b0f..d9ada27 100644 --- a/Govor.API/Controllers/MediaController.cs +++ b/Govor.API/Controllers/MediaController.cs @@ -1,9 +1,6 @@ -using Govor.Application.Interfaces; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Medias; using Govor.Contracts.Requests; -using Govor.Core.Models; -using Govor.Core.Repositories.MediasAttachments; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -16,14 +13,17 @@ public class MediaController : Controller { private readonly ILogger _logger; private readonly IMediaService _mediaService; + private readonly IAccesserToDownloadMedia _accesser; private readonly ICurrentUserService _currentUserService; public MediaController( ILogger logger, IMediaService mediaService, + IAccesserToDownloadMedia accesser, ICurrentUserService currentUserService) { _logger = logger; + _accesser = accesser; _mediaService = mediaService; _currentUserService = currentUserService; } @@ -32,20 +32,21 @@ public class MediaController : Controller [RequestSizeLimit(20_000_000)] // ~20MB public async Task Upload([FromForm] MediaUploadRequest request) { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + if (request?.FromFile is null || request.FromFile.Length == 0) + return BadRequest("No file uploaded"); + + if (request.FromFile.Length > 20_000_000) + return BadRequest("File is too large"); + + if (string.IsNullOrWhiteSpace(request.MimeType)) + return BadRequest("Missing MIME type"); + try { - if (request.FromFile.Length > 20_000_000) - return BadRequest("File is too large"); - - if (!ModelState.IsValid) - return BadRequest(ModelState); - - // Чтение байт из IFormFile - using var memoryStream = new MemoryStream(); - - await request.FromFile.CopyToAsync(memoryStream); - - byte[] fileBytes = memoryStream.ToArray(); + byte[] fileBytes = await ReadFileAsync(request.FromFile); var media = new Media( _currentUserService.GetCurrentUserId(), @@ -59,21 +60,21 @@ public class MediaController : Controller var result = await _mediaService.UploadMediaAsync(media); - _logger.LogInformation( - $"Uploaded file: {Path.GetFileName(request.FromFile.FileName)} from user {_currentUserService.GetCurrentUserId()}"); + _logger.LogInformation("Uploaded file {FileName} from user {UserId}", + media.FileName, media.UploaderId); return Ok(result); } - catch (InvalidOperationException ex) - { - _logger.LogWarning(ex, ex.Message); - return BadRequest(ex.Message); - } catch (UnauthorizedAccessException ex) { _logger.LogWarning(ex, ex.Message); return Unauthorized(ex.Message); } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, ex.Message); + return BadRequest(ex.Message); + } catch (Exception ex) { _logger.LogError(ex, "Error uploading media"); @@ -81,21 +82,32 @@ public class MediaController : Controller } } + private async Task ReadFileAsync(IFormFile file) + { + await using var ms = new MemoryStream(); + await file.CopyToAsync(ms); + return ms.ToArray(); + } + + [HttpGet("download/{id}")] public async Task Download(Guid id) { try { - if (!ModelState.IsValid) - return BadRequest(ModelState); + var userId = _currentUserService.GetCurrentUserId(); + + if (!await _accesser.HasAccessAsync(id, userId)) + return Forbid(); var media = await _mediaService.GetMediaByIdAsync(id); + return File(media.Data, media.MineType, Path.GetFileName(media.FileName)); } catch (KeyNotFoundException ex) { _logger.LogWarning(ex, ex.Message); - return NotFound(ex.Message); + return NotFound("Media not found"); } catch (Exception ex) { diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index d7ced36..d169afb 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -11,6 +11,7 @@ using Govor.Application.Interfaces.UserSession; using Govor.Application.Services; using Govor.Application.Services.Authentication; using Govor.Application.Services.Friends; +using Govor.Application.Services.Medias; using Govor.Application.Services.Messages; using Govor.Application.Services.UserSessions; using Govor.Core.Infrastructure.Extensions; @@ -68,6 +69,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); // UserSession services.AddScoped(); diff --git a/Govor.Application.Tests/Govor.Application.Tests.csproj b/Govor.Application.Tests/Govor.Application.Tests.csproj index cdd654f..b9c4e8e 100644 --- a/Govor.Application.Tests/Govor.Application.Tests.csproj +++ b/Govor.Application.Tests/Govor.Application.Tests.csproj @@ -27,7 +27,7 @@ - + diff --git a/Govor.Application.Tests/Validators/UsernameValidatorTests.cs b/Govor.Application.Tests/Infrastructure/Validators/UsernameValidatorTests.cs similarity index 95% rename from Govor.Application.Tests/Validators/UsernameValidatorTests.cs rename to Govor.Application.Tests/Infrastructure/Validators/UsernameValidatorTests.cs index 1e4571a..60daae4 100644 --- a/Govor.Application.Tests/Validators/UsernameValidatorTests.cs +++ b/Govor.Application.Tests/Infrastructure/Validators/UsernameValidatorTests.cs @@ -1,7 +1,7 @@ using Govor.Application.Exceptions.AuthService; using Govor.Application.Infrastructure.Validators; -namespace Govor.API.Tests.UnitTests.Services.Validators; +namespace Govor.Application.Tests.Infrastructure.Validators; [TestFixture] public class UsernameValidatorTests diff --git a/Govor.Application.Tests/Services/Medias/AccesserToDownloadMediaServiceTests.cs b/Govor.Application.Tests/Services/Medias/AccesserToDownloadMediaServiceTests.cs new file mode 100644 index 0000000..183d562 --- /dev/null +++ b/Govor.Application.Tests/Services/Medias/AccesserToDownloadMediaServiceTests.cs @@ -0,0 +1,152 @@ +using Govor.Application.Services.Medias; +using Govor.Core.Models; +using Govor.Core.Models.Messages; +using Govor.Data; +using Microsoft.EntityFrameworkCore; + +namespace Govor.Application.Tests.Services.Medias; + +[TestFixture] +public class AccesserToDownloadMediaServiceTests +{ + private GovorDbContext _dbContext = null!; + private AccesserToDownloadMediaService _accesser = null!; + private Guid _userId; + private Guid _otherUserId; + private Guid _groupId; + private Guid _mediaFileId; + + [SetUp] + public async Task SetUp() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .Options; + + _dbContext = new GovorDbContext(options); + _accesser = new AccesserToDownloadMediaService(_dbContext); + + _userId = Guid.NewGuid(); + _otherUserId = Guid.NewGuid(); + _groupId = Guid.NewGuid(); + _mediaFileId = Guid.NewGuid(); + + // Seed message from user to other user + var message = new Message + { + Id = Guid.NewGuid(), + SenderId = _userId, + RecipientId = _otherUserId, + RecipientType = RecipientType.User + }; + + + var media = new MediaFile + { + Id = _mediaFileId, + Url = "/media/test.png", + MineType = "image/png", + MediaType = MediaType.Image, + UploaderId = _userId, + DateCreated = DateTime.UtcNow + }; + + var attachment = new MediaAttachments + { + Id = Guid.NewGuid(), + MediaFileId = _mediaFileId, + MessageId = message.Id, + Message = message, + MediaFile = media + }; + + await _dbContext.Messages.AddAsync(message); + await _dbContext.MediaFiles.AddAsync(media); + await _dbContext.MediaAttachments.AddAsync(attachment); + await _dbContext.SaveChangesAsync(); + } + + [Test] + public async Task HasAccessAsync_ReturnsTrue_ForSender() + { + var result = await _accesser.HasAccessAsync(_mediaFileId, _userId); + Assert.That(result, Is.True); + } + + [Test] + public async Task HasAccessAsync_ReturnsTrue_ForRecipient() + { + var result = await _accesser.HasAccessAsync(_mediaFileId, _otherUserId); + Assert.That(result, Is.True); + } + + [Test] + public async Task HasAccessAsync_ReturnsFalse_ForUnrelatedUser() + { + var unrelatedUserId = Guid.NewGuid(); + var result = await _accesser.HasAccessAsync(_mediaFileId, unrelatedUserId); + Assert.That(result, Is.False); + } + + [Test] + public async Task HasAccessAsync_ReturnsTrue_ForGroupMember() + { + var groupMediaId = Guid.NewGuid(); + + var groupMessage = new Message + { + Id = Guid.NewGuid(), + SenderId = _userId, + RecipientId = _groupId, + RecipientType = RecipientType.Group + }; + + var media = new MediaFile + { + Id = groupMediaId, + Url = "/media/group.png", + MineType = "image/png", + MediaType = MediaType.Image, + UploaderId = _userId, + DateCreated = DateTime.UtcNow + }; + + var attachment = new MediaAttachments + { + Id = Guid.NewGuid(), + MediaFileId = groupMediaId, + MessageId = groupMessage.Id, + Message = groupMessage, + MediaFile = media + }; + + var membership = new GroupMembership + { + Id = Guid.NewGuid(), + GroupId = _groupId, + UserId = _otherUserId + }; + + await _dbContext.Messages.AddAsync(groupMessage); + await _dbContext.MediaFiles.AddAsync(media); + await _dbContext.MediaAttachments.AddAsync(attachment); + await _dbContext.GroupMemberships.AddAsync(membership); + await _dbContext.SaveChangesAsync(); + + var result = await _accesser.HasAccessAsync(groupMediaId, _otherUserId); + Assert.That(result, Is.True); + } + + [Test] + public async Task HasAccessAsync_ReturnsFalse_IfMediaNotAttached() + { + var result = await _accesser.HasAccessAsync(Guid.NewGuid(), _userId); + Assert.That(result, Is.False); + } + + [TearDown] + public void TearDown() + { + _dbContext.Dispose(); + } +} diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs index 490cf51..278c4f5 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs @@ -8,6 +8,8 @@ using Moq; using Govor.Application.Interfaces.Authentication; using Govor.Application.Services.Authentication; +namespace Govor.Application.Tests.Services.UserSessions; + [TestFixture] public class UserSessionOpenerTests { diff --git a/Govor.Application/Interfaces/Medias/IAccesserToDownloadMedia.cs b/Govor.Application/Interfaces/Medias/IAccesserToDownloadMedia.cs new file mode 100644 index 0000000..a55669e --- /dev/null +++ b/Govor.Application/Interfaces/Medias/IAccesserToDownloadMedia.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces.Medias; + +public interface IAccesserToDownloadMedia +{ + Task HasAccessAsync(Guid mediaFileId, Guid userId); +} \ No newline at end of file diff --git a/Govor.Application/Services/Medias/AccesserToDownloadMediaService.cs b/Govor.Application/Services/Medias/AccesserToDownloadMediaService.cs new file mode 100644 index 0000000..305ea53 --- /dev/null +++ b/Govor.Application/Services/Medias/AccesserToDownloadMediaService.cs @@ -0,0 +1,33 @@ +using Govor.Application.Interfaces.Medias; +using Govor.Core.Models.Messages; +using Govor.Data; +using Microsoft.EntityFrameworkCore; + +namespace Govor.Application.Services.Medias; + +public class AccesserToDownloadMediaService : IAccesserToDownloadMedia +{ + private readonly GovorDbContext _dbContext; + + public AccesserToDownloadMediaService(GovorDbContext dbContext) + { + _dbContext = dbContext; + } + + public async Task HasAccessAsync(Guid mediaFileId, Guid userId) + { + return await _dbContext.MediaAttachments + .Include(ma => ma.Message) + .AnyAsync(ma => + ma.MediaFileId == mediaFileId && + ( + (ma.Message.RecipientType == RecipientType.User && + (ma.Message.SenderId == userId || ma.Message.RecipientId == userId)) + || + (ma.Message.RecipientType == RecipientType.Group && + _dbContext.GroupMemberships.Any(gm => + gm.GroupId == ma.Message.RecipientId && + gm.UserId == userId)) + )); + } +} \ No newline at end of file diff --git a/Govor.Application/Services/Messages/MediaService.cs b/Govor.Application/Services/Medias/MediaService.cs similarity index 98% rename from Govor.Application/Services/Messages/MediaService.cs rename to Govor.Application/Services/Medias/MediaService.cs index 547e393..a8e9ff9 100644 --- a/Govor.Application/Services/Messages/MediaService.cs +++ b/Govor.Application/Services/Medias/MediaService.cs @@ -5,7 +5,7 @@ using Govor.Data; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -namespace Govor.Application.Services.Messages; +namespace Govor.Application.Services.Medias; public class MediaService : IMediaService { diff --git a/Govor.Data/Configurations/GroupMembershipConfiguration.cs b/Govor.Data/Configurations/GroupMembershipConfiguration.cs index 9cee30b..fb16926 100644 --- a/Govor.Data/Configurations/GroupMembershipConfiguration.cs +++ b/Govor.Data/Configurations/GroupMembershipConfiguration.cs @@ -12,7 +12,7 @@ public class GroupMembershipConfiguration : IEntityTypeConfiguration e.UserId).IsRequired(); builder.Property(e => e.GroupId).IsRequired(); - builder.Property(e => e.InvitationId).IsRequired(); + builder.Property(e => e.InvitationId).IsRequired(false); // Optional: можно добавить навигацию к GroupInvitation builder.HasOne()