From ab643c16a4d57c4515d89d5e028b9f1ae3281475 Mon Sep 17 00:00:00 2001 From: Artemy <109195690+stalcker2288969@users.noreply.github.com> Date: Fri, 18 Jul 2025 20:16:36 +0700 Subject: [PATCH] Implement user session management and JWT refresh tokens Added user session models, interfaces, repository, and service for managing user sessions and refresh tokens. Refactored authentication flow to return user objects and open sessions with device info, supporting refresh token generation and validation. Updated JWT configuration to separate access and refresh options, and refactored related tests and API contracts. Improved media upload handling and error logging. Migrated dependency references and DI registrations accordingly. --- .github/workflows/qodana.yml | 22 + .../Controllers/AuthControllerTests.cs | 30 +- .../AdminStuff/InviteUserController.cs | 2 +- .../Controllers/AdminStuff/UsersController.cs | 1 - Govor.API/Controllers/AuthController.cs | 75 +- Govor.API/Controllers/ChatLoadController.cs | 2 +- Govor.API/Controllers/MediaController.cs | 32 +- .../ConfigurationProgramExtensions.cs | 10 +- Govor.API/Govor.API.csproj | 4 + Govor.API/Program.cs | 2 +- Govor.API/appsettings.json | 11 +- .../AdminsStuff/InvitationGeneratorTests.cs | 2 +- .../Authentication/AuthServiceTests.cs | 3 +- .../Authentication/JwtServiceTests.cs | 90 ++- .../UserSessions/UserSessionOpenerTests.cs | 127 ++++ .../AdminsStuff/InvitationGenerator.cs | 4 +- .../Interfaces/Authentication/IAuthService.cs | 5 +- .../Authentication/IInvitesService.cs | 2 +- .../Interfaces/Authentication/IJwtService.cs | 5 +- .../Interfaces/IInvitionGenerator.cs | 2 +- .../Interfaces/Medias/IMediaService.cs | 2 +- .../UserSession/IUserSessionOpener.cs | 10 + .../UserSession/IUserSessionReader.cs | 6 + .../UserSession/IUserSessionRevoker.cs | 8 + .../Services/Authentication/AuthService.cs | 33 +- .../{JwtOption.cs => JwtAccessOption.cs} | 4 +- .../Authentication/JwtRefreshOption.cs | 6 + .../Services/Authentication/JwtService.cs | 59 +- Govor.Application/Services/InvitesService.cs | 2 +- .../Services/Messages/MediaService.cs | 5 +- .../UserSessions/UserSessionOpener.cs | 68 ++ Govor.Contracts/Govor.Contracts.csproj | 5 +- Govor.Contracts/Requests/LoginRequest.cs | 1 + .../Requests/MediaUploadRequest.cs | 8 +- .../Requests/RegistrationRequest.cs | 1 + Govor.Core/Models/UserSession.cs | 25 + .../IUserSessionsExist.cs | 10 + .../IUserSessionsReader.cs | 13 + .../IUserSessionsRepository.cs | 8 + .../IUserSessionsWriter.cs | 11 + .../UserSessionsRepositoryTests.cs | 274 ++++++++ .../UserSessionConfiguration.cs | 25 + Govor.Data/GovorDbContext.cs | 3 +- .../20250718130008_usersessions.Designer.cs | 639 ++++++++++++++++++ .../Migrations/20250718130008_usersessions.cs | 53 ++ .../Migrations/GovorDbContextModelSnapshot.cs | 43 ++ .../Repositories/UserSessionsRepository.cs | 162 +++++ qodana.yaml | 29 + 48 files changed, 1860 insertions(+), 84 deletions(-) create mode 100644 .github/workflows/qodana.yml create mode 100644 Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs create mode 100644 Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs create mode 100644 Govor.Application/Interfaces/UserSession/IUserSessionReader.cs create mode 100644 Govor.Application/Interfaces/UserSession/IUserSessionRevoker.cs rename Govor.Application/Services/Authentication/{JwtOption.cs => JwtAccessOption.cs} (58%) create mode 100644 Govor.Application/Services/Authentication/JwtRefreshOption.cs create mode 100644 Govor.Application/Services/UserSessions/UserSessionOpener.cs create mode 100644 Govor.Core/Models/UserSession.cs create mode 100644 Govor.Core/Repositories/UserSessionsRepository/IUserSessionsExist.cs create mode 100644 Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs create mode 100644 Govor.Core/Repositories/UserSessionsRepository/IUserSessionsRepository.cs create mode 100644 Govor.Core/Repositories/UserSessionsRepository/IUserSessionsWriter.cs create mode 100644 Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs create mode 100644 Govor.Data/Configurations/UserSessionConfiguration.cs create mode 100644 Govor.Data/Migrations/20250718130008_usersessions.Designer.cs create mode 100644 Govor.Data/Migrations/20250718130008_usersessions.cs create mode 100644 Govor.Data/Repositories/UserSessionsRepository.cs create mode 100644 qodana.yaml 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)