diff --git a/.github/workflows/qodana.yml b/.github/workflows/qodana.yml new file mode 100644 index 0000000..ba604d9 --- /dev/null +++ b/.github/workflows/qodana.yml @@ -0,0 +1,22 @@ +name: Qodana Analysis + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + qodana: + name: Run Qodana Code Analysis + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Run Qodana + uses: JetBrains/qodana-action@v2024.1 + with: + # Указываем путь к qodana.yaml (если он не в корне) + args: --ide QDNET --project-dir . diff --git a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs index 1866a5f..994f1ec 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs @@ -1,11 +1,12 @@ using AutoFixture; using Govor.API.Controllers; -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Exceptions.AuthService; using Govor.Application.Exceptions.InvitesService; using Govor.Application.Interfaces.Authentication; +using Govor.Application.Interfaces.UserSession; using Govor.Contracts.Requests; using Govor.Core.Models; +using Govor.Core.Models.Users; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Logging; using Moq; @@ -19,6 +20,7 @@ public class AuthControllerTests private Mock _accountServiceMock; private Mock _invitesServiceMock; private Mock> _loggerMock; + private Mock _userSessionOpenerMock; private AuthController _controller; [SetUp] @@ -31,10 +33,12 @@ public class AuthControllerTests _accountServiceMock = new Mock(); _invitesServiceMock = new Mock(); _loggerMock = new Mock>(); - + _userSessionOpenerMock = new Mock(); + _controller = new AuthController( _accountServiceMock.Object, _invitesServiceMock.Object, + _userSessionOpenerMock.Object, _loggerMock.Object ); } @@ -47,9 +51,18 @@ public class AuthControllerTests var request = _fixture.Create(); var invitation = _fixture.Create(); var token = _fixture.Create(); - + + var user = _fixture.Build() + .With(x => x.Username).Create(); + _invitesServiceMock.Setup(s => s.ValidateAsync(request.InviteLink)).ReturnsAsync(invitation); - _accountServiceMock.Setup(s => s.RegistrationAsync(request.Name, request.Password, invitation)).ReturnsAsync(token); + + + _accountServiceMock.Setup(l => l.RegistrationAsync(request.Name, request.Password, invitation)) + .ReturnsAsync(user); + + _userSessionOpenerMock.Setup(f => f.OpenSessionAsync(user, request.DeviceInfo)) + .ReturnsAsync(token); // Act var result = await _controller.Register(request); @@ -137,7 +150,14 @@ public class AuthControllerTests // Arrange var loginRequest = _fixture.Create(); var token = _fixture.Create(); - _accountServiceMock.Setup(l => l.LoginAsync(loginRequest.Name, loginRequest.Password)).ReturnsAsync(token); + + var user = _fixture.Build() + .With(x => x.Username).Create(); + + _accountServiceMock.Setup(l => l.LoginAsync(loginRequest.Name, loginRequest.Password)).ReturnsAsync(user); + + _userSessionOpenerMock.Setup(f => f.OpenSessionAsync(user, loginRequest.DeviceInfo)) + .ReturnsAsync(token); // Act var result = await _controller.Login(loginRequest); diff --git a/Govor.API/Controllers/AdminStuff/InviteUserController.cs b/Govor.API/Controllers/AdminStuff/InviteUserController.cs index d5d28c1..00121b8 100644 --- a/Govor.API/Controllers/AdminStuff/InviteUserController.cs +++ b/Govor.API/Controllers/AdminStuff/InviteUserController.cs @@ -1,4 +1,4 @@ -using Govor.API.Services.AdminsStuff.Interfaces; +using Govor.Application.Interfaces; using Govor.Contracts.DTOs; using Govor.Contracts.Requests; using Govor.Core.Repositories.Invaites; diff --git a/Govor.API/Controllers/AdminStuff/UsersController.cs b/Govor.API/Controllers/AdminStuff/UsersController.cs index fa75d8e..dd180e8 100644 --- a/Govor.API/Controllers/AdminStuff/UsersController.cs +++ b/Govor.API/Controllers/AdminStuff/UsersController.cs @@ -1,4 +1,3 @@ -using Govor.API.Services.AdminsStuff.Interfaces; using Govor.Application.Interfaces; using Govor.Contracts.Responses.Admins; using Govor.Core.Models.Users; diff --git a/Govor.API/Controllers/AuthController.cs b/Govor.API/Controllers/AuthController.cs index 20d8287..b6e6a7e 100644 --- a/Govor.API/Controllers/AuthController.cs +++ b/Govor.API/Controllers/AuthController.cs @@ -1,7 +1,7 @@ -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Exceptions.AuthService; using Govor.Application.Exceptions.InvitesService; using Govor.Application.Interfaces.Authentication; +using Govor.Application.Interfaces.UserSession; using Govor.Contracts.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; @@ -13,33 +13,38 @@ namespace Govor.API.Controllers; [Route("api/[controller]")] public class AuthController : Controller { + private IUserSessionOpener _userSession; private IInvitesService _invitesService; private IAccountService _accountService; private ILogger _logger; - public AuthController(IAccountService accountService, IInvitesService invitesService, ILogger logger) + public AuthController(IAccountService accountService, IInvitesService invitesService,IUserSessionOpener userSessionOpener, ILogger logger) { + _userSession = userSessionOpener; _accountService = accountService; _invitesService = invitesService; _logger = logger; } + [RequireHttps] [HttpPost("register")]// api/auth/register - //[RequireHttps] public async Task Register([FromBody] RegistrationRequest registrationRequest) { try { if (!ModelState.IsValid) - { return BadRequest(ModelState); - } var invite = await _invitesService.ValidateAsync(registrationRequest.InviteLink); - var token = await _accountService.RegistrationAsync(registrationRequest.Name, registrationRequest.Password, + var user = await _accountService.RegistrationAsync(registrationRequest.Name, registrationRequest.Password, invite); - _logger.LogInformation($"Register request for {registrationRequest.Name} processed successfully"); + + _logger.LogInformation($"Register request for {user.Username} with id {user.Id} processed successfully"); + + var token = await _userSession.OpenSessionAsync(user, registrationRequest.DeviceInfo); + + _logger.LogInformation($"Session for user {user.Username} with id {user.Id} has been opened"); return Ok(new { token }); } catch (UserAlreadyExistException ex) @@ -64,35 +69,73 @@ public class AuthController : Controller } } + [RequireHttps] [HttpPost("login")]// api/auth/login - //[RequireHttps] - public async Task Login([FromBody] LoginRequest userRequest) + public async Task Login([FromBody] LoginRequest loginRequest) { try { if (!ModelState.IsValid) - { return BadRequest(ModelState); - } - var token = await _accountService.LoginAsync(userRequest.Name, userRequest.Password); - _logger.LogInformation($"Login request for {userRequest.Name} processed successfully"); + var user = await _accountService.LoginAsync(loginRequest.Name, loginRequest.Password); + _logger.LogInformation($"Login request for {user.Username} with id {user.Id} processed successfully"); + + var token = await _userSession.OpenSessionAsync(user, loginRequest.DeviceInfo); + + _logger.LogInformation($"Session for user {user.Username} with id {user.Id} has been opened"); + return Ok(new { token }); } catch (UserNotRegisteredException ex) { - _logger.LogWarning(ex, "Login failed for user {Name}", userRequest.Name); + _logger.LogWarning(ex, "Login failed for user {Name}", loginRequest.Name); return BadRequest("Login failed: user does not exist."); } catch (LoginUserException ex) { - _logger.LogWarning(ex, "Login failed for user {Name}", userRequest.Name); + _logger.LogWarning(ex, "Login failed for user {Name}", loginRequest.Name); return BadRequest("Login failed: username or password is incorrect."); } catch (Exception ex) { - _logger.LogError(ex, "Unexpected error during login for user {Name}", userRequest.Name); + _logger.LogError(ex, "Unexpected error during login for user {Name}", loginRequest.Name); return StatusCode(500, "An unexpected error occurred. Please try again later."); } } + +/* + [RequireHttps] + [HttpPost("refresh")] + public async Task Refresh([FromBody] string refreshToken) + { + try + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + if (string.IsNullOrEmpty(refreshToken)) + throw new InvalidOperationException("Refresh token cant be empty."); + + var newAccessToken = await _accountService.RefreshTokenAsync(refreshToken); + return Ok(new { accessToken = newAccessToken }); + } + catch (InvalidOperationException ex) + { + _logger.LogWarning(ex, "Invalid refresh token"); + return BadRequest(ex.Message); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Refresh token failed"); + return Unauthorized("Invalid refresh token"); + } + catch (Exception ex) + { + _logger.LogError(ex, ex.Message); + return StatusCode(500, "An unexpected error occurred."); + } + } +*/ + } \ No newline at end of file diff --git a/Govor.API/Controllers/ChatLoadController.cs b/Govor.API/Controllers/ChatLoadController.cs index 9a18d3c..4a1fa6f 100644 --- a/Govor.API/Controllers/ChatLoadController.cs +++ b/Govor.API/Controllers/ChatLoadController.cs @@ -32,7 +32,7 @@ public class ChatLoadController : Controller public async Task GetChatMessages( [FromQuery] Guid chatId, [FromQuery] Guid? startMessageId, - [FromQuery] int pageSize = 20) + [FromQuery] int pageSize) { try { diff --git a/Govor.API/Controllers/MediaController.cs b/Govor.API/Controllers/MediaController.cs index d842430..4768b0f 100644 --- a/Govor.API/Controllers/MediaController.cs +++ b/Govor.API/Controllers/MediaController.cs @@ -29,32 +29,51 @@ public class MediaController : Controller } [HttpPost("upload")] - [RequestSizeLimit(100_000_000)] // ~100MB + [RequestSizeLimit(20_000_000)] // ~20MB public async Task Upload([FromForm] MediaUploadRequest request) { 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.Data.CopyToAsync(memoryStream); + + await request.FromFile.CopyToAsync(memoryStream); + byte[] fileBytes = memoryStream.ToArray(); var media = new Media( _currentUserService.GetCurrentUserId(), DateTime.UtcNow, + Path.GetFileName(request.FromFile.FileName), fileBytes, - request.FileName, request.Type, request.MimeType, request.EncryptedKey ); var result = await _mediaService.UploadMediaAsync(media); + + _logger.LogInformation( + $"Uploaded file: {Path.GetFileName(request.FromFile.FileName)} from user {_currentUserService.GetCurrentUserId()}"); + 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 (Exception ex) { _logger.LogError(ex, "Error uploading media"); @@ -71,7 +90,12 @@ public class MediaController : Controller return BadRequest(ModelState); var media = await _mediaService.GetMediaByIdAsync(id); - return File(media.Data, media.MineType, media.FileName); + return File(media.Data, media.MineType, Path.GetFileName(media.FileName)); + } + catch (KeyNotFoundException ex) + { + _logger.LogWarning(ex, ex.Message); + return NotFound(ex.Message); } catch (Exception ex) { diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index 2689d8a..beb91f9 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -1,5 +1,3 @@ -using Govor.API.Services.AdminsStuff.Interfaces; -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Infrastructure.AdminsStuff; using Govor.Application.Infrastructure.Extensions; using Govor.Application.Infrastructure.Validators; @@ -9,10 +7,12 @@ using Govor.Application.Interfaces.Friends; using Govor.Application.Interfaces.Infrastructure.Extensions; using Govor.Application.Interfaces.Medias; using Govor.Application.Interfaces.Messages; +using Govor.Application.Interfaces.UserSession; using Govor.Application.Services; using Govor.Application.Services.Authentication; using Govor.Application.Services.Friends; using Govor.Application.Services.Messages; +using Govor.Application.Services.UserSessions; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Infrastructure.Validators; using Govor.Core.Models; @@ -26,6 +26,7 @@ using Govor.Core.Repositories.MediasAttachments; using Govor.Core.Repositories.Messages; using Govor.Core.Repositories.PrivateChats; using Govor.Core.Repositories.Users; +using Govor.Core.Repositories.UserSessionsRepository; using Govor.Data; using Govor.Data.Repositories; using Microsoft.EntityFrameworkCore; @@ -67,6 +68,9 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); + + // UserSession + services.AddScoped(); // Auto Mapper services.AddAutoMapper(typeof(MappingProfile)); @@ -82,7 +86,7 @@ public static class ConfigurationProgramExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - + services.AddScoped(); // other } diff --git a/Govor.API/Govor.API.csproj b/Govor.API/Govor.API.csproj index c7f5397..0f12b70 100644 --- a/Govor.API/Govor.API.csproj +++ b/Govor.API/Govor.API.csproj @@ -25,5 +25,9 @@ + + + + diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index 1b4b4cb..b911e88 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -26,7 +26,7 @@ builder.Services.AddCors(options => }); }); -builder.Services.Configure(configuration.GetSection(nameof(JwtOption))); +builder.Services.Configure(configuration.GetSection(nameof(JwtAccessOption))); // Add services builder.Services.AddSignalRConf();// signalR diff --git a/Govor.API/appsettings.json b/Govor.API/appsettings.json index 132a5a0..c1f2289 100644 --- a/Govor.API/appsettings.json +++ b/Govor.API/appsettings.json @@ -9,9 +9,12 @@ "GovorDbContext": "Server=147.45.255.215;Port=3306;Database=artemy_DB;User=artemy;Password=LoxHuy))228Goy;" }, "UseMySql": true, - "AllowedHosts": "*", - "JwtOption": { - "SecretKeу": "MY VERY SECRET KEY asdasdpafjhasofafpajsfj", - "Hours": "24" + "AllowedHosts": "govor-team-govor-88b3.twc1.net", + "JwtAccessOption": { + "SecretKey": "Q89eY7zP7C4+TqLmHF4kw9xkF1E8Ru4Zpg+up9wFt9g=", + "Minutes": 25 + }, + "JwtRefreshOption": { + "RefreshTokenLifetimeDays": 30 } } diff --git a/Govor.Application.Tests/Infrastructure/AdminsStuff/InvitationGeneratorTests.cs b/Govor.Application.Tests/Infrastructure/AdminsStuff/InvitationGeneratorTests.cs index 4ad9d52..698b6ed 100644 --- a/Govor.Application.Tests/Infrastructure/AdminsStuff/InvitationGeneratorTests.cs +++ b/Govor.Application.Tests/Infrastructure/AdminsStuff/InvitationGeneratorTests.cs @@ -1,6 +1,6 @@ using AutoFixture; -using Govor.API.Services.AdminsStuff.Interfaces; using Govor.Application.Infrastructure.AdminsStuff; +using Govor.Application.Interfaces; using Govor.Core.Models; using Govor.Core.Repositories.Invaites; using Moq; diff --git a/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs b/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs index 5964ad8..4a85b28 100644 --- a/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/AuthServiceTests.cs @@ -2,10 +2,8 @@ using AutoFixture; using Govor.Core.Infrastructure.Extensions; using Govor.Core.Models; using Govor.Core.Repositories.Users; -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Exceptions.AuthService; using Govor.Application.Interfaces.Authentication; -using Govor.Application.Services; using Govor.Application.Services.Authentication; using Govor.Core.Models.Users; using Govor.Core.Repositories.Admins; @@ -52,6 +50,7 @@ public class AuthServiceTests ); } + // Tests for Register action [Test] public void Given_ExistUser_When_Register_Should_Throw_UserAlreadyExistsException() { diff --git a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs index d7a2889..cc0b8fe 100644 --- a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs @@ -1,10 +1,12 @@ using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; using AutoFixture; -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Interfaces.Authentication; using Govor.Application.Services.Authentication; using Govor.Core.Models.Users; using Microsoft.Extensions.Options; +using Microsoft.IdentityModel.Tokens; using Moq; namespace Govor.Application.Tests.Services.Authentication; @@ -13,11 +15,13 @@ namespace Govor.Application.Tests.Services.Authentication; public class JwtServiceTests { private Fixture _fixture; - private Mock> _jwtOptionsMock; + private Mock> _jwtOptionsMock; + private Mock> _jwtRefreshOptionsMock; private Mock _invitesServiceMock; private IJwtService _jwtService; - private JwtOption _testJwtOptions; + private JwtAccessOption _testJwtAccessOptions; + private JwtRefreshOption _testJwtRefreshOptions; [SetUp] public void SetUp() @@ -26,18 +30,29 @@ public class JwtServiceTests _fixture.Behaviors.OfType().ToList().ForEach(b => _fixture.Behaviors.Remove(b)); _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); - _testJwtOptions = new JwtOption + _testJwtAccessOptions = new JwtAccessOption { - SecretKeу = "THIS IS A TEST SECRET KEY THAT IS LONG ENOUGH", // Ensure key size is sufficient for HMACSHA256 - Hours = 1 + SecretKeу = "THIS_IS_A_TEST_SECRET_KEY_THAT_IS_LONG_ENOUGH_1234", // Ensure key size is sufficient for HMACSHA256 + Minutes = 5 }; - _jwtOptionsMock = new Mock>(); - _jwtOptionsMock.Setup(o => o.Value).Returns(_testJwtOptions); - + _testJwtRefreshOptions = new JwtRefreshOption() + { + RefreshTokenLifetimeDays = 30 + }; + + _jwtOptionsMock = new Mock>(); + _jwtOptionsMock.Setup(o => o.Value).Returns(_testJwtAccessOptions); + + _jwtRefreshOptionsMock = new Mock>(); + _jwtRefreshOptionsMock.Setup(o => o.Value).Returns(_testJwtRefreshOptions); + _invitesServiceMock = new Mock(); - _jwtService = new JwtService(_jwtOptionsMock.Object, _invitesServiceMock.Object); + _jwtService = new JwtService( + _jwtOptionsMock.Object, + _jwtRefreshOptionsMock.Object, + _invitesServiceMock.Object); } [Test] @@ -48,7 +63,7 @@ public class JwtServiceTests var expectedRole = "User"; _invitesServiceMock.Setup(s => s.GetRoleAsync(user)).Returns(Task.FromResult(expectedRole)); // Act - var tokenString = await _jwtService.GenerateJwtTokenAsync(user); + var tokenString = await _jwtService.GenerateAccessTokenAsync(user); // Assert Assert.That(tokenString, Is.Not.Null.And.Not.Empty); @@ -57,4 +72,57 @@ public class JwtServiceTests var handler = new JwtSecurityTokenHandler(); Assert.DoesNotThrow(() => handler.ReadJwtToken(tokenString)); } + + [Test] + public async Task GenerateRefreshTokenAsync_ReturnsValidRefreshToken() + { + // Arrange + var user = new User { Id = Guid.NewGuid() }; + + // Act + var token = await _jwtService.GenerateRefreshTokenAsync(user); + + // Assert + Assert.That(token, Is.Not.Null); + + var handler = new JwtSecurityTokenHandler(); + var jwt = handler.ReadJwtToken(token); + + Assert.That(user.Id.ToString(), Is.EqualTo(jwt.Claims.First(c => c.Type == "userId").Value)); + Assert.That("refresh",Is.EqualTo(jwt.Claims.First(c => c.Type == "tokenType").Value)); + } + + [Test] + public async Task GetPrincipalFromExpiredToken_ReturnsValidClaimsPrincipal() + { + // Arrange + var userId = Guid.NewGuid(); + var handler = new JwtSecurityTokenHandler(); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_testJwtAccessOptions.SecretKeу)); + + var now = DateTime.UtcNow; + + var token = handler.CreateToken(new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim("userId", userId.ToString()) + }), + NotBefore = now.AddSeconds(-10), + IssuedAt = now.AddSeconds(-10), + Expires = now.AddSeconds(-5), + SigningCredentials = new SigningCredentials(key, SecurityAlgorithms.HmacSha256) + }); + + var expiredToken = handler.WriteToken(token); + + // Act + var principal = _jwtService.GetPrincipalFromExpiredToken(expiredToken); + + // Assert + Assert.That(principal, Is.Not.Null); + var claim = principal.FindFirst("userId"); + Assert.That(claim, Is.Not.Null); + Assert.That(userId.ToString(), Is.EqualTo(claim.Value)); + } } \ No newline at end of file diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs new file mode 100644 index 0000000..a50ff21 --- /dev/null +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs @@ -0,0 +1,127 @@ +using Govor.Application.Interfaces.Authentication; +using Govor.Application.Services.Authentication; +using Govor.Application.Services.UserSessions; +using Govor.Core.Models; +using Govor.Core.Models.Users; +using Govor.Core.Repositories.UserSessionsRepository; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace Govor.Application.Tests.Services.UserSessions; + +[TestFixture] +public class UserSessionOpenerTests +{ + private Mock _repositoryMock; + private Mock _jwtServiceMock; + private Mock> _loggerMock; + private IOptions _options; + private UserSessionOpener _service; + private User _user; + private const string DeviceInfo = "Chrome on Windows"; + private const string GeneratedToken = "new-refresh-token"; + + [SetUp] + public void Setup() + { + _repositoryMock = new Mock(); + _jwtServiceMock = new Mock(); + _loggerMock = new Mock>(); + _options = Options.Create(new JwtRefreshOption { RefreshTokenLifetimeDays = 30 }); + + _service = new UserSessionOpener( + _repositoryMock.Object, + _jwtServiceMock.Object, + _options, + _loggerMock.Object + ); + + _user = new User + { + Id = Guid.NewGuid(), + Username = "test", + PasswordHash = "hashed", + IconId = Guid.NewGuid(), + CreatedOn = DateOnly.FromDateTime(DateTime.UtcNow), + WasOnline = DateTime.UtcNow, + InviteId = Guid.NewGuid() + }; + + _jwtServiceMock + .Setup(j => j.GenerateRefreshTokenAsync(_user)) + .ReturnsAsync(GeneratedToken); + } + + [Test] + public async Task OpenSessionAsync_ShouldReturnExistingToken_IfSessionValid() + { + // Arrange + var session = new Core.Models.UserSession + { + UserId = _user.Id, + DeviceInfo = DeviceInfo, + RefreshToken = "valid-token", + CreatedAt = DateTime.UtcNow.AddDays(-10), + ExpiresAt = DateTime.UtcNow.AddDays(10), + IsRevoked = false + }; + + _repositoryMock + .Setup(r => r.GetByUserIdAsync(_user.Id)) + .ReturnsAsync(new List { session }); + + // Act + var result = await _service.OpenSessionAsync(_user, DeviceInfo); + + // Asser + Assert.That(result, Is.EqualTo("valid-token")); + _repositoryMock.Verify(r => r.UpdateAsync(It.IsAny()), Times.Never); + } + + [Test] + public async Task OpenSessionAsync_ShouldUpdateSession_IfExpiredOrRevoked() + { + // Arrange + var session = new Core.Models.UserSession + { + UserId = _user.Id, + DeviceInfo = DeviceInfo, + RefreshToken = "old-token", + CreatedAt = DateTime.UtcNow.AddDays(-40), + ExpiresAt = DateTime.UtcNow.AddDays(-1), + IsRevoked = false + }; + + _repositoryMock + .Setup(r => r.GetByUserIdAsync(_user.Id)) + .ReturnsAsync(new List { session }); + + // Act + var result = await _service.OpenSessionAsync(_user, DeviceInfo); + + // Assert + Assert.That(result, Is.EqualTo(GeneratedToken)); + _repositoryMock.Verify(r => r.UpdateAsync(It.Is(s => s.RefreshToken == GeneratedToken)), Times.Once); + } + + [Test] + public async Task OpenSessionAsync_ShouldCreateNewSession_IfNoneExists() + { + // Arrange + _repositoryMock + .Setup(r => r.GetByUserIdAsync(_user.Id)) + .ReturnsAsync(new List()); + + // Act + var result = await _service.OpenSessionAsync(_user, DeviceInfo); + + // Assert + Assert.That(result, Is.EqualTo(GeneratedToken)); + _repositoryMock.Verify(r => r.AddAsync(It.Is(s => + s.UserId == _user.Id && + s.DeviceInfo == DeviceInfo && + s.RefreshToken == GeneratedToken + )), Times.Once); + } +} diff --git a/Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs b/Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs index d448c49..f2000b7 100644 --- a/Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs +++ b/Govor.Application/Infrastructure/AdminsStuff/InvitationGenerator.cs @@ -1,4 +1,4 @@ -using Govor.API.Services.AdminsStuff.Interfaces; +using Govor.Application.Interfaces; using Govor.Core.Models; using Govor.Core.Repositories.Invaites; @@ -19,7 +19,7 @@ public class InvitationGenerator(IInvitesRepository repository) : IInvitationGen IsAdmin = isAdmin }; - await repository.AddAsync(newInvitation); + await repository.AddAsync(newInvitation); return newInvitation.Code; } diff --git a/Govor.Application/Interfaces/Authentication/IAuthService.cs b/Govor.Application/Interfaces/Authentication/IAuthService.cs index 1f54b69..010e82c 100644 --- a/Govor.Application/Interfaces/Authentication/IAuthService.cs +++ b/Govor.Application/Interfaces/Authentication/IAuthService.cs @@ -1,9 +1,10 @@ using Govor.Core.Models; +using Govor.Core.Models.Users; namespace Govor.Application.Interfaces.Authentication; public interface IAccountService { - public Task RegistrationAsync(string name, string password, Invitation invitation); - public Task LoginAsync(string name, string password); + public Task RegistrationAsync(string name, string password, Invitation invitation); + public Task LoginAsync(string name, string password); } \ No newline at end of file diff --git a/Govor.Application/Interfaces/Authentication/IInvitesService.cs b/Govor.Application/Interfaces/Authentication/IInvitesService.cs index 566f145..86e24f8 100644 --- a/Govor.Application/Interfaces/Authentication/IInvitesService.cs +++ b/Govor.Application/Interfaces/Authentication/IInvitesService.cs @@ -1,7 +1,7 @@ using Govor.Core.Models; using Govor.Core.Models.Users; -namespace Govor.API.Services.Authentication.Interfaces; +namespace Govor.Application.Interfaces.Authentication; public interface IInvitesService { diff --git a/Govor.Application/Interfaces/Authentication/IJwtService.cs b/Govor.Application/Interfaces/Authentication/IJwtService.cs index 6904693..18c7477 100644 --- a/Govor.Application/Interfaces/Authentication/IJwtService.cs +++ b/Govor.Application/Interfaces/Authentication/IJwtService.cs @@ -1,8 +1,11 @@ +using System.Security.Claims; using Govor.Core.Models.Users; namespace Govor.Application.Interfaces.Authentication; public interface IJwtService { - Task GenerateJwtTokenAsync(User user); + Task GenerateAccessTokenAsync(User user); + Task GenerateRefreshTokenAsync(User user); + ClaimsPrincipal GetPrincipalFromExpiredToken(string token); } \ No newline at end of file diff --git a/Govor.Application/Interfaces/IInvitionGenerator.cs b/Govor.Application/Interfaces/IInvitionGenerator.cs index 0c2f932..05d80c2 100644 --- a/Govor.Application/Interfaces/IInvitionGenerator.cs +++ b/Govor.Application/Interfaces/IInvitionGenerator.cs @@ -1,4 +1,4 @@ -namespace Govor.API.Services.AdminsStuff.Interfaces; +namespace Govor.Application.Interfaces; public interface IInvitationGenerator { diff --git a/Govor.Application/Interfaces/Medias/IMediaService.cs b/Govor.Application/Interfaces/Medias/IMediaService.cs index 48a8232..b8a9735 100644 --- a/Govor.Application/Interfaces/Medias/IMediaService.cs +++ b/Govor.Application/Interfaces/Medias/IMediaService.cs @@ -12,8 +12,8 @@ public interface IMediaService public record Media(Guid UploaderId, DateTime UploadedOn, - byte[] Data, string FileName, + byte[] Data, MediaType Type, string MineType, string EncryptedKey); diff --git a/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs b/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs new file mode 100644 index 0000000..641084d --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs @@ -0,0 +1,10 @@ +using Govor.Core.Models.Users; + +namespace Govor.Application.Interfaces.UserSession; + + +public interface IUserSessionOpener +{ + Task OpenSessionAsync(User user, string deviceInfo); +} + diff --git a/Govor.Application/Interfaces/UserSession/IUserSessionReader.cs b/Govor.Application/Interfaces/UserSession/IUserSessionReader.cs new file mode 100644 index 0000000..3824747 --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/IUserSessionReader.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Interfaces.UserSession; + +public interface IUserSessionReader +{ + Task> GetAllSessionsAsync(Guid userId); +} diff --git a/Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs b/Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs new file mode 100644 index 0000000..6106db5 --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs @@ -0,0 +1,8 @@ +namespace Govor.Application.Interfaces.UserSession; + + +public interface IUserSessionRevoker +{ + Task CloseSessionByRefreshTokenAsync(string refreshToken); + Task CloseAllSessionsAsync(Guid userId); +} diff --git a/Govor.Application/Services/Authentication/AuthService.cs b/Govor.Application/Services/Authentication/AuthService.cs index dbfcf01..1c4b2bf 100644 --- a/Govor.Application/Services/Authentication/AuthService.cs +++ b/Govor.Application/Services/Authentication/AuthService.cs @@ -30,7 +30,7 @@ public class AuthService : IAccountService _usernameValidator = usernameValidator; } - public async Task RegistrationAsync(string name, string password, Invitation invitation) + public async Task RegistrationAsync(string name, string password, Invitation invitation) { _usernameValidator.Validate(name); @@ -55,11 +55,11 @@ public class AuthService : IAccountService await SetRole(user, invitation); - return await _jwtService.GenerateJwtTokenAsync(user); + return user; } - public async Task LoginAsync(string name, string password) + public async Task LoginAsync(string name, string password) { if (await _usersRepository.ExistsUsernameAsync(name) == false) throw new UserNotRegisteredException(name); @@ -69,9 +69,34 @@ public class AuthService : IAccountService if (_passwordHasher.Verify(password, user.PasswordHash) == false) throw new LoginUserException(); - return await _jwtService.GenerateJwtTokenAsync(user); + return user; } + + /* + public async Task RefreshTokenAsync(string refreshToken) + { + try + { + var principal = _jwtService.GetPrincipalFromExpiredToken(refreshToken); + + var userId = Guid.Parse(principal?.FindFirst("userId")?.Value ?? string.Empty); + var storedTokens = await _userSessionsRepository.GetUserTokensAsync(userId); + + if (!storedTokens.Contains(refreshToken)) + throw new UnauthorizedAccessException("Invalid refresh token"); + + var user = await _usersRepository.FindByIdAsync(userId); + var newAccessToken = await _jwtService.GenerateAccessTokenAsync(user); + return newAccessToken; + } + catch (SecurityTokenException ex) + { + throw new InvalidOperationException("Invalid refresh token", ex); + } + } + */ + private async Task SetRole(User user, Invitation invitation) { if(invitation.IsAdmin) diff --git a/Govor.Application/Services/Authentication/JwtOption.cs b/Govor.Application/Services/Authentication/JwtAccessOption.cs similarity index 58% rename from Govor.Application/Services/Authentication/JwtOption.cs rename to Govor.Application/Services/Authentication/JwtAccessOption.cs index a4ebef1..6308a92 100644 --- a/Govor.Application/Services/Authentication/JwtOption.cs +++ b/Govor.Application/Services/Authentication/JwtAccessOption.cs @@ -1,6 +1,6 @@ namespace Govor.Application.Services.Authentication; -public class JwtOption +public class JwtAccessOption { public string SecretKeу {get; set;} - public int Hours { get; set; } + public int Minutes { get; set; } } \ No newline at end of file diff --git a/Govor.Application/Services/Authentication/JwtRefreshOption.cs b/Govor.Application/Services/Authentication/JwtRefreshOption.cs new file mode 100644 index 0000000..3e88c1a --- /dev/null +++ b/Govor.Application/Services/Authentication/JwtRefreshOption.cs @@ -0,0 +1,6 @@ +namespace Govor.Application.Services.Authentication; + +public class JwtRefreshOption +{ + public int RefreshTokenLifetimeDays { get; set; } +} \ No newline at end of file diff --git a/Govor.Application/Services/Authentication/JwtService.cs b/Govor.Application/Services/Authentication/JwtService.cs index 9f5e8e4..7110fbd 100644 --- a/Govor.Application/Services/Authentication/JwtService.cs +++ b/Govor.Application/Services/Authentication/JwtService.cs @@ -1,7 +1,6 @@ using System.IdentityModel.Tokens.Jwt; using System.Security.Claims; using System.Text; -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Interfaces.Authentication; using Govor.Core.Models.Users; using Microsoft.Extensions.Options; @@ -11,16 +10,18 @@ namespace Govor.Application.Services.Authentication; public class JwtService : IJwtService { - private JwtOption _jwtOption; + private JwtAccessOption _jwtAccessOption; + private JwtRefreshOption _refreshOptions; private IInvitesService _invitesService; - public JwtService(IOptions options, IInvitesService invitesService) + public JwtService(IOptions options, IOptions refreshOptions, IInvitesService invitesService) { - _jwtOption = options.Value; + _refreshOptions = refreshOptions.Value; + _jwtAccessOption = options.Value; _invitesService = invitesService; } - public async Task GenerateJwtTokenAsync(User user) + public async Task GenerateAccessTokenAsync(User user) { var claims = new[] { @@ -29,13 +30,55 @@ public class JwtService : IJwtService }; var singing = new SigningCredentials( - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtOption.SecretKeу)), + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKeу)), SecurityAlgorithms.HmacSha256Signature); - var token = new JwtSecurityToken(signingCredentials: singing, - expires: DateTime.UtcNow.AddHours(_jwtOption.Hours), + var token = new JwtSecurityToken( + expires: DateTime.UtcNow.AddMinutes(_jwtAccessOption.Minutes), + signingCredentials: singing, claims: claims); return new JwtSecurityTokenHandler().WriteToken(token); } + + public async Task GenerateRefreshTokenAsync(User user) + { + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKeу)); + var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); + + var claims = new[] + { + new Claim("userId", user.Id.ToString()), + new Claim("tokenType", "refresh") + }; + + var token = new JwtSecurityToken( + expires: DateTime.UtcNow.AddDays(_refreshOptions.RefreshTokenLifetimeDays), + signingCredentials: creds, + claims: claims + ); + + return new JwtSecurityTokenHandler().WriteToken(token); + } + + public ClaimsPrincipal GetPrincipalFromExpiredToken(string token) + { + var tokenValidationParameters = new TokenValidationParameters + { + ValidateAudience = false, + ValidateIssuer = false, + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKeу)), + ValidateLifetime = false // << important + }; + + var handler = new JwtSecurityTokenHandler(); + var principal = handler.ValidateToken(token, tokenValidationParameters, out var securityToken); + + if (securityToken is not JwtSecurityToken jwtToken || jwtToken.Header.Alg != SecurityAlgorithms.HmacSha256) + throw new SecurityTokenException("Invalid token"); + + return principal; + } + } \ No newline at end of file diff --git a/Govor.Application/Services/InvitesService.cs b/Govor.Application/Services/InvitesService.cs index 0a5a240..a9ea40e 100644 --- a/Govor.Application/Services/InvitesService.cs +++ b/Govor.Application/Services/InvitesService.cs @@ -1,5 +1,5 @@ -using Govor.API.Services.Authentication.Interfaces; using Govor.Application.Exceptions.InvitesService; +using Govor.Application.Interfaces.Authentication; using Govor.Core.Models; using Govor.Core.Models.Users; using Govor.Core.Repositories.Invaites; diff --git a/Govor.Application/Services/Messages/MediaService.cs b/Govor.Application/Services/Messages/MediaService.cs index 21bba28..547e393 100644 --- a/Govor.Application/Services/Messages/MediaService.cs +++ b/Govor.Application/Services/Messages/MediaService.cs @@ -67,7 +67,7 @@ public class MediaService : IMediaService var mediaFile = await _dbContext.MediaFiles .AsNoTracking() .FirstOrDefaultAsync(x => x.Id == mediaId) - ?? throw new KeyNotFoundException("No media found"); + ?? throw new KeyNotFoundException($"No media found by given id {mediaId}"); // Загрузить бинарные данные из хранилища Stream dataStream = await _storageService.LoadAsync(mediaFile.Url); @@ -83,8 +83,8 @@ public class MediaService : IMediaService return new Media( mediaFile.UploaderId, mediaFile.DateCreated, + mediaFile.MediaType.ToString(), contentBytes, - string.Empty, mediaFile.MediaType, mediaFile.MineType, string.Empty @@ -96,5 +96,4 @@ public class MediaService : IMediaService throw; } } - } \ No newline at end of file diff --git a/Govor.Application/Services/UserSessions/UserSessionOpener.cs b/Govor.Application/Services/UserSessions/UserSessionOpener.cs new file mode 100644 index 0000000..e0519f3 --- /dev/null +++ b/Govor.Application/Services/UserSessions/UserSessionOpener.cs @@ -0,0 +1,68 @@ +using Govor.Application.Interfaces.Authentication; +using Govor.Application.Interfaces.UserSession; +using Govor.Application.Services.Authentication; +using Govor.Core.Models.Users; +using Govor.Core.Repositories.UserSessionsRepository; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Govor.Application.Services.UserSessions; + +public class UserSessionOpener : IUserSessionOpener +{ + private readonly IUserSessionsRepository _repository; + private readonly IJwtService _jwtService; + private readonly ILogger _logger; + private readonly JwtRefreshOption _options; + + public UserSessionOpener(IUserSessionsRepository repository, IJwtService jwtService, IOptions options, ILogger logger) + { + _jwtService = jwtService; + _repository = repository; + _logger = logger; + _options = options.Value; + } + public async Task OpenSessionAsync(User user, string deviceInfo) + { + _logger.LogInformation($"Opening session for user {user.Id} on device '{deviceInfo}'"); + + var sessions = await _repository.GetByUserIdAsync(user.Id); + var session = sessions.FirstOrDefault(s => s.DeviceInfo == deviceInfo); + + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + var newExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays); + + if (session is not null) + { + if (session.IsRevoked || session.ExpiresAt <= DateTime.UtcNow) + { + // Update Session + session.RefreshToken = newRefreshToken; + session.ExpiresAt = newExpiresAt; + session.CreatedAt = DateTime.UtcNow; + session.IsRevoked = false; + + await _repository.UpdateAsync(session); + _logger.LogInformation($"Updated expired/revoked session for user {user.Id} on device '{deviceInfo}'"); + } + + return session.RefreshToken; + } + + // New Session + var newSession = new Core.Models.UserSession + { + UserId = user.Id, + DeviceInfo = deviceInfo, + RefreshToken = newRefreshToken, + CreatedAt = DateTime.UtcNow, + ExpiresAt = newExpiresAt, + IsRevoked = false + }; + + await _repository.AddAsync(newSession); + _logger.LogInformation($"Created new session for user {user.Id} on device '{deviceInfo}'"); + + return newRefreshToken; + } +} \ No newline at end of file diff --git a/Govor.Contracts/Govor.Contracts.csproj b/Govor.Contracts/Govor.Contracts.csproj index 1618bf5..6427a81 100644 --- a/Govor.Contracts/Govor.Contracts.csproj +++ b/Govor.Contracts/Govor.Contracts.csproj @@ -11,9 +11,6 @@ - - ..\..\..\..\..\Program Files\dotnet\shared\Microsoft.AspNetCore.App\8.0.17\Microsoft.AspNetCore.Http.Features.dll - + - diff --git a/Govor.Contracts/Requests/LoginRequest.cs b/Govor.Contracts/Requests/LoginRequest.cs index f36455e..f7a0b11 100644 --- a/Govor.Contracts/Requests/LoginRequest.cs +++ b/Govor.Contracts/Requests/LoginRequest.cs @@ -13,4 +13,5 @@ public class LoginRequest [Required] [MinLength(8)] public string Password { get; init; } + public string DeviceInfo { get; init; } } \ No newline at end of file diff --git a/Govor.Contracts/Requests/MediaUploadRequest.cs b/Govor.Contracts/Requests/MediaUploadRequest.cs index 32a8a99..4c3a74b 100644 --- a/Govor.Contracts/Requests/MediaUploadRequest.cs +++ b/Govor.Contracts/Requests/MediaUploadRequest.cs @@ -7,9 +7,11 @@ namespace Govor.Contracts.Requests; public class MediaUploadRequest { [Required] - public IFormFile Data { get; set; } - public string FileName { get; set; } - public string EncryptedKey { get; set; } = string.Empty; + public IFormFile FromFile { get; set; } + [Required] public MediaType Type { get; set; } + [Required, MaxLength(255)] public string MimeType { get; set; } = string.Empty; + [Required] + public string EncryptedKey { get; set; } = string.Empty; } \ No newline at end of file diff --git a/Govor.Contracts/Requests/RegistrationRequest.cs b/Govor.Contracts/Requests/RegistrationRequest.cs index 7189bc8..eb130c8 100644 --- a/Govor.Contracts/Requests/RegistrationRequest.cs +++ b/Govor.Contracts/Requests/RegistrationRequest.cs @@ -15,4 +15,5 @@ public record RegistrationRequest public string Password { get; init; } [MinLength(InvitationValidator.MIN_INVITATION_LENGTH)] public string InviteLink { get; init; } + public string DeviceInfo { get; init; } } diff --git a/Govor.Core/Models/UserSession.cs b/Govor.Core/Models/UserSession.cs new file mode 100644 index 0000000..23ce695 --- /dev/null +++ b/Govor.Core/Models/UserSession.cs @@ -0,0 +1,25 @@ +namespace Govor.Core.Models; + +public class UserSession +{ + public Guid Id { get; set; } = Guid.NewGuid(); + public Guid UserId { get; set; } + public string RefreshToken { get; set; } = string.Empty; + public string DeviceInfo { get; set; } = string.Empty; // например, "Chrome on Windows" + public DateTime CreatedAt { get; set; } = DateTime.UtcNow; + public DateTime ExpiresAt { get; set; } + public bool IsRevoked { get; set; } = false; + + public override bool Equals(object? obj) + { + UserSession? userSession = obj as UserSession; + + return Id == userSession.Id && + UserId == userSession.UserId && + RefreshToken == userSession.RefreshToken && + DeviceInfo == userSession.DeviceInfo && + CreatedAt == userSession.CreatedAt && + ExpiresAt == userSession.ExpiresAt && + IsRevoked == userSession.IsRevoked; + } +} \ No newline at end of file diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs new file mode 100644 index 0000000..38a16fd --- /dev/null +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs @@ -0,0 +1,10 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.UserSessionsRepository; + +public interface IUserSessionsExist +{ + public bool Exist(Guid sessionId); + public bool Exist(string refresh); + public bool Exist(UserSession userSession); +} \ No newline at end of file diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs new file mode 100644 index 0000000..0c6e51f --- /dev/null +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs @@ -0,0 +1,13 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.UserSessionsRepository; + +public interface IUserSessionsReader +{ + public Task> GetAllAsync(); + public Task GetByIdAsync(Guid sessionId); + public Task> GetByUserIdAsync(Guid userId); + public Task> GetByCreatedAtAsync(DateTime createdAt); + public Task> GetByExpiresAtAsync(DateTime createdAt); + public Task> GetByRevokedAsync(bool isRevoked); +} \ No newline at end of file diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsRepository.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsRepository.cs new file mode 100644 index 0000000..35061b7 --- /dev/null +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsRepository.cs @@ -0,0 +1,8 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.UserSessionsRepository; + +public interface IUserSessionsRepository : IUserSessionsReader, IUserSessionsWriter, IUserSessionsExist +{ + +} \ No newline at end of file diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs new file mode 100644 index 0000000..aa6eeaa --- /dev/null +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs @@ -0,0 +1,11 @@ +using Govor.Core.Models; + +namespace Govor.Core.Repositories.UserSessionsRepository; + +public interface IUserSessionsWriter +{ + Task AddAsync(UserSession userSession); + Task UpdateAsync(UserSession userSession); + Task RemoveAsync(Guid sessionId); + Task RemoveByUserIdAsync(Guid userId); +} \ No newline at end of file diff --git a/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs b/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs new file mode 100644 index 0000000..632a8bd --- /dev/null +++ b/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs @@ -0,0 +1,274 @@ +using AutoFixture; +using Govor.Core.Models; +using Govor.Data.Repositories; +using Govor.Data.Repositories.Exceptions; +using Microsoft.EntityFrameworkCore; + +namespace Govor.Data.Tests.Repositories; + +[TestFixture] +public class UserSessionsRepositoryTests +{ + private Fixture _fixture; + private DbContextOptions _options; + private int _testIteration = 0; + + [SetUp] + public void SetUp() + { + _testIteration += 1; + + _fixture = new Fixture(); + + _fixture.Behaviors + .OfType() + .ToList() + .ForEach(b => _fixture.Behaviors.Remove(b)); + + _fixture.Behaviors.Add(new OmitOnRecursionBehavior()); + + _options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: $"DbGovor_{nameof(UserSessionsRepositoryTests)}_{_testIteration}") + .Options; + } + + [Test] + public async Task GetAllSessions_ReturnsAllSessions() + { + // Arrange + var random = new Random(); + var sessions = _fixture.CreateMany(random.Next(2, 10)).ToList(); + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + + // Act + var result = await repository.GetAllAsync(); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Count, Is.EqualTo(sessions.Count)); + Assert.That(result, Is.EquivalentTo(sessions)); + } + + [Test] + public void Given_EmptySetDb_When_GetAll_Should_Throw_NotFoundException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + // Act & Assert + Assert.ThrowsAsync(async () => await repository.GetAllAsync()); + } + + [Test] + public async Task Given_ValidId_When_GetByIdAsync_ShouldReturnSession() + { + // Arrange + var random = new Random(); + var sessions = _fixture.CreateMany(random.Next(2, 10)).ToList(); + + var id = sessions.First().Id; + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + // Act + var result = await repository.GetByIdAsync(id); + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result.Id, Is.EqualTo(id)); + Assert.That(result, Is.EqualTo(sessions.First())); + } + + [Test] + public void Given_InvalidId_When_GetByIdAsync_Should_Throw_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + // Act & Assert + Assert.ThrowsAsync>(async () => await repository.GetByIdAsync(_fixture.Create())); + } + + [Test] + public async Task Given_ValidCreatedAt_When_GetByCreatedAtAsync_ShouldReturnSession() + { + // Arrange + var random = new Random(); + var sessions = _fixture.CreateMany(random.Next(2, 10)).ToList(); + + var created = sessions.First().CreatedAt; + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + + // Act + var list = await repository.GetByCreatedAtAsync(created); + + + // Assert + var result = list.First(); + Assert.That(result, Is.Not.Null); + Assert.That(result.CreatedAt, Is.EqualTo(created)); + Assert.That(result, Is.EqualTo(sessions.First())); + } + + [Test] + public void Given_ValidCreatedAt_When_GetByCreatedAtAsync_Should_Throw_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + // Act & Assert + Assert.ThrowsAsync>(async () => await repository.GetByCreatedAtAsync(_fixture.Create())); + } + + [Test] + public async Task Given_ValidExpiresAt_When_GetByExpiresAtAsync_ShouldReturnSession() + { + // Arrange + var random = new Random(); + var sessions = _fixture.CreateMany(random.Next(2, 10)).ToList(); + + var expiresAt = sessions.First().ExpiresAt; + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + + // Act + var list = await repository.GetByExpiresAtAsync(expiresAt); + + // Assert + var result = list.First(); + Assert.That(result, Is.Not.Null); + Assert.That(result.ExpiresAt, Is.EqualTo(expiresAt)); + Assert.That(result, Is.EqualTo(sessions.First())); + } + + [Test] + public void Given_ValidExpiresAt_When_GetByExpiresAtAsync_Should_Throw_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + // Act & Assert + Assert.ThrowsAsync>(async () => await repository.GetByExpiresAtAsync(_fixture.Create())); + } + + [Test] + public async Task Given_isRevoked_When_GetByRevokedAsync_ShouldReturnSession() + { + // Arrange + var random = new Random(); + var sessions = _fixture.Build() + .With(f => f.IsRevoked, true) + .CreateMany(random.Next(2, 10)).ToList(); + + var isRevoke = sessions.First().IsRevoked; + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + + // Act + var result = await repository.GetByRevokedAsync(isRevoke); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.EquivalentTo(sessions)); + } + + [Test] + public void Given_InvalidRevoked_When_GetByExpiresAtAsync_Should_Throw_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + // Act & Assert + Assert.ThrowsAsync>(async () => await repository.GetByRevokedAsync(false)); + } + + [Test] + public async Task Given_ValidUserSessions_When_AddAsync_Then_UserSessionsAdded() + { + // Arrange + var session = _fixture.Create(); + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + // Act + await repository.AddAsync(session); + + // Assert + Assert.That(context.UserSessions.Count, Is.EqualTo(1)); + Assert.That(context.UserSessions.First(), Is.EqualTo(session)); + } + + [Test] + public async Task Given_InvalidUserSessions_When_AddAsync_Should_Throw_AdditionException() + { + // Arrange + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + // Act & Assert + Assert.ThrowsAsync(async () => await repository.AddAsync(default)); + } + + [Test] + public async Task Given_ExistUserSessions_When_Exist_Then_ReturnTrue() + { + // Arrange + var session = _fixture.Create(); + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.Add(session); + await context.SaveChangesAsync(); + + // Act + var result1 = repository.Exist(session); + var result2 = repository.Exist(session.Id); + var result3 = repository.Exist(session.RefreshToken); + + // Assert + Assert.That(result1, Is.True); + Assert.That(result2, Is.True); + Assert.That(result3, Is.True); + } + + [Test] + public async Task Given_NotExistUserSessions_When_Exist_Then_ReturnFalse() + { + // Arrange + var session = _fixture.Create(); + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + // Act + var result1 = repository.Exist(session); + var result2 = repository.Exist(session.Id); + var result3 = repository.Exist(session.RefreshToken); + // Assert + Assert.That(result1, Is.False); + Assert.That(result2, Is.False); + Assert.That(result3, Is.False); + } +} \ No newline at end of file diff --git a/Govor.Data/Configurations/UserSessionConfiguration.cs b/Govor.Data/Configurations/UserSessionConfiguration.cs new file mode 100644 index 0000000..0c0c297 --- /dev/null +++ b/Govor.Data/Configurations/UserSessionConfiguration.cs @@ -0,0 +1,25 @@ +using Govor.Core.Models; +using Govor.Core.Models.Users; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Govor.Data.Configurations; + +public class UserSessionConfiguration : IEntityTypeConfiguration +{ + public void Configure(EntityTypeBuilder builder) + { + builder.HasKey(us => us.Id); + + builder.Property(us => us.RefreshToken) + .IsRequired(); + + builder.Property(us => us.DeviceInfo) + .HasMaxLength(200); + + builder.HasOne() + .WithMany() + .HasForeignKey(us => us.UserId) + .OnDelete(DeleteBehavior.Cascade); + } +} \ No newline at end of file diff --git a/Govor.Data/GovorDbContext.cs b/Govor.Data/GovorDbContext.cs index 040314a..47ce2fc 100644 --- a/Govor.Data/GovorDbContext.cs +++ b/Govor.Data/GovorDbContext.cs @@ -1,4 +1,3 @@ -using System.Text.RegularExpressions; using Govor.Core.Models; using Govor.Core.Models.Messages; using Govor.Core.Models.Users; @@ -10,6 +9,7 @@ namespace Govor.Data; public class GovorDbContext(DbContextOptions options) : DbContext(options) { public virtual DbSet Users { get; set; } + public virtual DbSet UserSessions { get; set; } public virtual DbSet Friendships { get; set; } public virtual DbSet PrivateChats { get; set; } public virtual DbSet Admins { get; set; } @@ -29,6 +29,7 @@ public class GovorDbContext(DbContextOptions options) : DbContex protected override void OnModelCreating(ModelBuilder modelBuilder) { + modelBuilder.ApplyConfiguration(new UserSessionConfiguration()); modelBuilder.ApplyConfiguration(new FriendshipConfiguration()); modelBuilder.ApplyConfiguration(new UserConfiguration()); modelBuilder.ApplyConfiguration(new InvitationConfiguration()); diff --git a/Govor.Data/Migrations/20250718130008_usersessions.Designer.cs b/Govor.Data/Migrations/20250718130008_usersessions.Designer.cs new file mode 100644 index 0000000..e33657b --- /dev/null +++ b/Govor.Data/Migrations/20250718130008_usersessions.Designer.cs @@ -0,0 +1,639 @@ +// +using System; +using Govor.Data; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Metadata; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Govor.Data.Migrations +{ + [DbContext(typeof(GovorDbContext))] + [Migration("20250718130008_usersessions")] + partial class usersessions + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "8.0.6") + .HasAnnotation("Relational:MaxIdentifierLength", 64); + + MySqlModelBuilderExtensions.AutoIncrementColumns(modelBuilder); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("ImageId") + .HasColumnType("char(36)"); + + b.Property("IsChannel") + .HasColumnType("tinyint(1)"); + + b.Property("IsPrivate") + .HasColumnType("tinyint(1)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("varchar(100)"); + + b.HasKey("Id"); + + b.ToTable("ChatGroups"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("AddresseeId") + .HasColumnType("char(36)"); + + b.Property("RequesterId") + .HasColumnType("char(36)"); + + b.Property("Status") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.HasIndex("AddresseeId"); + + b.HasIndex("RequesterId"); + + b.ToTable("Friendships"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.ToTable("GroupAdmins"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("varchar(500)"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InvitationCode") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.Property("UserMakerId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("UserMakerId"); + + b.ToTable("GroupInvitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("GroupId") + .HasColumnType("char(36)"); + + b.Property("InvitationId") + .IsRequired() + .HasColumnType("char(36)"); + + b.Property("IsBanned") + .HasColumnType("tinyint(1)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("GroupId"); + + b.HasIndex("InvitationId"); + + b.ToTable("GroupMemberships"); + }); + + modelBuilder.Entity("Govor.Core.Models.Invitation", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("Code") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("DateCreated") + .HasColumnType("datetime(6)"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("EndDate") + .HasColumnType("datetime(6)"); + + b.Property("IsActive") + .HasColumnType("tinyint(1)"); + + b.Property("IsAdmin") + .HasColumnType("tinyint(1)"); + + b.Property("MaxParticipants") + .HasColumnType("int"); + + b.HasKey("Id"); + + b.ToTable("Invitations"); + }); + + modelBuilder.Entity("Govor.Core.Models.MediaFile", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("DateCreated") + .HasColumnType("datetime(6)"); + + b.Property("MediaType") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("MineType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("varchar(128)"); + + b.Property("UploaderId") + .HasColumnType("char(36)"); + + b.Property("Url") + .IsRequired() + .HasColumnType("longtext"); + + b.HasKey("Id"); + + b.ToTable("MediaFiles"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MediaFileId") + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("MediaFileId"); + + b.HasIndex("MessageId"); + + b.ToTable("MediaAttachments"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("ChatGroupId") + .HasColumnType("char(36)"); + + b.Property("EditedAt") + .HasColumnType("datetime(6)"); + + b.Property("EncryptedContent") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IsEdited") + .HasColumnType("tinyint(1)"); + + b.Property("PrivateChatId") + .HasColumnType("char(36)"); + + b.Property("RecipientId") + .HasColumnType("char(36)"); + + b.Property("RecipientType") + .HasColumnType("int"); + + b.Property("ReplyToMessageId") + .HasColumnType("char(36)"); + + b.Property("SenderId") + .HasColumnType("char(36)"); + + b.Property("SentAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("ChatGroupId"); + + b.HasIndex("PrivateChatId"); + + b.ToTable("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.Property("ReactedAt") + .HasColumnType("datetime(6)"); + + b.Property("ReactionCode") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("varchar(64)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("MessageReactions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("MessageId") + .HasColumnType("char(36)"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.Property("ViewedAt") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("MessageId", "UserId") + .IsUnique(); + + b.ToTable("MessageViews"); + }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("UserAId") + .HasColumnType("char(36)"); + + b.Property("UserBId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.ToTable("PrivateChats"); + }); + + modelBuilder.Entity("Govor.Core.Models.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeviceInfo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRevoked") + .HasColumnType("tinyint(1)"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("UserId"); + + b.ToTable("Admins"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedOn") + .HasColumnType("date"); + + b.Property("Description") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("IconId") + .HasColumnType("char(36)"); + + b.Property("InviteId") + .HasColumnType("char(36)"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("Username") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("WasOnline") + .HasColumnType("datetime(6)"); + + b.HasKey("Id"); + + b.HasIndex("InviteId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Friendship", b => + { + b.HasOne("Govor.Core.Models.Users.User", "Addressee") + .WithMany("ReceivedFriendRequests") + .HasForeignKey("AddresseeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "Requester") + .WithMany("SentFriendRequests") + .HasForeignKey("RequesterId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Addressee"); + + b.Navigation("Requester"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupAdmins", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Admins") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupInvitation", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("InviteCodes") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "UserMaker") + .WithMany() + .HasForeignKey("UserMakerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("UserMaker"); + }); + + modelBuilder.Entity("Govor.Core.Models.GroupMembership", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Members") + .HasForeignKey("GroupId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.GroupInvitation", null) + .WithMany() + .HasForeignKey("InvitationId") + .OnDelete(DeleteBehavior.SetNull) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MediaAttachments", b => + { + b.HasOne("Govor.Core.Models.MediaFile", "MediaFile") + .WithMany() + .HasForeignKey("MediaFileId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Messages.Message", "Message") + .WithMany("MediaAttachments") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("MediaFile"); + + b.Navigation("Message"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.HasOne("Govor.Core.Models.ChatGroup", null) + .WithMany("Messages") + .HasForeignKey("ChatGroupId"); + + b.HasOne("Govor.Core.Models.PrivateChat", null) + .WithMany("Messages") + .HasForeignKey("PrivateChatId"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageReaction", b => + { + b.HasOne("Govor.Core.Models.Messages.Message", "Message") + .WithMany("Reactions") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Govor.Core.Models.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Message"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.MessageView", b => + { + b.HasOne("Govor.Core.Models.Messages.Message", null) + .WithMany("MessageViews") + .HasForeignKey("MessageId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.UserSession", b => + { + b.HasOne("Govor.Core.Models.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => + { + b.HasOne("Govor.Core.Models.Users.User", "User") + .WithOne() + .HasForeignKey("Govor.Core.Models.Users.Admin", "UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.HasOne("Govor.Core.Models.Invitation", "Invite") + .WithMany("Users") + .HasForeignKey("InviteId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Invite"); + }); + + modelBuilder.Entity("Govor.Core.Models.ChatGroup", b => + { + b.Navigation("Admins"); + + b.Navigation("InviteCodes"); + + b.Navigation("Members"); + + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Invitation", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("Govor.Core.Models.Messages.Message", b => + { + b.Navigation("MediaAttachments"); + + b.Navigation("MessageViews"); + + b.Navigation("Reactions"); + }); + + modelBuilder.Entity("Govor.Core.Models.PrivateChat", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Govor.Core.Models.Users.User", b => + { + b.Navigation("ReceivedFriendRequests"); + + b.Navigation("SentFriendRequests"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/Govor.Data/Migrations/20250718130008_usersessions.cs b/Govor.Data/Migrations/20250718130008_usersessions.cs new file mode 100644 index 0000000..a9f01c0 --- /dev/null +++ b/Govor.Data/Migrations/20250718130008_usersessions.cs @@ -0,0 +1,53 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Govor.Data.Migrations +{ + /// + public partial class usersessions : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "UserSessions", + columns: table => new + { + Id = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + UserId = table.Column(type: "char(36)", nullable: false, collation: "ascii_general_ci"), + RefreshToken = table.Column(type: "longtext", nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + DeviceInfo = table.Column(type: "varchar(200)", maxLength: 200, nullable: false) + .Annotation("MySql:CharSet", "utf8mb4"), + CreatedAt = table.Column(type: "datetime(6)", nullable: false), + ExpiresAt = table.Column(type: "datetime(6)", nullable: false), + IsRevoked = table.Column(type: "tinyint(1)", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_UserSessions", x => x.Id); + table.ForeignKey( + name: "FK_UserSessions_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }) + .Annotation("MySql:CharSet", "utf8mb4"); + + migrationBuilder.CreateIndex( + name: "IX_UserSessions_UserId", + table: "UserSessions", + column: "UserId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "UserSessions"); + } + } +} diff --git a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs index 33d36a5..49f1d34 100644 --- a/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs +++ b/Govor.Data/Migrations/GovorDbContextModelSnapshot.cs @@ -364,6 +364,40 @@ namespace Govor.Data.Migrations b.ToTable("PrivateChats"); }); + modelBuilder.Entity("Govor.Core.Models.UserSession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("char(36)"); + + b.Property("CreatedAt") + .HasColumnType("datetime(6)"); + + b.Property("DeviceInfo") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("varchar(200)"); + + b.Property("ExpiresAt") + .HasColumnType("datetime(6)"); + + b.Property("IsRevoked") + .HasColumnType("tinyint(1)"); + + b.Property("RefreshToken") + .IsRequired() + .HasColumnType("longtext"); + + b.Property("UserId") + .HasColumnType("char(36)"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserSessions"); + }); + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => { b.Property("UserId") @@ -529,6 +563,15 @@ namespace Govor.Data.Migrations .IsRequired(); }); + modelBuilder.Entity("Govor.Core.Models.UserSession", b => + { + b.HasOne("Govor.Core.Models.Users.User", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + modelBuilder.Entity("Govor.Core.Models.Users.Admin", b => { b.HasOne("Govor.Core.Models.Users.User", "User") diff --git a/Govor.Data/Repositories/UserSessionsRepository.cs b/Govor.Data/Repositories/UserSessionsRepository.cs new file mode 100644 index 0000000..a4371f4 --- /dev/null +++ b/Govor.Data/Repositories/UserSessionsRepository.cs @@ -0,0 +1,162 @@ +using Govor.Core.Infrastructure.Extensions; +using Govor.Core.Models; +using Govor.Core.Repositories.UserSessionsRepository; +using Govor.Data.Repositories.Exceptions; +using Microsoft.EntityFrameworkCore; + +namespace Govor.Data.Repositories; + +public class UserSessionsRepository : IUserSessionsRepository +{ + private GovorDbContext _context; + + public UserSessionsRepository(GovorDbContext context) + { + _context = context; + } + + public async Task> GetAllAsync() + { + return await _context.UserSessions + .AsNoTracking() + .ToListOrThrowIfEmpty(new NotFoundException("Database is empty")); + } + + public async Task GetByIdAsync(Guid sessionId) + { + return await _context.UserSessions + .AsNoTracking() + .FirstOrDefaultAsync(session => session.Id == sessionId) + ?? throw new NotFoundByKeyException(sessionId, "Session with given id does not exist"); + } + + public async Task> GetByUserIdAsync(Guid userId) + { + return await _context.UserSessions + .AsNoTracking() + .Where(session => session.UserId == userId) + .ToListOrThrowIfEmpty(new NotFoundByKeyException(userId, "Sessions with given user id does not exist")); + } + + public async Task> GetByCreatedAtAsync(DateTime createdAt) + { + return await _context.UserSessions + .AsNoTracking() + .Where(session => session.CreatedAt == createdAt) + .ToListOrThrowIfEmpty(new NotFoundByKeyException(createdAt, "Sessions with given created at does not exist")); + } + + public async Task> GetByExpiresAtAsync(DateTime expiresAt) + { + return await _context.UserSessions + .AsNoTracking() + .Where(session => session.ExpiresAt == expiresAt) + .ToListOrThrowIfEmpty(new NotFoundByKeyException(expiresAt, "Sessions with given expires at does not exist")); + } + + public async Task> GetByRevokedAsync(bool isRevoked) + { + return await _context.UserSessions + .AsNoTracking() + .Where(session => session.IsRevoked == isRevoked) + .ToListOrThrowIfEmpty(new NotFoundByKeyException(isRevoked, "Sessions is revoked does not exist")); + } + + public async Task AddAsync(UserSession userSession) + { + try + { + _context.UserSessions.Add(userSession); + await _context.SaveChangesAsync(); + } + catch (Exception ex) + { + throw new AdditionException("Failed to add user session", ex); + } + } + + public async Task UpdateAsync(UserSession userSession) + { + try + { + //_validator.Validate(user); + + var rowsAffected = await _context.UserSessions + .Where(u => u.Id == userSession.Id) + .ExecuteUpdateAsync(u => u + .SetProperty(a => a.UserId, userSession.UserId) + .SetProperty(u => u.RefreshToken, userSession.RefreshToken) + .SetProperty(u => u.DeviceInfo, userSession.DeviceInfo) + .SetProperty(u => u.CreatedAt, userSession.CreatedAt) + .SetProperty(u => u.ExpiresAt, userSession.ExpiresAt) + .SetProperty(u => u.IsRevoked, userSession.IsRevoked) + ); + + if (rowsAffected == 0) + throw new UpdateException($"Not found user session by given id {userSession.Id}"); + } + catch (Exception ex) + { + throw new UpdateException($"Error when updating the user session {userSession.Id}", ex); + } + } + + public async Task RemoveAsync(Guid sessionId) + { + try + { + var result = await GetByIdAsync(sessionId); + + _context.UserSessions.Remove(result); + await _context.SaveChangesAsync(); + } + catch (NotFoundByKeyException ex) + { + throw new RemoveException($"Not found session by given id {sessionId}", ex); + } + catch (Exception ex) + { + throw new RemoveException("Error when removing the session", ex); + } + } + + public async Task RemoveByUserIdAsync(Guid userId) + { + try + { + var result = await GetByUserIdAsync(userId); + + _context.UserSessions.RemoveRange(result); + await _context.SaveChangesAsync(); + } + catch (NotFoundByKeyException ex) + { + throw new RemoveException($"Not found user sessions by given user id {userId}", ex); + } + catch (Exception ex) + { + throw new RemoveException("Error when removing the user sessions", ex); + } + } + + public bool Exist(Guid sessionId) + { + return _context.UserSessions.Any(session => session.Id == sessionId); + } + + public bool Exist(string refresh) + { + return _context.UserSessions.Any(session => session.RefreshToken == refresh); + } + + public bool Exist(UserSession userSession) + { + return _context.UserSessions.Any(session => session.Id == userSession.Id && + session.RefreshToken == userSession.RefreshToken && + session.IsRevoked == userSession.IsRevoked && + session.CreatedAt == userSession.CreatedAt && + session.ExpiresAt == userSession.ExpiresAt && + session.DeviceInfo == userSession.DeviceInfo && + session.UserId == userSession.UserId); + } +} \ No newline at end of file diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..f2bd515 --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,29 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# +version: "1.0" + +#Specify IDE code to run analysis without container (Applied in CI/CD pipeline) +ide: QDNET + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com)