diff --git a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs index 994f1ec..d216655 100644 --- a/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs +++ b/Govor.API.Tests/IntegrationTests/Controllers/AuthControllerTests.cs @@ -1,5 +1,5 @@ using AutoFixture; -using Govor.API.Controllers; +using Govor.API.Controllers.Authentication; using Govor.Application.Exceptions.AuthService; using Govor.Application.Exceptions.InvitesService; using Govor.Application.Interfaces.Authentication; @@ -50,7 +50,7 @@ public class AuthControllerTests // Arrange var request = _fixture.Create(); var invitation = _fixture.Create(); - var token = _fixture.Create(); + var token = _fixture.Create(); var user = _fixture.Build() .With(x => x.Username).Create(); @@ -70,8 +70,11 @@ public class AuthControllerTests // Assert Assert.That(result, Is.InstanceOf()); var okResult = result as OkObjectResult; - dynamic value = okResult.Value; - Assert.That((string)value.GetType().GetProperty("token").GetValue(value, null), Is.EqualTo(token)); + + var response = okResult?.Value as RefreshResult; + Assert.That(response, Is.Not.Null); + Assert.That(response.accessToken, Is.EqualTo(token.accessToken)); + Assert.That(response.refreshToken, Is.EqualTo(token.refreshToken)); } [Test] @@ -149,7 +152,7 @@ public class AuthControllerTests { // Arrange var loginRequest = _fixture.Create(); - var token = _fixture.Create(); + var token = _fixture.Create(); var user = _fixture.Build() .With(x => x.Username).Create(); @@ -164,8 +167,11 @@ public class AuthControllerTests // Assert Assert.That(result, Is.InstanceOf()); var okResult = result as OkObjectResult; - dynamic value = okResult.Value; - Assert.That((string)value.GetType().GetProperty("token").GetValue(value, null), Is.EqualTo(token)); + + var response = okResult?.Value as RefreshResult; + Assert.That(response, Is.Not.Null); + Assert.That(response.accessToken, Is.EqualTo(token.accessToken)); + Assert.That(response.refreshToken, Is.EqualTo(token.refreshToken)); } [Test] diff --git a/Govor.API/Controllers/AuthController.cs b/Govor.API/Controllers/Authentication/AuthController.cs similarity index 74% rename from Govor.API/Controllers/AuthController.cs rename to Govor.API/Controllers/Authentication/AuthController.cs index b6e6a7e..0b97228 100644 --- a/Govor.API/Controllers/AuthController.cs +++ b/Govor.API/Controllers/Authentication/AuthController.cs @@ -6,7 +6,7 @@ using Govor.Contracts.Requests; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; -namespace Govor.API.Controllers; +namespace Govor.API.Controllers.Authentication; [ApiController] [AllowAnonymous] @@ -18,7 +18,11 @@ public class AuthController : Controller private IAccountService _accountService; private ILogger _logger; - public AuthController(IAccountService accountService, IInvitesService invitesService,IUserSessionOpener userSessionOpener, ILogger logger) + public AuthController( + IAccountService accountService, + IInvitesService invitesService, + IUserSessionOpener userSessionOpener, + ILogger logger) { _userSession = userSessionOpener; _accountService = accountService; @@ -45,7 +49,8 @@ public class AuthController : Controller 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 }); + + return Ok(token); } catch (UserAlreadyExistException ex) { @@ -85,7 +90,7 @@ public class AuthController : Controller _logger.LogInformation($"Session for user {user.Username} with id {user.Id} has been opened"); - return Ok(new { token }); + return Ok(token); } catch (UserNotRegisteredException ex) { @@ -103,39 +108,4 @@ public class AuthController : Controller 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/Authentication/RefreshController.cs b/Govor.API/Controllers/Authentication/RefreshController.cs new file mode 100644 index 0000000..1bdc065 --- /dev/null +++ b/Govor.API/Controllers/Authentication/RefreshController.cs @@ -0,0 +1,58 @@ +using Govor.Application.Interfaces.UserSession; +using Govor.Contracts.Requests; +using Govor.Contracts.Responses; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +namespace Govor.API.Controllers.Authentication; + +[Route("api/auth/token")] +public class RefreshController : Controller +{ + private readonly ILogger _logger; + private readonly IUserSessionRefresher _userSession; + + public RefreshController(ILogger logger, IUserSessionRefresher userSession) + { + _logger = logger; + _userSession = userSession; + } + + [RequireHttps] + [AllowAnonymous] + [HttpPost("refresh")] + public async Task Refresh([FromBody] RefreshTokenRequest refreshRequest) + { + try + { + if (!ModelState.IsValid) + return BadRequest(ModelState); + + if (string.IsNullOrEmpty(refreshRequest.RefreshToken)) + throw new InvalidOperationException("Refresh token cant be empty."); + + var result = await _userSession.RefreshTokenAsync(refreshRequest.RefreshToken); + + return Ok(new RefreshTokenResponse() + { + AccessToken = result.accessToken, + RefreshToken = result.refreshToken + }); + } + 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/Friends/FriendsRequestQueryController.cs b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs index f3f8bda..251765e 100644 --- a/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs +++ b/Govor.API/Controllers/Friends/FriendsRequestQueryController.cs @@ -41,7 +41,7 @@ public class FriendsRequestQueryController : Controller } catch (InvalidOperationException ex) { - _logger.LogError(ex, ex.Message); + _logger.LogWarning(ex, ex.Message); return Ok(new List()); } catch (Exception ex) @@ -67,7 +67,7 @@ public class FriendsRequestQueryController : Controller } catch (InvalidOperationException ex) { - _logger.LogError(ex, ex.Message); + _logger.LogWarning(ex, ex.Message); return Ok(new List()); } catch (Exception ex) diff --git a/Govor.API/Extensions/AddOptionExtensions.cs b/Govor.API/Extensions/AddOptionExtensions.cs new file mode 100644 index 0000000..aa0951c --- /dev/null +++ b/Govor.API/Extensions/AddOptionExtensions.cs @@ -0,0 +1,14 @@ +using Govor.Application.Services.Authentication; + +namespace Govor.API.Extensions; + +public static class AddOptionExtensions +{ + public static IServiceCollection AddOptionsConfiguration(this IServiceCollection services, IConfiguration configuration) + { + services.Configure(configuration.GetSection(nameof(JwtAccessOption))); + services.Configure(configuration.GetSection(nameof(JwtRefreshOption))); + + return services; + } +} \ No newline at end of file diff --git a/Govor.API/Extensions/ConfigurationProgramExtensions.cs b/Govor.API/Extensions/ConfigurationProgramExtensions.cs index beb91f9..d7ced36 100644 --- a/Govor.API/Extensions/ConfigurationProgramExtensions.cs +++ b/Govor.API/Extensions/ConfigurationProgramExtensions.cs @@ -71,7 +71,7 @@ public static class ConfigurationProgramExtensions // UserSession services.AddScoped(); - + services.AddScoped(); // Auto Mapper services.AddAutoMapper(typeof(MappingProfile)); } diff --git a/Govor.API/Program.cs b/Govor.API/Program.cs index b911e88..2e1886e 100644 --- a/Govor.API/Program.cs +++ b/Govor.API/Program.cs @@ -41,7 +41,7 @@ builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) ValidateLifetime = true, ValidateIssuerSigningKey = true, IssuerSigningKey = new SymmetricSecurityKey( - Encoding.UTF8.GetBytes(builder.Configuration["JwtOption:SecretKeу"]!)) + Encoding.UTF8.GetBytes(builder.Configuration["JwtAccessOption:SecretKey"]!)) }; options.Events = new JwtBearerEvents { @@ -67,6 +67,8 @@ builder.Services.AddServices(); builder.Services.AddRepositories(); builder.Services.AddValidators(); +builder.Services.AddOptionsConfiguration(configuration); + builder.Services.AddGovorDbContext(configuration); // GovorDbContext init builder.Services.AddEndpointsApiExplorer(); diff --git a/Govor.API/appsettings.json b/Govor.API/appsettings.json index c1f2289..d01d102 100644 --- a/Govor.API/appsettings.json +++ b/Govor.API/appsettings.json @@ -9,7 +9,7 @@ "GovorDbContext": "Server=147.45.255.215;Port=3306;Database=artemy_DB;User=artemy;Password=LoxHuy))228Goy;" }, "UseMySql": true, - "AllowedHosts": "govor-team-govor-88b3.twc1.net", + "AllowedHosts": "govor-team-govor-88b3.twc1.net;localhost;localhost:7155", "JwtAccessOption": { "SecretKey": "Q89eY7zP7C4+TqLmHF4kw9xkF1E8Ru4Zpg+up9wFt9g=", "Minutes": 25 diff --git a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs index cc0b8fe..62953df 100644 --- a/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs +++ b/Govor.Application.Tests/Services/Authentication/JwtServiceTests.cs @@ -32,7 +32,7 @@ public class JwtServiceTests _testJwtAccessOptions = new JwtAccessOption { - SecretKeу = "THIS_IS_A_TEST_SECRET_KEY_THAT_IS_LONG_ENOUGH_1234", // Ensure key size is sufficient for HMACSHA256 + SecretKey = "THIS_IS_A_TEST_SECRET_KEY_THAT_IS_LONG_ENOUGH_1234", // Ensure key size is sufficient for HMACSHA256 Minutes = 5 }; @@ -98,7 +98,7 @@ public class JwtServiceTests // Arrange var userId = Guid.NewGuid(); var handler = new JwtSecurityTokenHandler(); - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_testJwtAccessOptions.SecretKeу)); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_testJwtAccessOptions.SecretKey)); var now = DateTime.UtcNow; diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs index a50ff21..490cf51 100644 --- a/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionOpenerTests.cs @@ -1,5 +1,3 @@ -using Govor.Application.Interfaces.Authentication; -using Govor.Application.Services.Authentication; using Govor.Application.Services.UserSessions; using Govor.Core.Models; using Govor.Core.Models.Users; @@ -7,8 +5,8 @@ using Govor.Core.Repositories.UserSessionsRepository; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; using Moq; - -namespace Govor.Application.Tests.Services.UserSessions; +using Govor.Application.Interfaces.Authentication; +using Govor.Application.Services.Authentication; [TestFixture] public class UserSessionOpenerTests @@ -21,6 +19,7 @@ public class UserSessionOpenerTests private User _user; private const string DeviceInfo = "Chrome on Windows"; private const string GeneratedToken = "new-refresh-token"; + private const string NewAccessToken = "new-access-token"; [SetUp] public void Setup() @@ -30,13 +29,7 @@ public class UserSessionOpenerTests _loggerMock = new Mock>(); _options = Options.Create(new JwtRefreshOption { RefreshTokenLifetimeDays = 30 }); - _service = new UserSessionOpener( - _repositoryMock.Object, - _jwtServiceMock.Object, - _options, - _loggerMock.Object - ); - + // Инициализируем пользователя ДО моков, чтобы не было null в Setup _user = new User { Id = Guid.NewGuid(), @@ -48,16 +41,22 @@ public class UserSessionOpenerTests InviteId = Guid.NewGuid() }; - _jwtServiceMock - .Setup(j => j.GenerateRefreshTokenAsync(_user)) - .ReturnsAsync(GeneratedToken); + _jwtServiceMock.Setup(j => j.GenerateRefreshTokenAsync(_user)).ReturnsAsync(GeneratedToken); + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user)).ReturnsAsync(NewAccessToken); + + _service = new UserSessionOpener( + _repositoryMock.Object, + _jwtServiceMock.Object, + _options, + _loggerMock.Object + ); } [Test] public async Task OpenSessionAsync_ShouldReturnExistingToken_IfSessionValid() { // Arrange - var session = new Core.Models.UserSession + var session = new UserSession { UserId = _user.Id, DeviceInfo = DeviceInfo, @@ -67,23 +66,24 @@ public class UserSessionOpenerTests IsRevoked = false }; - _repositoryMock - .Setup(r => r.GetByUserIdAsync(_user.Id)) - .ReturnsAsync(new List { session }); - + _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); + // Assert + Assert.That(result.refreshToken, Is.EqualTo(GeneratedToken)); + Assert.That(result.accessToken, Is.EqualTo(NewAccessToken)); + _repositoryMock.Verify(r => r.UpdateAsync(It.Is(s => + s.RefreshToken == GeneratedToken && + s.IsRevoked == false)), Times.Once); } [Test] public async Task OpenSessionAsync_ShouldUpdateSession_IfExpiredOrRevoked() { // Arrange - var session = new Core.Models.UserSession + var session = new UserSession { UserId = _user.Id, DeviceInfo = DeviceInfo, @@ -93,35 +93,52 @@ public class UserSessionOpenerTests IsRevoked = false }; - _repositoryMock - .Setup(r => r.GetByUserIdAsync(_user.Id)) - .ReturnsAsync(new List { session }); - + _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); + Assert.That(result.refreshToken, Is.EqualTo(GeneratedToken)); + Assert.That(result.accessToken, Is.EqualTo(NewAccessToken)); + _repositoryMock.Verify(r => r.UpdateAsync(It.Is(s => + s.RefreshToken == GeneratedToken && + s.IsRevoked == false)), Times.Once); } [Test] public async Task OpenSessionAsync_ShouldCreateNewSession_IfNoneExists() { // Arrange - _repositoryMock - .Setup(r => r.GetByUserIdAsync(_user.Id)) - .ReturnsAsync(new List()); + _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)); + Assert.That(result.refreshToken, Is.EqualTo(GeneratedToken)); + Assert.That(result.accessToken, Is.EqualTo(NewAccessToken)); _repositoryMock.Verify(r => r.AddAsync(It.Is(s => s.UserId == _user.Id && s.DeviceInfo == DeviceInfo && s.RefreshToken == GeneratedToken )), Times.Once); } + + [Test] + public async Task OpenSessionAsync_ShouldCreateNewSession_WhenNotFoundByKeyExceptionThrown() + { + // Arrange + _repositoryMock + .Setup(r => r.GetByUserIdAsync(_user.Id)) + .ThrowsAsync(new Govor.Data.Repositories.Exceptions.NotFoundByKeyException(_user.Id, "userId")); + + // Act + var result = await _service.OpenSessionAsync(_user, DeviceInfo); + + // Assert + Assert.That(result.refreshToken, Is.EqualTo(GeneratedToken)); + Assert.That(result.accessToken, Is.EqualTo(NewAccessToken)); + _repositoryMock.Verify(r => r.AddAsync(It.IsAny()), Times.Once); + } } diff --git a/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs b/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs new file mode 100644 index 0000000..0577a5c --- /dev/null +++ b/Govor.Application.Tests/Services/UserSessions/UserSessionRefresherTests.cs @@ -0,0 +1,135 @@ +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.Users; +using Govor.Core.Repositories.UserSessionsRepository; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; + +namespace Govor.Application.Tests.Services.UserSessions; + +[TestFixture] +public class UserSessionRefresherTests +{ + private Mock _sessionsRepoMock; + private Mock _usersRepoMock; + private Mock _jwtServiceMock; + private Mock> _loggerMock; + private Mock> _optionsMock; + private JwtRefreshOption _options; + private UserSessionRefresher _refresher; + private const string OldRefreshToken = "old-refresh-token"; + private const string NewRefreshToken = "new-refresh-token"; + private const string NewAccessToken = "new-access-token"; + private User _user; + private UserSession _session; + + [SetUp] + public void Setup() + { + _sessionsRepoMock = new Mock(); + _usersRepoMock = new Mock(); + _jwtServiceMock = new Mock(); + _loggerMock = new Mock>(); + _optionsMock = new Mock>(); + + _options = new JwtRefreshOption { RefreshTokenLifetimeDays = 30 }; + + _optionsMock.SetupGet(o => o.Value).Returns(_options); + + _refresher = new UserSessionRefresher( + _sessionsRepoMock.Object, + _loggerMock.Object, + _usersRepoMock.Object, + _optionsMock.Object, + _jwtServiceMock.Object); + + _user = new User + { + Id = Guid.NewGuid(), + Username = "TestUser", + PasswordHash = "hash", + InviteId = Guid.NewGuid() + }; + + _session = new UserSession + { + RefreshToken = OldRefreshToken, + UserId = _user.Id, + DeviceInfo = "Chrome", + CreatedAt = DateTime.UtcNow.AddDays(-5), + ExpiresAt = DateTime.UtcNow.AddDays(5), + IsRevoked = false + }; + } + + [Test] + public async Task RefreshTokenAsync_ValidToken_ReturnsNewTokensAndCreatesNewSession() + { + // Arrange + _sessionsRepoMock.Setup(r => r.GetByRefreshTokenAsync(OldRefreshToken)).ReturnsAsync(_session); + _usersRepoMock.Setup(r => r.FindByIdAsync(_user.Id)).ReturnsAsync(_user); + _jwtServiceMock.Setup(j => j.GenerateAccessTokenAsync(_user)).ReturnsAsync(NewAccessToken); + _jwtServiceMock.Setup(j => j.GenerateRefreshTokenAsync(_user)).ReturnsAsync(NewRefreshToken); + + // Act + var result = await _refresher.RefreshTokenAsync(OldRefreshToken); + + // Assert + Assert.That(result.accessToken, Is.EqualTo(NewAccessToken)); + Assert.That(result.refreshToken, Is.EqualTo(NewRefreshToken)); + Assert.That(_session.IsRevoked, Is.True); + + _sessionsRepoMock.Verify(r => r.UpdateAsync(_session), Times.Once); + _sessionsRepoMock.Verify(r => r.AddAsync(It.Is(s => + s.UserId == _user.Id && + s.RefreshToken == NewRefreshToken && + s.DeviceInfo == _session.DeviceInfo)), Times.Once); + } + + [Test] + public void RefreshTokenAsync_RevokedToken_ThrowsUnauthorizedAccessException() + { + // Arrange + _session.IsRevoked = true; + _sessionsRepoMock.Setup(r => r.GetByRefreshTokenAsync(OldRefreshToken)).ReturnsAsync(_session); + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _refresher.RefreshTokenAsync(OldRefreshToken)); + + Assert.That(ex.Message, Contains.Substring("Refresh token is invalid or expired")); + } + + [Test] + public void RefreshTokenAsync_ExpiredToken_ThrowsUnauthorizedAccessException() + { + // Arrange + _session.ExpiresAt = DateTime.UtcNow.AddMinutes(-1); + _sessionsRepoMock.Setup(r => r.GetByRefreshTokenAsync(OldRefreshToken)).ReturnsAsync(_session); + + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _refresher.RefreshTokenAsync(OldRefreshToken)); + + Assert.That(ex.Message, Contains.Substring("Refresh token is invalid or expired")); + } + + [Test] + public void RefreshTokenAsync_TokenNotFound_ThrowsUnauthorizedAccessException() + { + // Arrange + _sessionsRepoMock.Setup(r => r.GetByRefreshTokenAsync(OldRefreshToken)) + .ThrowsAsync(new NotFoundByKeyException("token", OldRefreshToken)); + // Act & Assert + var ex = Assert.ThrowsAsync(async () => + await _refresher.RefreshTokenAsync(OldRefreshToken)); + + Assert.That(ex.Message, Contains.Substring("Invalid refresh token")); + } +} + diff --git a/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs b/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs index 641084d..243688c 100644 --- a/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs +++ b/Govor.Application/Interfaces/UserSession/IUserSessionOpener.cs @@ -5,6 +5,6 @@ namespace Govor.Application.Interfaces.UserSession; public interface IUserSessionOpener { - Task OpenSessionAsync(User user, string deviceInfo); + Task OpenSessionAsync(User user, string deviceInfo); } diff --git a/Govor.Application/Interfaces/UserSession/IUserSessionRefresher.cs b/Govor.Application/Interfaces/UserSession/IUserSessionRefresher.cs new file mode 100644 index 0000000..1020f96 --- /dev/null +++ b/Govor.Application/Interfaces/UserSession/IUserSessionRefresher.cs @@ -0,0 +1,8 @@ +namespace Govor.Application.Interfaces.UserSession; + +public interface IUserSessionRefresher +{ + Task RefreshTokenAsync(string refreshToken); +} + +public record RefreshResult(string refreshToken, string accessToken); \ No newline at end of file diff --git a/Govor.Application/Services/Authentication/JwtAccessOption.cs b/Govor.Application/Services/Authentication/JwtAccessOption.cs index 6308a92..a898c5e 100644 --- a/Govor.Application/Services/Authentication/JwtAccessOption.cs +++ b/Govor.Application/Services/Authentication/JwtAccessOption.cs @@ -1,6 +1,6 @@ namespace Govor.Application.Services.Authentication; public class JwtAccessOption { - public string SecretKeу {get; set;} + public string SecretKey {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 index 3e88c1a..51db7ad 100644 --- a/Govor.Application/Services/Authentication/JwtRefreshOption.cs +++ b/Govor.Application/Services/Authentication/JwtRefreshOption.cs @@ -3,4 +3,4 @@ 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 7110fbd..12da414 100644 --- a/Govor.Application/Services/Authentication/JwtService.cs +++ b/Govor.Application/Services/Authentication/JwtService.cs @@ -30,7 +30,7 @@ public class JwtService : IJwtService }; var singing = new SigningCredentials( - new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKeу)), + new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKey)), SecurityAlgorithms.HmacSha256Signature); var token = new JwtSecurityToken( @@ -43,7 +43,7 @@ public class JwtService : IJwtService public async Task GenerateRefreshTokenAsync(User user) { - var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKeу)); + var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKey)); var creds = new SigningCredentials(key, SecurityAlgorithms.HmacSha256); var claims = new[] @@ -68,7 +68,7 @@ public class JwtService : IJwtService ValidateAudience = false, ValidateIssuer = false, ValidateIssuerSigningKey = true, - IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKeу)), + IssuerSigningKey = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_jwtAccessOption.SecretKey)), ValidateLifetime = false // << important }; diff --git a/Govor.Application/Services/UserSessions/UserSessionOpener.cs b/Govor.Application/Services/UserSessions/UserSessionOpener.cs index e0519f3..5fe5e59 100644 --- a/Govor.Application/Services/UserSessions/UserSessionOpener.cs +++ b/Govor.Application/Services/UserSessions/UserSessionOpener.cs @@ -3,6 +3,7 @@ using Govor.Application.Interfaces.UserSession; using Govor.Application.Services.Authentication; using Govor.Core.Models.Users; using Govor.Core.Repositories.UserSessionsRepository; +using Govor.Data.Repositories.Exceptions; using Microsoft.Extensions.Logging; using Microsoft.Extensions.Options; @@ -11,58 +12,78 @@ 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; + private readonly IJwtService _jwtService; - public UserSessionOpener(IUserSessionsRepository repository, IJwtService jwtService, IOptions options, ILogger logger) + 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) + + 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) + try { - if (session.IsRevoked || session.ExpiresAt <= DateTime.UtcNow) + var sessions = await _repository.GetByUserIdAsync(user.Id); + var session = sessions.FirstOrDefault(s => s.DeviceInfo == deviceInfo); + + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + var accessToken = await _jwtService.GenerateAccessTokenAsync(user); + + var newExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays); + + if (session is not null) { - // 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}'"); + _logger.LogInformation($"Updated session for user {user.Id} on device '{deviceInfo}'"); + + return new RefreshResult(session.RefreshToken, accessToken); } - - return session.RefreshToken; + + return await OpenNewSession(); } - - // New Session - var newSession = new Core.Models.UserSession + catch (NotFoundByKeyException ex) { - UserId = user.Id, - DeviceInfo = deviceInfo, - RefreshToken = newRefreshToken, - CreatedAt = DateTime.UtcNow, - ExpiresAt = newExpiresAt, - IsRevoked = false - }; + return await OpenNewSession(); + } + + async Task OpenNewSession() + { + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + var accessToken = await _jwtService.GenerateAccessTokenAsync(user); + + var newSession = new Core.Models.UserSession + { + UserId = user.Id, + DeviceInfo = deviceInfo, + RefreshToken = newRefreshToken, + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays), + IsRevoked = false + }; - await _repository.AddAsync(newSession); - _logger.LogInformation($"Created new session for user {user.Id} on device '{deviceInfo}'"); + await _repository.AddAsync(newSession); + + _logger.LogInformation($"Created new session for user {user.Id} on device '{deviceInfo}'"); - return newRefreshToken; + return new RefreshResult(newRefreshToken, accessToken); + } } -} \ No newline at end of file +} + \ No newline at end of file diff --git a/Govor.Application/Services/UserSessions/UserSessionRefresher.cs b/Govor.Application/Services/UserSessions/UserSessionRefresher.cs new file mode 100644 index 0000000..1404404 --- /dev/null +++ b/Govor.Application/Services/UserSessions/UserSessionRefresher.cs @@ -0,0 +1,74 @@ +using Govor.Application.Interfaces.Authentication; +using Govor.Application.Interfaces.UserSession; +using Govor.Application.Services.Authentication; +using Govor.Core.Models; +using Govor.Core.Repositories.Users; +using Govor.Core.Repositories.UserSessionsRepository; +using Govor.Data.Repositories.Exceptions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Govor.Application.Services.UserSessions; + +public class UserSessionRefresher : IUserSessionRefresher +{ + private readonly IUserSessionsRepository _sessionsRepository; + private readonly ILogger _logger; + private readonly IUsersRepository _usersRepository; + private readonly JwtRefreshOption _options; + private readonly IJwtService _jwtService; + + public UserSessionRefresher( + IUserSessionsRepository sessionsRepository, + ILogger logger, + IUsersRepository usersRepository, + IOptions options, + IJwtService jwtService) + { + _sessionsRepository = sessionsRepository; + _logger = logger; + _usersRepository = usersRepository; + _options = options.Value; + _jwtService = jwtService; + } + + public async Task RefreshTokenAsync(string refreshToken) + { + try + { + var session = await _sessionsRepository.GetByRefreshTokenAsync(refreshToken); + + if (session.IsRevoked || session.ExpiresAt <= DateTime.UtcNow) + throw new UnauthorizedAccessException("Refresh token is invalid or expired"); + + session.IsRevoked = true; + await _sessionsRepository.UpdateAsync(session); + + // Find user + var user = await _usersRepository.FindByIdAsync(session.UserId); + + // New tokens + var newAccessToken = await _jwtService.GenerateAccessTokenAsync(user); + var newRefreshToken = await _jwtService.GenerateRefreshTokenAsync(user); + + // Opening new session + var newSession = new UserSession + { + UserId = user.Id, + RefreshToken = newRefreshToken, + DeviceInfo = session.DeviceInfo, + CreatedAt = DateTime.UtcNow, + ExpiresAt = DateTime.UtcNow.AddDays(_options.RefreshTokenLifetimeDays) + }; + + await _sessionsRepository.AddAsync(newSession); + + return new RefreshResult(newRefreshToken, newAccessToken); + } + catch (NotFoundByKeyException ex) + { + _logger.LogWarning(ex, ex.Message); + throw new UnauthorizedAccessException("Invalid refresh token", ex); + } + } +} \ No newline at end of file diff --git a/Govor.Contracts/Requests/RefreshTokenRequest.cs b/Govor.Contracts/Requests/RefreshTokenRequest.cs new file mode 100644 index 0000000..c4f402d --- /dev/null +++ b/Govor.Contracts/Requests/RefreshTokenRequest.cs @@ -0,0 +1,6 @@ +namespace Govor.Contracts.Requests; + +public class RefreshTokenRequest +{ + public string RefreshToken { get; set; } = null!; +} diff --git a/Govor.Contracts/Responses/RefreshTokenResponse.cs b/Govor.Contracts/Responses/RefreshTokenResponse.cs new file mode 100644 index 0000000..b2d598a --- /dev/null +++ b/Govor.Contracts/Responses/RefreshTokenResponse.cs @@ -0,0 +1,7 @@ +namespace Govor.Contracts.Responses; + +public class RefreshTokenResponse +{ + public string RefreshToken { get; set; } = null!; + public string AccessToken { get; set; } = null!; +} \ No newline at end of file diff --git a/Govor.Core/Models/UserSession.cs b/Govor.Core/Models/UserSession.cs index 23ce695..ce6cdaa 100644 --- a/Govor.Core/Models/UserSession.cs +++ b/Govor.Core/Models/UserSession.cs @@ -5,7 +5,7 @@ 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 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; diff --git a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs index 0c6e51f..6d4fc71 100644 --- a/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs +++ b/Govor.Core/Repositories/UserSessionsRepository/IUserSessionsReader.cs @@ -10,4 +10,5 @@ public interface IUserSessionsReader public Task> GetByCreatedAtAsync(DateTime createdAt); public Task> GetByExpiresAtAsync(DateTime createdAt); public Task> GetByRevokedAsync(bool isRevoked); + public Task GetByRefreshTokenAsync(string refreshToken); } \ No newline at end of file diff --git a/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs b/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs index 7e26ff4..15d6b07 100644 --- a/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/PrivateChatsRepositoryTests.cs @@ -200,5 +200,4 @@ public class PrivateChatsRepositoryTests Assert.That(result1, Is.False); Assert.That(result2, Is.False); } - } \ No newline at end of file diff --git a/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs b/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs index 632a8bd..37dd421 100644 --- a/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs +++ b/Govor.Data.Tests/Repositories/UserSessionsRepositoryTests.cs @@ -202,6 +202,41 @@ public class UserSessionsRepositoryTests Assert.ThrowsAsync>(async () => await repository.GetByRevokedAsync(false)); } + [Test] + public async Task Given_ValidRefreshToken_When_GetByRefreshTokenAsync_ShouldReturnSession() + { + // Arrange + var random = new Random(); + var sessions = _fixture.Build() + .With(f => f.IsRevoked, true) + .CreateMany(random.Next(2, 10)).ToList(); + + var token = sessions.First().RefreshToken; + + await using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + + context.UserSessions.AddRange(sessions); + await context.SaveChangesAsync(); + + // Act + var result = await repository.GetByRefreshTokenAsync(token); + + // Assert + Assert.That(result, Is.Not.Null); + Assert.That(result, Is.EqualTo(sessions.First())); + } + + [Test] + public void Given_InvalidRefreshToken_When_GetByRefreshTokenAsync_Should_Throw_NotFoundByKeyException() + { + // Arrange + using var context = new GovorDbContext(_options); + var repository = new UserSessionsRepository(context); + // Act & Assert + Assert.ThrowsAsync>(async () => await repository.GetByRefreshTokenAsync(_fixture.Create())); + } + [Test] public async Task Given_ValidUserSessions_When_AddAsync_Then_UserSessionsAdded() { diff --git a/Govor.Data/Repositories/UserSessionsRepository.cs b/Govor.Data/Repositories/UserSessionsRepository.cs index a4371f4..c4ed8d1 100644 --- a/Govor.Data/Repositories/UserSessionsRepository.cs +++ b/Govor.Data/Repositories/UserSessionsRepository.cs @@ -62,6 +62,14 @@ public class UserSessionsRepository : IUserSessionsRepository .ToListOrThrowIfEmpty(new NotFoundByKeyException(isRevoked, "Sessions is revoked does not exist")); } + public async Task GetByRefreshTokenAsync(string refreshToken) + { + return await _context.UserSessions + .AsNoTracking() + .FirstOrDefaultAsync(session => session.RefreshToken == refreshToken) + ?? throw new NotFoundByKeyException(refreshToken, "Session with given refresh token does not exist"); + } + public async Task AddAsync(UserSession userSession) { try